From 1ff3bde3abc6cd97142e1f8890379b9b13e73718 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 16:10:46 -0400 Subject: [PATCH 01/33] Add reusable TypeScript components --- .github/workflows/ci.yml | 34 + .gitignore | 6 + .prettierignore | 6 + COMPONENTS.md | 156 ++ COMPONENTS_AUTHORING.md | 131 + COMPONENTS_GETTING_STARTED.md | 173 ++ NPM_RELEASE_CHECKLIST.md | 148 ++ README.md | 5 + eslint.config.js | 14 +- package.json | 23 +- pnpm-lock.yaml | 2129 +++++++++++++---- pnpm-workspace.yaml | 4 + spacetime-agents-ts/LICENSE.txt | 759 ++++++ spacetime-agents-ts/README.md | 142 ++ spacetime-agents-ts/example/.env.example | 40 + spacetime-agents-ts/example/.gitignore | 5 + spacetime-agents-ts/example/README.md | 229 ++ spacetime-agents-ts/example/package.json | 30 + .../example/public/assets/brand.svg | 17 + .../example/public/assets/logo.svg | 5 + spacetime-agents-ts/example/public/index.html | 494 ++++ .../example/public/markdown.js | 46 + spacetime-agents-ts/example/public/styles.css | 1389 +++++++++++ spacetime-agents-ts/example/public/ui.js | 1193 +++++++++ .../example/scripts/test-markdown.mjs | 35 + spacetime-agents-ts/example/server.ts | 263 ++ .../example/spacetimedb/package.json | 23 + .../example/spacetimedb/scripts/test-loop.ts | 1557 ++++++++++++ .../example/spacetimedb/scripts/tsconfig.json | 4 + .../example/spacetimedb/src/agents/chat.ts | 20 + .../example/spacetimedb/src/agents/index.ts | 7 + .../spacetimedb/src/agents/summarizer.ts | 17 + .../example/spacetimedb/src/attachments.ts | 39 + .../example/spacetimedb/src/index.ts | 1058 ++++++++ .../example/spacetimedb/src/loop.ts | 254 ++ .../example/spacetimedb/src/model.ts | 132 + .../example/spacetimedb/src/runtime.ts | 384 +++ .../example/spacetimedb/src/summarize.ts | 76 + .../example/spacetimedb/src/sweeper.ts | 14 + .../example/spacetimedb/src/tools/echo.ts | 10 + .../example/spacetimedb/src/tools/getTime.ts | 16 + .../example/spacetimedb/src/tools/index.ts | 2 + .../example/spacetimedb/src/types.ts | 6 + .../example/spacetimedb/src/views.ts | 115 + .../example/spacetimedb/tsconfig.json | 14 + spacetime-agents-ts/example/src/app.ts | 591 +++++ spacetime-agents-ts/example/tsconfig.json | 14 + spacetime-agents-ts/package.json | 78 + spacetime-agents-ts/scripts/test-kit.ts | 1189 +++++++++ spacetime-agents-ts/spacetimedb/package.json | 19 + spacetime-agents-ts/spacetimedb/src/index.ts | 1033 ++++++++ .../spacetimedb/src/install.ts | 22 + spacetime-agents-ts/spacetimedb/src/loop.ts | 231 ++ spacetime-agents-ts/spacetimedb/src/model.ts | 100 + .../spacetimedb/src/submodule.ts | 25 + .../spacetimedb/src/summarize.ts | 66 + spacetime-agents-ts/spacetimedb/tsconfig.json | 12 + spacetime-agents-ts/src/embeddings.ts | 196 ++ spacetime-agents-ts/src/index.ts | 46 + spacetime-agents-ts/src/kit.ts | 617 +++++ spacetime-agents-ts/src/openrouter.ts | 162 ++ spacetime-agents-ts/src/providers.ts | 290 +++ spacetime-agents-ts/src/stale-locks.ts | 34 + spacetime-agents-ts/tsconfig.json | 15 + spacetime-api-keys-ts/LICENSE.txt | 759 ++++++ spacetime-api-keys-ts/README.md | 198 ++ spacetime-api-keys-ts/example/.env.example | 5 + spacetime-api-keys-ts/example/README.md | 198 ++ spacetime-api-keys-ts/example/package.json | 32 + .../example/public/assets/brand.svg | 17 + .../example/public/index.html | 239 ++ .../example/public/styles.css | 1100 +++++++++ .../example/scripts/test-model.ts | 117 + .../example/scripts/test-share-key.ts | 15 + spacetime-api-keys-ts/example/server.ts | 102 + .../example/spacetimedb/package.json | 22 + .../example/spacetimedb/src/index.ts | 1106 +++++++++ .../example/spacetimedb/tsconfig.json | 15 + spacetime-api-keys-ts/example/src/app.ts | 1199 ++++++++++ .../app/apiKeys/add_admin_identity_reducer.ts | 15 + .../app/apiKeys/api_key_usage_admin_table.ts | 22 + .../app/apiKeys/api_keys_admin_table.ts | 31 + .../create_api_key_for_subject_procedure.ts | 25 + .../app/apiKeys/create_api_key_procedure.ts | 24 + .../codegen/app/apiKeys/my_api_keys_table.ts | 31 + .../apiKeys/remove_admin_identity_reducer.ts | 15 + .../revoke_api_key_for_subject_reducer.ts | 16 + .../app/apiKeys/revoke_api_key_reducer.ts | 15 + .../app/apiKeys/rotate_api_key_procedure.ts | 22 + .../apiKeys/sweep_api_key_usage_reducer.ts | 16 + .../example/src/codegen/app/apiKeys/types.ts | 111 + .../example/src/codegen/app/build_reducer.ts | 18 + .../example/src/codegen/app/clear_reducer.ts | 16 + .../codegen/app/clear_world_events_reducer.ts | 13 + .../src/codegen/app/colony_cells_table.ts | 20 + .../src/codegen/app/colony_entities_table.ts | 24 + .../src/codegen/app/colony_grid_table.ts | 26 + .../app/create_access_key_procedure.ts | 24 + .../src/codegen/app/ensure_world_procedure.ts | 19 + .../example/src/codegen/app/grid/types.ts | 69 + .../example/src/codegen/app/index.ts | 324 +++ .../src/codegen/app/my_access_keys_table.ts | 31 + .../example/src/codegen/app/plant_reducer.ts | 17 + .../src/codegen/app/presence_entry_table.ts | 24 + .../codegen/app/presence_heartbeat_reducer.ts | 21 + .../src/codegen/app/presence_leave_reducer.ts | 15 + .../src/codegen/app/reset_world_reducer.ts | 13 + .../codegen/app/revoke_access_key_reducer.ts | 15 + .../app/rotate_access_key_procedure.ts | 22 + .../src/codegen/app/terraform_reducer.ts | 17 + .../example/src/codegen/app/types.ts | 159 ++ .../src/codegen/app/types/procedures.ts | 19 + .../example/src/codegen/app/types/reducers.ts | 30 + .../src/codegen/app/unbuild_reducer.ts | 16 + .../src/codegen/app/world_event_table.ts | 22 + .../example/src/codegen/app/world_table.ts | 19 + spacetime-api-keys-ts/example/src/model.ts | 381 +++ .../example/src/share-key.ts | 16 + spacetime-api-keys-ts/example/tsconfig.json | 16 + spacetime-api-keys-ts/package.json | 63 + spacetime-api-keys-ts/scripts/test.ts | 30 + spacetime-api-keys-ts/src/index.ts | 14 + spacetime-api-keys-ts/src/key-utils.ts | 79 + spacetime-api-keys-ts/src/submodule.ts | 33 + spacetime-api-keys-ts/src/submodule/auth.ts | 19 + .../src/submodule/install.ts | 9 + .../src/submodule/operations.ts | 696 ++++++ spacetime-api-keys-ts/src/submodule/schema.ts | 130 + spacetime-api-keys-ts/tsconfig.json | 15 + spacetime-auth-ts/LICENSE.txt | 759 ++++++ spacetime-auth-ts/README.md | 181 ++ spacetime-auth-ts/example/.env.example | 32 + spacetime-auth-ts/example/.gitignore | 12 + spacetime-auth-ts/example/README.md | 195 ++ spacetime-auth-ts/example/package.json | 29 + .../example/public/assets/brand.svg | 17 + .../example/public/assets/logo.svg | 5 + spacetime-auth-ts/example/public/index.html | 280 +++ spacetime-auth-ts/example/public/styles.css | 1069 +++++++++ spacetime-auth-ts/example/public/ui.js | 488 ++++ spacetime-auth-ts/example/server.ts | 201 ++ .../example/spacetimedb/package.json | 20 + .../example/spacetimedb/src/index.ts | 301 +++ .../example/spacetimedb/tsconfig.json | 14 + spacetime-auth-ts/example/src/app.ts | 395 +++ spacetime-auth-ts/example/tsconfig.json | 14 + spacetime-auth-ts/package.json | 85 + spacetime-auth-ts/scripts/test.ts | 345 +++ spacetime-auth-ts/spacetimedb/package.json | 20 + spacetime-auth-ts/spacetimedb/src/index.ts | 2 + spacetime-auth-ts/spacetimedb/tsconfig.json | 14 + spacetime-auth-ts/src/admin.ts | 34 + spacetime-auth-ts/src/caller.ts | 48 + spacetime-auth-ts/src/context.ts | 37 + spacetime-auth-ts/src/crypto.ts | 169 ++ spacetime-auth-ts/src/handlers/_helpers.ts | 165 ++ .../src/handlers/email_verify.ts | 178 ++ spacetime-auth-ts/src/handlers/github.ts | 97 + spacetime-auth-ts/src/handlers/google.ts | 36 + spacetime-auth-ts/src/handlers/index.ts | 33 + spacetime-auth-ts/src/handlers/oauth.ts | 451 ++++ spacetime-auth-ts/src/handlers/password.ts | 285 +++ .../src/handlers/password_reset.ts | 196 ++ spacetime-auth-ts/src/handlers/session.ts | 229 ++ spacetime-auth-ts/src/index.ts | 156 ++ spacetime-auth-ts/src/jwt.ts | 193 ++ spacetime-auth-ts/src/keys.ts | 281 +++ spacetime-auth-ts/src/mailer.ts | 46 + spacetime-auth-ts/src/mounted/index.ts | 340 +++ spacetime-auth-ts/src/mounted/install.ts | 26 + spacetime-auth-ts/src/procedures.ts | 375 +++ spacetime-auth-ts/src/rate_limit.ts | 110 + spacetime-auth-ts/src/request-trust.ts | 74 + spacetime-auth-ts/src/submodule.ts | 61 + spacetime-auth-ts/src/tables.ts | 137 ++ spacetime-auth-ts/src/types.ts | 18 + spacetime-auth-ts/tsconfig.json | 15 + spacetime-cron-ts/DESIGN.md | 351 +++ spacetime-cron-ts/LICENSE.txt | 759 ++++++ spacetime-cron-ts/README.md | 389 +++ spacetime-cron-ts/example/.env.example | 9 + spacetime-cron-ts/example/README.md | 202 ++ spacetime-cron-ts/example/package.json | 28 + .../example/public/assets/logo.svg | 5 + spacetime-cron-ts/example/public/index.html | 272 +++ spacetime-cron-ts/example/public/styles.css | 982 ++++++++ spacetime-cron-ts/example/server.ts | 53 + .../example/spacetimedb/package.json | 19 + .../example/spacetimedb/src/index.ts | 177 ++ .../example/spacetimedb/tsconfig.json | 13 + spacetime-cron-ts/example/src/app.ts | 437 ++++ .../src/codegen/app/activity_log_table.ts | 18 + .../src/codegen/app/cleanup_fire_table.ts | 26 + .../src/codegen/app/cron_jobs_table.ts | 30 + .../codegen/app/cron_reconcile_tick_table.ts | 17 + .../example/src/codegen/app/cron_run_table.ts | 28 + .../src/codegen/app/digest_fire_table.ts | 26 + .../example/src/codegen/app/index.ts | 203 ++ .../src/codegen/app/schedule_cron_reducer.ts | 18 + .../src/codegen/app/schedule_every_reducer.ts | 17 + .../example/src/codegen/app/types.ts | 153 ++ .../src/codegen/app/types/procedures.ts | 10 + .../example/src/codegen/app/types/reducers.ts | 16 + .../src/codegen/app/unschedule_job_reducer.ts | 15 + spacetime-cron-ts/example/tsconfig.json | 14 + spacetime-cron-ts/package.json | 69 + .../scripts/sys-abi-test-register.mjs | 19 + spacetime-cron-ts/scripts/test-cron-parser.ts | 210 ++ .../scripts/test-module-local.mjs | 509 ++++ spacetime-cron-ts/scripts/test-recovery.mjs | 307 +++ .../scripts/test-registration.ts | 218 ++ spacetime-cron-ts/scripts/test-schedule.ts | 199 ++ spacetime-cron-ts/scripts/test-types.ts | 67 + spacetime-cron-ts/scripts/test-validation.ts | 97 + spacetime-cron-ts/spacetimedb/package.json | 19 + spacetime-cron-ts/spacetimedb/src/index.ts | 338 +++ spacetime-cron-ts/spacetimedb/tsconfig.json | 12 + spacetime-cron-ts/src/cron.ts | 1137 +++++++++ spacetime-cron-ts/src/index.ts | 28 + spacetime-cron-ts/src/parser.ts | 58 + spacetime-cron-ts/src/schedule.ts | 184 ++ spacetime-cron-ts/src/sys-abi.d.ts | 10 + spacetime-cron-ts/src/types.ts | 164 ++ spacetime-cron-ts/tsconfig.json | 15 + spacetime-crypto-ts/LICENSE.txt | 759 ++++++ spacetime-crypto-ts/README.md | 71 + spacetime-crypto-ts/package.json | 59 + spacetime-crypto-ts/scripts/test-vectors.ts | 340 +++ spacetime-crypto-ts/src/hmac.ts | 8 + spacetime-crypto-ts/src/index.ts | 19 + spacetime-crypto-ts/src/sha256.ts | 10 + spacetime-crypto-ts/src/timing.ts | 113 + spacetime-crypto-ts/src/vendors.ts | 163 ++ spacetime-crypto-ts/tsconfig.json | 14 + spacetime-files-ts/LICENSE.txt | 759 ++++++ spacetime-files-ts/README.md | 259 ++ spacetime-files-ts/example/.env.example | 7 + spacetime-files-ts/example/.gitignore | 3 + spacetime-files-ts/example/README.md | 164 ++ spacetime-files-ts/example/package.json | 29 + .../example/public/assets/brand.svg | 17 + .../example/public/assets/logo.svg | 5 + spacetime-files-ts/example/public/index.html | 594 +++++ spacetime-files-ts/example/public/styles.css | 1268 ++++++++++ .../example/scripts/test-downloads.ts | 120 + .../example/scripts/test-selection.ts | 49 + spacetime-files-ts/example/server.ts | 104 + .../example/spacetimedb/package.json | 19 + .../example/spacetimedb/src/index.ts | 444 ++++ .../example/spacetimedb/tsconfig.json | 15 + spacetime-files-ts/example/src/app.ts | 1195 +++++++++ .../src/codegen/app/create_folder_reducer.ts | 15 + .../src/codegen/app/delete_file_reducer.ts | 15 + .../src/codegen/app/delete_folder_reducer.ts | 15 + .../example/src/codegen/app/files/types.ts | 32 + .../example/src/codegen/app/index.ts | 142 ++ .../src/codegen/app/move_file_reducer.ts | 16 + .../codegen/app/my_file_summaries_table.ts | 21 + .../src/codegen/app/my_folders_table.ts | 21 + .../codegen/app/read_file_bytes_procedure.ts | 20 + .../src/codegen/app/rename_file_reducer.ts | 16 + .../src/codegen/app/rename_folder_reducer.ts | 16 + .../app/set_file_visibility_reducer.ts | 16 + .../example/src/codegen/app/types.ts | 46 + .../src/codegen/app/types/procedures.ts | 13 + .../example/src/codegen/app/types/reducers.ts | 26 + .../src/codegen/app/upload_file_reducer.ts | 18 + .../example/src/context-menu.ts | 135 ++ spacetime-files-ts/example/src/dialog.ts | 97 + spacetime-files-ts/example/src/downloads.ts | 126 + spacetime-files-ts/example/src/drop-target.ts | 68 + spacetime-files-ts/example/src/keyboard.ts | 96 + .../example/src/list-actions.ts | 157 ++ spacetime-files-ts/example/src/rendering.ts | 227 ++ spacetime-files-ts/example/src/selection.ts | 71 + spacetime-files-ts/example/src/uploads.ts | 217 ++ spacetime-files-ts/example/src/utils.ts | 154 ++ spacetime-files-ts/example/src/viewer.ts | 180 ++ spacetime-files-ts/example/src/zip.ts | 130 + spacetime-files-ts/example/tsconfig.json | 15 + spacetime-files-ts/package.json | 79 + spacetime-files-ts/scripts/test.ts | 41 + spacetime-files-ts/src/constants.ts | 5 + spacetime-files-ts/src/handlers.ts | 155 ++ spacetime-files-ts/src/hash.ts | 10 + spacetime-files-ts/src/index.ts | 43 + spacetime-files-ts/src/procedures.ts | 304 +++ spacetime-files-ts/src/query.ts | 17 + spacetime-files-ts/src/rows.ts | 39 + spacetime-files-ts/src/submodule.ts | 7 + spacetime-files-ts/src/submodule/install.ts | 6 + spacetime-files-ts/src/submodule/schema.ts | 42 + spacetime-files-ts/src/validation.ts | 79 + spacetime-files-ts/tsconfig.json | 14 + spacetime-grid-ts/LICENSE.txt | 759 ++++++ spacetime-grid-ts/README.md | 232 ++ spacetime-grid-ts/example/.env.example | 32 + spacetime-grid-ts/example/README.md | 167 ++ spacetime-grid-ts/example/package.json | 31 + .../example/public/assets/brand.svg | 17 + .../example/public/assets/logo.svg | 5 + .../example/public/hex-geometry.js | 111 + spacetime-grid-ts/example/public/index.html | 262 ++ spacetime-grid-ts/example/public/styles.css | 682 ++++++ spacetime-grid-ts/example/public/ui.js | 1186 +++++++++ .../example/scripts/test-hex-geometry.mjs | 60 + spacetime-grid-ts/example/server.ts | 196 ++ .../example/spacetimedb/package.json | 21 + .../example/spacetimedb/src/auth-adapter.ts | 181 ++ .../example/spacetimedb/src/index.ts | 966 ++++++++ .../example/spacetimedb/src/schema.ts | 140 ++ .../example/spacetimedb/src/views.ts | 243 ++ .../example/spacetimedb/tsconfig.json | 14 + spacetime-grid-ts/example/src/app.ts | 502 ++++ .../src/codegen/app/actor_directory_table.ts | 24 + .../src/codegen/app/ai_take_turn_procedure.ts | 20 + .../src/codegen/app/attack_unit_procedure.ts | 17 + .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../codegen/app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../src/codegen/app/auth/rateLimit/types.ts | 56 + .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../example/src/codegen/app/auth/types.ts | 137 ++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../src/codegen/app/auth/whoami_procedure.ts | 19 + .../src/codegen/app/create_match_procedure.ts | 20 + .../src/codegen/app/end_turn_procedure.ts | 16 + .../app/get_auth_public_key_procedure.ts | 19 + .../app/get_cells_in_range_procedure.ts | 23 + .../example/src/codegen/app/grid/types.ts | 69 + .../example/src/codegen/app/index.ts | 360 +++ .../src/codegen/app/join_match_procedure.ts | 16 + .../codegen/app/link_connection_reducer.ts | 15 + .../codegen/app/list_my_sessions_procedure.ts | 19 + .../codegen/app/lobby_open_matches_table.ts | 17 + .../src/codegen/app/move_unit_procedure.ts | 22 + .../src/codegen/app/my_auth_user_table.ts | 21 + .../src/codegen/app/my_cell_states_table.ts | 20 + .../src/codegen/app/my_grid_entities_table.ts | 24 + .../example/src/codegen/app/my_grids_table.ts | 26 + .../app/my_match_participants_table.ts | 20 + .../src/codegen/app/my_matches_table.ts | 28 + .../src/codegen/app/my_player_units_table.ts | 22 + .../src/codegen/app/npc_actor_table.ts | 18 + .../codegen/app/revoke_my_session_reducer.ts | 15 + .../src/codegen/app/revoke_session_reducer.ts | 15 + .../codegen/app/set_auth_config_reducer.ts | 23 + .../example/src/codegen/app/types.ts | 276 +++ .../src/codegen/app/types/procedures.ts | 40 + .../example/src/codegen/app/types/reducers.ts | 22 + .../src/codegen/app/unit_type_table.ts | 21 + .../codegen/app/unlink_connection_reducer.ts | 13 + .../src/codegen/app/update_profile_reducer.ts | 16 + .../src/codegen/app/whoami_procedure.ts | 19 + spacetime-grid-ts/example/tsconfig.json | 14 + spacetime-grid-ts/package.json | 72 + spacetime-grid-ts/scripts/test-pathfind.ts | 228 ++ spacetime-grid-ts/src/index.ts | 38 + spacetime-grid-ts/src/math/coords.ts | 19 + spacetime-grid-ts/src/math/distance.ts | 34 + spacetime-grid-ts/src/math/index.ts | 22 + spacetime-grid-ts/src/math/neighbors.ts | 44 + spacetime-grid-ts/src/math/pathfind.ts | 189 ++ spacetime-grid-ts/src/procedures.ts | 535 +++++ spacetime-grid-ts/src/rows.ts | 74 + spacetime-grid-ts/src/submodule.ts | 6 + spacetime-grid-ts/src/submodule/install.ts | 6 + spacetime-grid-ts/src/submodule/schema.ts | 46 + spacetime-grid-ts/tsconfig.json | 14 + spacetime-lobby-ts/LICENSE.txt | 759 ++++++ spacetime-lobby-ts/README.md | 177 ++ spacetime-lobby-ts/example/.env.example | 4 + spacetime-lobby-ts/example/.gitignore | 6 + spacetime-lobby-ts/example/README.md | 167 ++ spacetime-lobby-ts/example/package.json | 28 + .../example/public/assets/brand.svg | 17 + spacetime-lobby-ts/example/public/index.html | 132 + spacetime-lobby-ts/example/public/styles.css | 1480 ++++++++++++ .../example/scripts/test-model.ts | 181 ++ spacetime-lobby-ts/example/server.ts | 47 + .../example/spacetimedb/package.json | 19 + .../example/spacetimedb/src/catalog.ts | 210 ++ .../example/spacetimedb/src/index.ts | 835 +++++++ .../example/spacetimedb/src/schema.ts | 242 ++ .../example/spacetimedb/src/views.ts | 254 ++ .../example/spacetimedb/tsconfig.json | 22 + spacetime-lobby-ts/example/src/app.ts | 1069 +++++++++ spacetime-lobby-ts/example/src/model.ts | 300 +++ spacetime-lobby-ts/example/tsconfig.json | 22 + spacetime-lobby-ts/package.json | 65 + spacetime-lobby-ts/scripts/test.ts | 36 + spacetime-lobby-ts/src/index.ts | 27 + spacetime-lobby-ts/src/keys.ts | 3 + spacetime-lobby-ts/src/matchmaking.ts | 67 + spacetime-lobby-ts/src/submodule.ts | 56 + spacetime-lobby-ts/src/submodule/install.ts | 22 + .../src/submodule/operations.ts | 814 +++++++ spacetime-lobby-ts/src/submodule/schema.ts | 270 +++ spacetime-lobby-ts/src/submodule/views.ts | 256 ++ spacetime-lobby-ts/tsconfig.json | 22 + spacetime-posthog-ts/.gitignore | 5 + spacetime-posthog-ts/LICENSE.txt | 759 ++++++ spacetime-posthog-ts/README.md | 171 ++ spacetime-posthog-ts/example/.env.example | 10 + spacetime-posthog-ts/example/.gitignore | 7 + spacetime-posthog-ts/example/README.md | 166 ++ .../example/catalog/catalog.ts | 240 ++ spacetime-posthog-ts/example/package.json | 28 + .../example/public/assets/brand.svg | 17 + .../example/public/index.html | 314 +++ .../example/public/styles.css | 1159 +++++++++ .../example/scripts/test-economy.ts | 38 + spacetime-posthog-ts/example/server.ts | 277 +++ .../example/spacetimedb/package.json | 19 + .../example/spacetimedb/src/catalog.ts | 149 ++ .../example/spacetimedb/src/economy.ts | 246 ++ .../example/spacetimedb/src/index.ts | 1006 ++++++++ .../example/spacetimedb/src/recent.ts | 9 + .../example/spacetimedb/src/schema.ts | 331 +++ .../example/spacetimedb/src/validation.ts | 26 + .../example/spacetimedb/src/views.ts | 169 ++ .../example/spacetimedb/tsconfig.json | 22 + spacetime-posthog-ts/example/src/app.ts | 1007 ++++++++ spacetime-posthog-ts/example/tsconfig.json | 22 + spacetime-posthog-ts/package.json | 65 + spacetime-posthog-ts/scripts/test.ts | 92 + spacetime-posthog-ts/src/index.ts | 16 + spacetime-posthog-ts/src/submodule.ts | 26 + spacetime-posthog-ts/src/submodule/auth.ts | 58 + spacetime-posthog-ts/src/submodule/config.ts | 80 + spacetime-posthog-ts/src/submodule/http.ts | 29 + spacetime-posthog-ts/src/submodule/install.ts | 9 + .../src/submodule/operations.ts | 511 ++++ .../src/submodule/outbox-state.ts | 86 + spacetime-posthog-ts/src/submodule/schema.ts | 182 ++ spacetime-posthog-ts/src/submodule/utils.ts | 29 + .../src/submodule/value-utils.ts | 16 + spacetime-posthog-ts/tsconfig.json | 22 + spacetime-presence-ts/LICENSE.txt | 759 ++++++ spacetime-presence-ts/README.md | 149 ++ spacetime-presence-ts/example/.env.example | 26 + spacetime-presence-ts/example/README.md | 184 ++ spacetime-presence-ts/example/package.json | 28 + .../example/public/assets/brand.svg | 17 + .../example/public/assets/logo.svg | 5 + .../example/public/chat-model.js | 170 ++ .../example/public/chat-state.js | 27 + spacetime-presence-ts/example/public/chat.css | 1390 +++++++++++ .../example/public/index.html | 702 ++++++ .../example/public/styles.css | 1361 +++++++++++ spacetime-presence-ts/example/public/ui.js | 1896 +++++++++++++++ .../example/scripts/test-ui-model.mjs | 111 + spacetime-presence-ts/example/server.ts | 202 ++ .../example/spacetimedb/package.json | 21 + .../example/spacetimedb/src/chat-policy.ts | 32 + .../example/spacetimedb/src/domain.ts | 331 +++ .../example/spacetimedb/src/index.ts | 1271 ++++++++++ .../example/spacetimedb/src/model.ts | 192 ++ .../example/spacetimedb/src/views.ts | 376 +++ .../example/spacetimedb/tsconfig.json | 15 + spacetime-presence-ts/example/src/app.ts | 748 ++++++ .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../codegen/app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../src/codegen/app/auth/rateLimit/types.ts | 56 + .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../example/src/codegen/app/auth/types.ts | 137 ++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../src/codegen/app/auth/whoami_procedure.ts | 19 + .../src/codegen/app/create_room_reducer.ts | 18 + .../src/codegen/app/create_server_reducer.ts | 15 + .../src/codegen/app/delete_message_reducer.ts | 15 + .../src/codegen/app/delete_room_reducer.ts | 15 + .../src/codegen/app/delete_server_reducer.ts | 15 + .../app/delete_thread_message_reducer.ts | 15 + .../src/codegen/app/edit_message_reducer.ts | 16 + .../app/edit_thread_message_reducer.ts | 16 + .../example/src/codegen/app/files/types.ts | 32 + .../app/get_attachment_file_procedure.ts | 20 + .../app/get_auth_public_key_procedure.ts | 19 + .../src/codegen/app/heartbeat_reducer.ts | 13 + .../example/src/codegen/app/index.ts | 488 ++++ .../src/codegen/app/join_room_reducer.ts | 15 + .../src/codegen/app/join_server_reducer.ts | 15 + .../src/codegen/app/leave_room_reducer.ts | 15 + .../src/codegen/app/leave_server_reducer.ts | 15 + .../codegen/app/link_connection_reducer.ts | 15 + .../codegen/app/list_my_sessions_procedure.ts | 19 + .../src/codegen/app/mark_room_read_reducer.ts | 15 + .../src/codegen/app/my_auth_user_table.ts | 21 + .../src/codegen/app/my_chat_users_table.ts | 27 + .../codegen/app/my_message_threads_table.ts | 20 + .../codegen/app/my_presence_entries_table.ts | 24 + .../codegen/app/my_rate_limit_status_table.ts | 19 + .../codegen/app/my_room_attachments_table.ts | 27 + .../src/codegen/app/my_room_members_table.ts | 19 + .../app/my_room_message_reactions_table.ts | 19 + .../src/codegen/app/my_room_messages_table.ts | 23 + .../codegen/app/my_room_read_cursors_table.ts | 19 + .../example/src/codegen/app/my_rooms_table.ts | 24 + .../codegen/app/my_server_members_table.ts | 19 + .../src/codegen/app/my_servers_table.ts | 18 + .../codegen/app/my_thread_messages_table.ts | 20 + .../src/codegen/app/pin_message_reducer.ts | 15 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/rateLimit/consume_procedure.ts | 24 + .../app/rateLimit/rate_limit_config_table.ts | 17 + .../app/rateLimit/reset_buckets_reducer.ts | 15 + .../app/rateLimit/run_sweep_procedure.ts | 16 + .../src/codegen/app/rateLimit/types.ts | 56 + .../app/rateLimit/update_config_reducer.ts | 15 + .../src/codegen/app/rename_room_reducer.ts | 16 + .../src/codegen/app/rename_server_reducer.ts | 16 + .../codegen/app/revoke_my_session_reducer.ts | 15 + .../src/codegen/app/revoke_session_reducer.ts | 15 + .../codegen/app/search_messages_procedure.ts | 21 + .../src/codegen/app/send_message_reducer.ts | 24 + .../app/send_thread_message_reducer.ts | 16 + .../codegen/app/set_auth_config_reducer.ts | 23 + .../codegen/app/set_display_name_reducer.ts | 15 + .../codegen/app/set_room_category_reducer.ts | 16 + .../codegen/app/set_room_privacy_reducer.ts | 16 + .../src/codegen/app/set_status_reducer.ts | 21 + .../src/codegen/app/start_typing_reducer.ts | 15 + .../src/codegen/app/stop_typing_reducer.ts | 15 + .../codegen/app/toggle_reaction_reducer.ts | 16 + .../example/src/codegen/app/types.ts | 296 +++ .../src/codegen/app/types/procedures.ts | 25 + .../example/src/codegen/app/types/reducers.ts | 76 + .../codegen/app/unlink_connection_reducer.ts | 13 + .../src/codegen/app/unpin_message_reducer.ts | 15 + .../src/codegen/app/update_profile_reducer.ts | 16 + .../src/codegen/app/whoami_procedure.ts | 19 + spacetime-presence-ts/example/tsconfig.json | 15 + spacetime-presence-ts/package.json | 68 + spacetime-presence-ts/scripts/test.ts | 166 ++ .../spacetimedb/package.json | 19 + .../spacetimedb/src/index.ts | 2 + .../spacetimedb/tsconfig.json | 12 + spacetime-presence-ts/src/index.ts | 30 + spacetime-presence-ts/src/mounted/index.ts | 250 ++ spacetime-presence-ts/src/mounted/install.ts | 33 + spacetime-presence-ts/src/presence.ts | 263 ++ spacetime-presence-ts/src/submodule.ts | 11 + spacetime-presence-ts/src/tables.ts | 60 + spacetime-presence-ts/tsconfig.json | 15 + spacetime-rate-limit-ts/LICENSE.txt | 759 ++++++ spacetime-rate-limit-ts/README.md | 151 ++ spacetime-rate-limit-ts/example/.env.example | 4 + spacetime-rate-limit-ts/example/README.md | 178 ++ spacetime-rate-limit-ts/example/package.json | 28 + .../example/public/assets/brand.svg | 17 + .../example/public/assets/logo.svg | 5 + .../example/public/index.html | 174 ++ .../example/public/styles.css | 1310 ++++++++++ spacetime-rate-limit-ts/example/public/ui.js | 763 ++++++ .../example/scripts/test-reactor-rules.ts | 44 + spacetime-rate-limit-ts/example/server.ts | 40 + .../example/spacetimedb/package.json | 19 + .../example/spacetimedb/src/index.ts | 1167 +++++++++ .../example/spacetimedb/src/model.ts | 74 + .../example/spacetimedb/src/reactor-rules.ts | 216 ++ .../example/spacetimedb/tsconfig.json | 11 + spacetime-rate-limit-ts/example/src/app.ts | 409 ++++ .../src/codegen/app/buy_upgrade_procedure.ts | 20 + .../example/src/codegen/app/index.ts | 257 ++ .../src/codegen/app/overcharge_procedure.ts | 19 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/rateLimit/consume_procedure.ts | 24 + .../app/rateLimit/rate_limit_config_table.ts | 17 + .../app/rateLimit/reset_buckets_reducer.ts | 15 + .../app/rateLimit/run_sweep_procedure.ts | 16 + .../src/codegen/app/rateLimit/types.ts | 56 + .../app/rateLimit/update_config_reducer.ts | 15 + .../app/rate_limit_demo_config_table.ts | 18 + .../app/rate_limit_events_admin_table.ts | 26 + .../src/codegen/app/reactor_events_table.ts | 25 + .../codegen/app/reactor_limit_status_table.ts | 21 + .../src/codegen/app/reactor_players_table.ts | 24 + .../src/codegen/app/reactor_shop_table.ts | 21 + .../src/codegen/app/reactor_state_table.ts | 31 + .../codegen/app/repair_reactor_procedure.ts | 19 + .../src/codegen/app/reset_demo_reducer.ts | 13 + .../src/codegen/app/run_sweep_procedure.ts | 16 + .../codegen/app/set_player_color_reducer.ts | 15 + .../codegen/app/start_reactor_procedure.ts | 19 + .../src/codegen/app/tap_reactor_procedure.ts | 19 + .../example/src/codegen/app/types.ts | 157 ++ .../src/codegen/app/types/procedures.ts | 28 + .../example/src/codegen/app/types/reducers.ts | 16 + .../src/codegen/app/update_config_reducer.ts | 17 + spacetime-rate-limit-ts/example/tsconfig.json | 15 + spacetime-rate-limit-ts/package.json | 64 + spacetime-rate-limit-ts/scripts/test.ts | 150 ++ .../spacetimedb/package.json | 19 + .../spacetimedb/src/index.ts | 17 + .../spacetimedb/src/install.ts | 1 + .../spacetimedb/src/submodule.ts | 2 + .../spacetimedb/tsconfig.json | 12 + spacetime-rate-limit-ts/src/index.ts | 15 + spacetime-rate-limit-ts/src/key.ts | 7 + spacetime-rate-limit-ts/src/limit.ts | 290 +++ spacetime-rate-limit-ts/src/submodule.ts | 26 + .../src/submodule/install.ts | 12 + .../src/submodule/operations.ts | 190 ++ .../src/submodule/schema.ts | 77 + spacetime-rate-limit-ts/tsconfig.json | 15 + spacetime-resend-ts/.gitignore | 5 + spacetime-resend-ts/LICENSE.txt | 759 ++++++ spacetime-resend-ts/README.md | 232 ++ spacetime-resend-ts/example/.env.example | 28 + spacetime-resend-ts/example/.gitignore | 10 + spacetime-resend-ts/example/README.md | 181 ++ spacetime-resend-ts/example/package.json | 29 + .../example/public/assets/brand.svg | 17 + spacetime-resend-ts/example/public/index.html | 151 ++ spacetime-resend-ts/example/public/styles.css | 885 +++++++ .../example/scripts/test-message.ts | 15 + spacetime-resend-ts/example/server.ts | 241 ++ .../example/spacetimedb/package.json | 20 + .../example/spacetimedb/src/index.ts | 270 +++ .../example/spacetimedb/src/message.ts | 20 + .../example/spacetimedb/tsconfig.json | 15 + spacetime-resend-ts/example/src/app.ts | 672 ++++++ spacetime-resend-ts/example/tsconfig.json | 17 + spacetime-resend-ts/package.json | 71 + .../scripts/test-resend-smoke.ts | 454 ++++ spacetime-resend-ts/scripts/test-unit.ts | 98 + spacetime-resend-ts/src/index.ts | 24 + spacetime-resend-ts/src/submodule.ts | 15 + spacetime-resend-ts/src/submodule/auth.ts | 80 + spacetime-resend-ts/src/submodule/config.ts | 112 + .../src/submodule/email-input.ts | 90 + .../src/submodule/email_writes.ts | 77 + spacetime-resend-ts/src/submodule/http.ts | 66 + spacetime-resend-ts/src/submodule/install.ts | 9 + .../src/submodule/operations.ts | 376 +++ spacetime-resend-ts/src/submodule/request.ts | 67 + spacetime-resend-ts/src/submodule/schema.ts | 293 +++ .../src/submodule/text-validation.ts | 7 + spacetime-resend-ts/src/submodule/utils.ts | 45 + .../src/submodule/webhook-metadata.ts | 13 + spacetime-resend-ts/src/submodule/webhooks.ts | 397 +++ spacetime-resend-ts/tsconfig.json | 22 + spacetime-retry-ts/LICENSE.txt | 759 ++++++ spacetime-retry-ts/README.md | 124 + spacetime-retry-ts/package.json | 64 + spacetime-retry-ts/scripts/test.ts | 45 + spacetime-retry-ts/spacetimedb/package.json | 19 + spacetime-retry-ts/spacetimedb/src/index.ts | 129 + .../spacetimedb/src/submodule.ts | 7 + spacetime-retry-ts/spacetimedb/tsconfig.json | 12 + spacetime-retry-ts/src/index.ts | 9 + spacetime-retry-ts/src/kit.ts | 54 + spacetime-retry-ts/src/submodule.ts | 341 +++ spacetime-retry-ts/tsconfig.json | 15 + spacetime-stripe-ts/LICENSE.txt | 759 ++++++ spacetime-stripe-ts/README.md | 239 ++ spacetime-stripe-ts/example/.env.example | 32 + spacetime-stripe-ts/example/.gitignore | 11 + spacetime-stripe-ts/example/README.md | 206 ++ spacetime-stripe-ts/example/package.json | 28 + .../example/public/assets/brand.svg | 17 + .../example/public/assets/logo.svg | 5 + spacetime-stripe-ts/example/public/index.html | 155 ++ spacetime-stripe-ts/example/public/styles.css | 1169 +++++++++ spacetime-stripe-ts/example/public/ui.js | 745 ++++++ spacetime-stripe-ts/example/server.ts | 417 ++++ .../example/spacetimedb/LICENSE.txt | 759 ++++++ .../example/spacetimedb/README.md | 30 + .../example/spacetimedb/package.json | 20 + .../example/spacetimedb/src/index.ts | 9 + .../example/spacetimedb/src/submodule/auth.ts | 79 + .../spacetimedb/src/submodule/operations.ts | 593 +++++ .../spacetimedb/src/submodule/schema.ts | 91 + .../spacetimedb/src/submodule/utils.ts | 24 + .../spacetimedb/src/submodule/webhooks.ts | 41 + .../example/spacetimedb/tsconfig.json | 22 + spacetime-stripe-ts/example/src/app.ts | 220 ++ spacetime-stripe-ts/example/tsconfig.json | 16 + spacetime-stripe-ts/package.json | 73 + .../scripts/test-stripe-e2e.ts | 763 ++++++ .../scripts/test-stripe-smoke.ts | 398 +++ spacetime-stripe-ts/scripts/test-unit.ts | 100 + spacetime-stripe-ts/src/index.ts | 53 + spacetime-stripe-ts/src/submodule.ts | 23 + spacetime-stripe-ts/src/submodule/auth.ts | 79 + spacetime-stripe-ts/src/submodule/config.ts | 140 ++ spacetime-stripe-ts/src/submodule/http.ts | 79 + spacetime-stripe-ts/src/submodule/install.ts | 9 + spacetime-stripe-ts/src/submodule/limits.ts | 3 + .../src/submodule/operations.ts | 949 ++++++++ .../src/submodule/operations/billing.ts | 676 ++++++ .../src/submodule/operations/queries.ts | 222 ++ .../src/submodule/operations/webhook.ts | 111 + spacetime-stripe-ts/src/submodule/router.ts | 7 + spacetime-stripe-ts/src/submodule/schema.ts | 532 ++++ spacetime-stripe-ts/src/submodule/utils.ts | 45 + .../src/submodule/webhook-metadata.ts | 25 + .../src/submodule/webhook-request.ts | 35 + spacetime-stripe-ts/tsconfig.json | 22 + tools/check-example-assets.mjs | 54 + tools/check-spacetime-release.mjs | 39 + tools/consumer-install-check.mjs | 228 ++ tools/doc-check.mjs | 202 ++ tools/example-server-identity.ts | 68 + tools/release-check.mjs | 463 ++++ tools/release-packages.mjs | 25 + tools/run-example-builds.mjs | 40 + tools/run-example-smokes.mjs | 703 ++++++ tools/run-example-tests.mjs | 38 + tools/run-module-builds.mjs | 68 + tools/run-package-checks.mjs | 42 + tools/run-production-audits.mjs | 14 + 738 files changed, 113352 insertions(+), 499 deletions(-) create mode 100644 COMPONENTS.md create mode 100644 COMPONENTS_AUTHORING.md create mode 100644 COMPONENTS_GETTING_STARTED.md create mode 100644 NPM_RELEASE_CHECKLIST.md create mode 100644 spacetime-agents-ts/LICENSE.txt create mode 100644 spacetime-agents-ts/README.md create mode 100644 spacetime-agents-ts/example/.env.example create mode 100644 spacetime-agents-ts/example/.gitignore create mode 100644 spacetime-agents-ts/example/README.md create mode 100644 spacetime-agents-ts/example/package.json create mode 100644 spacetime-agents-ts/example/public/assets/brand.svg create mode 100644 spacetime-agents-ts/example/public/assets/logo.svg create mode 100644 spacetime-agents-ts/example/public/index.html create mode 100644 spacetime-agents-ts/example/public/markdown.js create mode 100644 spacetime-agents-ts/example/public/styles.css create mode 100644 spacetime-agents-ts/example/public/ui.js create mode 100644 spacetime-agents-ts/example/scripts/test-markdown.mjs create mode 100644 spacetime-agents-ts/example/server.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/package.json create mode 100644 spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/scripts/tsconfig.json create mode 100644 spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/agents/index.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/attachments.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/loop.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/model.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/runtime.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/summarize.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/sweeper.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/tools/index.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/types.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-agents-ts/example/src/app.ts create mode 100644 spacetime-agents-ts/example/tsconfig.json create mode 100644 spacetime-agents-ts/package.json create mode 100644 spacetime-agents-ts/scripts/test-kit.ts create mode 100644 spacetime-agents-ts/spacetimedb/package.json create mode 100644 spacetime-agents-ts/spacetimedb/src/index.ts create mode 100644 spacetime-agents-ts/spacetimedb/src/install.ts create mode 100644 spacetime-agents-ts/spacetimedb/src/loop.ts create mode 100644 spacetime-agents-ts/spacetimedb/src/model.ts create mode 100644 spacetime-agents-ts/spacetimedb/src/submodule.ts create mode 100644 spacetime-agents-ts/spacetimedb/src/summarize.ts create mode 100644 spacetime-agents-ts/spacetimedb/tsconfig.json create mode 100644 spacetime-agents-ts/src/embeddings.ts create mode 100644 spacetime-agents-ts/src/index.ts create mode 100644 spacetime-agents-ts/src/kit.ts create mode 100644 spacetime-agents-ts/src/openrouter.ts create mode 100644 spacetime-agents-ts/src/providers.ts create mode 100644 spacetime-agents-ts/src/stale-locks.ts create mode 100644 spacetime-agents-ts/tsconfig.json create mode 100644 spacetime-api-keys-ts/LICENSE.txt create mode 100644 spacetime-api-keys-ts/README.md create mode 100644 spacetime-api-keys-ts/example/.env.example create mode 100644 spacetime-api-keys-ts/example/README.md create mode 100644 spacetime-api-keys-ts/example/package.json create mode 100644 spacetime-api-keys-ts/example/public/assets/brand.svg create mode 100644 spacetime-api-keys-ts/example/public/index.html create mode 100644 spacetime-api-keys-ts/example/public/styles.css create mode 100644 spacetime-api-keys-ts/example/scripts/test-model.ts create mode 100644 spacetime-api-keys-ts/example/scripts/test-share-key.ts create mode 100644 spacetime-api-keys-ts/example/server.ts create mode 100644 spacetime-api-keys-ts/example/spacetimedb/package.json create mode 100644 spacetime-api-keys-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-api-keys-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-api-keys-ts/example/src/app.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/add_admin_identity_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_key_usage_admin_table.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_keys_admin_table.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_for_subject_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/my_api_keys_table.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/remove_admin_identity_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_for_subject_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/rotate_api_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/sweep_api_key_usage_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/types.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/build_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/clear_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/clear_world_events_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/colony_cells_table.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/colony_entities_table.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/colony_grid_table.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/create_access_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/ensure_world_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/grid/types.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/index.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/my_access_keys_table.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/plant_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/presence_entry_table.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/presence_heartbeat_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/presence_leave_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/reset_world_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/revoke_access_key_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/rotate_access_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/terraform_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/types.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/types/procedures.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/types/reducers.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/unbuild_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/world_event_table.ts create mode 100644 spacetime-api-keys-ts/example/src/codegen/app/world_table.ts create mode 100644 spacetime-api-keys-ts/example/src/model.ts create mode 100644 spacetime-api-keys-ts/example/src/share-key.ts create mode 100644 spacetime-api-keys-ts/example/tsconfig.json create mode 100644 spacetime-api-keys-ts/package.json create mode 100644 spacetime-api-keys-ts/scripts/test.ts create mode 100644 spacetime-api-keys-ts/src/index.ts create mode 100644 spacetime-api-keys-ts/src/key-utils.ts create mode 100644 spacetime-api-keys-ts/src/submodule.ts create mode 100644 spacetime-api-keys-ts/src/submodule/auth.ts create mode 100644 spacetime-api-keys-ts/src/submodule/install.ts create mode 100644 spacetime-api-keys-ts/src/submodule/operations.ts create mode 100644 spacetime-api-keys-ts/src/submodule/schema.ts create mode 100644 spacetime-api-keys-ts/tsconfig.json create mode 100644 spacetime-auth-ts/LICENSE.txt create mode 100644 spacetime-auth-ts/README.md create mode 100644 spacetime-auth-ts/example/.env.example create mode 100644 spacetime-auth-ts/example/.gitignore create mode 100644 spacetime-auth-ts/example/README.md create mode 100644 spacetime-auth-ts/example/package.json create mode 100644 spacetime-auth-ts/example/public/assets/brand.svg create mode 100644 spacetime-auth-ts/example/public/assets/logo.svg create mode 100644 spacetime-auth-ts/example/public/index.html create mode 100644 spacetime-auth-ts/example/public/styles.css create mode 100644 spacetime-auth-ts/example/public/ui.js create mode 100644 spacetime-auth-ts/example/server.ts create mode 100644 spacetime-auth-ts/example/spacetimedb/package.json create mode 100644 spacetime-auth-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-auth-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-auth-ts/example/src/app.ts create mode 100644 spacetime-auth-ts/example/tsconfig.json create mode 100644 spacetime-auth-ts/package.json create mode 100644 spacetime-auth-ts/scripts/test.ts create mode 100644 spacetime-auth-ts/spacetimedb/package.json create mode 100644 spacetime-auth-ts/spacetimedb/src/index.ts create mode 100644 spacetime-auth-ts/spacetimedb/tsconfig.json create mode 100644 spacetime-auth-ts/src/admin.ts create mode 100644 spacetime-auth-ts/src/caller.ts create mode 100644 spacetime-auth-ts/src/context.ts create mode 100644 spacetime-auth-ts/src/crypto.ts create mode 100644 spacetime-auth-ts/src/handlers/_helpers.ts create mode 100644 spacetime-auth-ts/src/handlers/email_verify.ts create mode 100644 spacetime-auth-ts/src/handlers/github.ts create mode 100644 spacetime-auth-ts/src/handlers/google.ts create mode 100644 spacetime-auth-ts/src/handlers/index.ts create mode 100644 spacetime-auth-ts/src/handlers/oauth.ts create mode 100644 spacetime-auth-ts/src/handlers/password.ts create mode 100644 spacetime-auth-ts/src/handlers/password_reset.ts create mode 100644 spacetime-auth-ts/src/handlers/session.ts create mode 100644 spacetime-auth-ts/src/index.ts create mode 100644 spacetime-auth-ts/src/jwt.ts create mode 100644 spacetime-auth-ts/src/keys.ts create mode 100644 spacetime-auth-ts/src/mailer.ts create mode 100644 spacetime-auth-ts/src/mounted/index.ts create mode 100644 spacetime-auth-ts/src/mounted/install.ts create mode 100644 spacetime-auth-ts/src/procedures.ts create mode 100644 spacetime-auth-ts/src/rate_limit.ts create mode 100644 spacetime-auth-ts/src/request-trust.ts create mode 100644 spacetime-auth-ts/src/submodule.ts create mode 100644 spacetime-auth-ts/src/tables.ts create mode 100644 spacetime-auth-ts/src/types.ts create mode 100644 spacetime-auth-ts/tsconfig.json create mode 100644 spacetime-cron-ts/DESIGN.md create mode 100644 spacetime-cron-ts/LICENSE.txt create mode 100644 spacetime-cron-ts/README.md create mode 100644 spacetime-cron-ts/example/.env.example create mode 100644 spacetime-cron-ts/example/README.md create mode 100644 spacetime-cron-ts/example/package.json create mode 100644 spacetime-cron-ts/example/public/assets/logo.svg create mode 100644 spacetime-cron-ts/example/public/index.html create mode 100644 spacetime-cron-ts/example/public/styles.css create mode 100644 spacetime-cron-ts/example/server.ts create mode 100644 spacetime-cron-ts/example/spacetimedb/package.json create mode 100644 spacetime-cron-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-cron-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-cron-ts/example/src/app.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/activity_log_table.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/cleanup_fire_table.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/cron_jobs_table.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/cron_reconcile_tick_table.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/cron_run_table.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/digest_fire_table.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/index.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/schedule_cron_reducer.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/schedule_every_reducer.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/types.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/types/procedures.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/types/reducers.ts create mode 100644 spacetime-cron-ts/example/src/codegen/app/unschedule_job_reducer.ts create mode 100644 spacetime-cron-ts/example/tsconfig.json create mode 100644 spacetime-cron-ts/package.json create mode 100644 spacetime-cron-ts/scripts/sys-abi-test-register.mjs create mode 100644 spacetime-cron-ts/scripts/test-cron-parser.ts create mode 100644 spacetime-cron-ts/scripts/test-module-local.mjs create mode 100644 spacetime-cron-ts/scripts/test-recovery.mjs create mode 100644 spacetime-cron-ts/scripts/test-registration.ts create mode 100644 spacetime-cron-ts/scripts/test-schedule.ts create mode 100644 spacetime-cron-ts/scripts/test-types.ts create mode 100644 spacetime-cron-ts/scripts/test-validation.ts create mode 100644 spacetime-cron-ts/spacetimedb/package.json create mode 100644 spacetime-cron-ts/spacetimedb/src/index.ts create mode 100644 spacetime-cron-ts/spacetimedb/tsconfig.json create mode 100644 spacetime-cron-ts/src/cron.ts create mode 100644 spacetime-cron-ts/src/index.ts create mode 100644 spacetime-cron-ts/src/parser.ts create mode 100644 spacetime-cron-ts/src/schedule.ts create mode 100644 spacetime-cron-ts/src/sys-abi.d.ts create mode 100644 spacetime-cron-ts/src/types.ts create mode 100644 spacetime-cron-ts/tsconfig.json create mode 100644 spacetime-crypto-ts/LICENSE.txt create mode 100644 spacetime-crypto-ts/README.md create mode 100644 spacetime-crypto-ts/package.json create mode 100644 spacetime-crypto-ts/scripts/test-vectors.ts create mode 100644 spacetime-crypto-ts/src/hmac.ts create mode 100644 spacetime-crypto-ts/src/index.ts create mode 100644 spacetime-crypto-ts/src/sha256.ts create mode 100644 spacetime-crypto-ts/src/timing.ts create mode 100644 spacetime-crypto-ts/src/vendors.ts create mode 100644 spacetime-crypto-ts/tsconfig.json create mode 100644 spacetime-files-ts/LICENSE.txt create mode 100644 spacetime-files-ts/README.md create mode 100644 spacetime-files-ts/example/.env.example create mode 100644 spacetime-files-ts/example/.gitignore create mode 100644 spacetime-files-ts/example/README.md create mode 100644 spacetime-files-ts/example/package.json create mode 100644 spacetime-files-ts/example/public/assets/brand.svg create mode 100644 spacetime-files-ts/example/public/assets/logo.svg create mode 100644 spacetime-files-ts/example/public/index.html create mode 100644 spacetime-files-ts/example/public/styles.css create mode 100644 spacetime-files-ts/example/scripts/test-downloads.ts create mode 100644 spacetime-files-ts/example/scripts/test-selection.ts create mode 100644 spacetime-files-ts/example/server.ts create mode 100644 spacetime-files-ts/example/spacetimedb/package.json create mode 100644 spacetime-files-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-files-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-files-ts/example/src/app.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/create_folder_reducer.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/delete_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/delete_folder_reducer.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/files/types.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/index.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/move_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/my_file_summaries_table.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/my_folders_table.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/read_file_bytes_procedure.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/rename_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/rename_folder_reducer.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/set_file_visibility_reducer.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/types.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/types/procedures.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/types/reducers.ts create mode 100644 spacetime-files-ts/example/src/codegen/app/upload_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/context-menu.ts create mode 100644 spacetime-files-ts/example/src/dialog.ts create mode 100644 spacetime-files-ts/example/src/downloads.ts create mode 100644 spacetime-files-ts/example/src/drop-target.ts create mode 100644 spacetime-files-ts/example/src/keyboard.ts create mode 100644 spacetime-files-ts/example/src/list-actions.ts create mode 100644 spacetime-files-ts/example/src/rendering.ts create mode 100644 spacetime-files-ts/example/src/selection.ts create mode 100644 spacetime-files-ts/example/src/uploads.ts create mode 100644 spacetime-files-ts/example/src/utils.ts create mode 100644 spacetime-files-ts/example/src/viewer.ts create mode 100644 spacetime-files-ts/example/src/zip.ts create mode 100644 spacetime-files-ts/example/tsconfig.json create mode 100644 spacetime-files-ts/package.json create mode 100644 spacetime-files-ts/scripts/test.ts create mode 100644 spacetime-files-ts/src/constants.ts create mode 100644 spacetime-files-ts/src/handlers.ts create mode 100644 spacetime-files-ts/src/hash.ts create mode 100644 spacetime-files-ts/src/index.ts create mode 100644 spacetime-files-ts/src/procedures.ts create mode 100644 spacetime-files-ts/src/query.ts create mode 100644 spacetime-files-ts/src/rows.ts create mode 100644 spacetime-files-ts/src/submodule.ts create mode 100644 spacetime-files-ts/src/submodule/install.ts create mode 100644 spacetime-files-ts/src/submodule/schema.ts create mode 100644 spacetime-files-ts/src/validation.ts create mode 100644 spacetime-files-ts/tsconfig.json create mode 100644 spacetime-grid-ts/LICENSE.txt create mode 100644 spacetime-grid-ts/README.md create mode 100644 spacetime-grid-ts/example/.env.example create mode 100644 spacetime-grid-ts/example/README.md create mode 100644 spacetime-grid-ts/example/package.json create mode 100644 spacetime-grid-ts/example/public/assets/brand.svg create mode 100644 spacetime-grid-ts/example/public/assets/logo.svg create mode 100644 spacetime-grid-ts/example/public/hex-geometry.js create mode 100644 spacetime-grid-ts/example/public/index.html create mode 100644 spacetime-grid-ts/example/public/styles.css create mode 100644 spacetime-grid-ts/example/public/ui.js create mode 100644 spacetime-grid-ts/example/scripts/test-hex-geometry.mjs create mode 100644 spacetime-grid-ts/example/server.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/package.json create mode 100644 spacetime-grid-ts/example/spacetimedb/src/auth-adapter.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/src/schema.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-grid-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-grid-ts/example/src/app.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/actor_directory_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/ai_take_turn_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/attack_unit_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/link_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/my_auth_user_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/types.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/types.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/update_profile_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/whoami_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/create_match_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/end_turn_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/get_cells_in_range_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/grid/types.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/index.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/join_match_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/link_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/list_my_sessions_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/lobby_open_matches_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/move_unit_procedure.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/my_auth_user_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/my_cell_states_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/my_grid_entities_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/my_grids_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/my_match_participants_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/my_matches_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/my_player_units_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/npc_actor_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/revoke_my_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/revoke_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/set_auth_config_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/types.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/types/procedures.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/types/reducers.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/unit_type_table.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/unlink_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/update_profile_reducer.ts create mode 100644 spacetime-grid-ts/example/src/codegen/app/whoami_procedure.ts create mode 100644 spacetime-grid-ts/example/tsconfig.json create mode 100644 spacetime-grid-ts/package.json create mode 100644 spacetime-grid-ts/scripts/test-pathfind.ts create mode 100644 spacetime-grid-ts/src/index.ts create mode 100644 spacetime-grid-ts/src/math/coords.ts create mode 100644 spacetime-grid-ts/src/math/distance.ts create mode 100644 spacetime-grid-ts/src/math/index.ts create mode 100644 spacetime-grid-ts/src/math/neighbors.ts create mode 100644 spacetime-grid-ts/src/math/pathfind.ts create mode 100644 spacetime-grid-ts/src/procedures.ts create mode 100644 spacetime-grid-ts/src/rows.ts create mode 100644 spacetime-grid-ts/src/submodule.ts create mode 100644 spacetime-grid-ts/src/submodule/install.ts create mode 100644 spacetime-grid-ts/src/submodule/schema.ts create mode 100644 spacetime-grid-ts/tsconfig.json create mode 100644 spacetime-lobby-ts/LICENSE.txt create mode 100644 spacetime-lobby-ts/README.md create mode 100644 spacetime-lobby-ts/example/.env.example create mode 100644 spacetime-lobby-ts/example/.gitignore create mode 100644 spacetime-lobby-ts/example/README.md create mode 100644 spacetime-lobby-ts/example/package.json create mode 100644 spacetime-lobby-ts/example/public/assets/brand.svg create mode 100644 spacetime-lobby-ts/example/public/index.html create mode 100644 spacetime-lobby-ts/example/public/styles.css create mode 100644 spacetime-lobby-ts/example/scripts/test-model.ts create mode 100644 spacetime-lobby-ts/example/server.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/package.json create mode 100644 spacetime-lobby-ts/example/spacetimedb/src/catalog.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/src/schema.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-lobby-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-lobby-ts/example/src/app.ts create mode 100644 spacetime-lobby-ts/example/src/model.ts create mode 100644 spacetime-lobby-ts/example/tsconfig.json create mode 100644 spacetime-lobby-ts/package.json create mode 100644 spacetime-lobby-ts/scripts/test.ts create mode 100644 spacetime-lobby-ts/src/index.ts create mode 100644 spacetime-lobby-ts/src/keys.ts create mode 100644 spacetime-lobby-ts/src/matchmaking.ts create mode 100644 spacetime-lobby-ts/src/submodule.ts create mode 100644 spacetime-lobby-ts/src/submodule/install.ts create mode 100644 spacetime-lobby-ts/src/submodule/operations.ts create mode 100644 spacetime-lobby-ts/src/submodule/schema.ts create mode 100644 spacetime-lobby-ts/src/submodule/views.ts create mode 100644 spacetime-lobby-ts/tsconfig.json create mode 100644 spacetime-posthog-ts/.gitignore create mode 100644 spacetime-posthog-ts/LICENSE.txt create mode 100644 spacetime-posthog-ts/README.md create mode 100644 spacetime-posthog-ts/example/.env.example create mode 100644 spacetime-posthog-ts/example/.gitignore create mode 100644 spacetime-posthog-ts/example/README.md create mode 100644 spacetime-posthog-ts/example/catalog/catalog.ts create mode 100644 spacetime-posthog-ts/example/package.json create mode 100644 spacetime-posthog-ts/example/public/assets/brand.svg create mode 100644 spacetime-posthog-ts/example/public/index.html create mode 100644 spacetime-posthog-ts/example/public/styles.css create mode 100644 spacetime-posthog-ts/example/scripts/test-economy.ts create mode 100644 spacetime-posthog-ts/example/server.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/package.json create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/catalog.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/economy.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/recent.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/schema.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/validation.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-posthog-ts/example/src/app.ts create mode 100644 spacetime-posthog-ts/example/tsconfig.json create mode 100644 spacetime-posthog-ts/package.json create mode 100644 spacetime-posthog-ts/scripts/test.ts create mode 100644 spacetime-posthog-ts/src/index.ts create mode 100644 spacetime-posthog-ts/src/submodule.ts create mode 100644 spacetime-posthog-ts/src/submodule/auth.ts create mode 100644 spacetime-posthog-ts/src/submodule/config.ts create mode 100644 spacetime-posthog-ts/src/submodule/http.ts create mode 100644 spacetime-posthog-ts/src/submodule/install.ts create mode 100644 spacetime-posthog-ts/src/submodule/operations.ts create mode 100644 spacetime-posthog-ts/src/submodule/outbox-state.ts create mode 100644 spacetime-posthog-ts/src/submodule/schema.ts create mode 100644 spacetime-posthog-ts/src/submodule/utils.ts create mode 100644 spacetime-posthog-ts/src/submodule/value-utils.ts create mode 100644 spacetime-posthog-ts/tsconfig.json create mode 100644 spacetime-presence-ts/LICENSE.txt create mode 100644 spacetime-presence-ts/README.md create mode 100644 spacetime-presence-ts/example/.env.example create mode 100644 spacetime-presence-ts/example/README.md create mode 100644 spacetime-presence-ts/example/package.json create mode 100644 spacetime-presence-ts/example/public/assets/brand.svg create mode 100644 spacetime-presence-ts/example/public/assets/logo.svg create mode 100644 spacetime-presence-ts/example/public/chat-model.js create mode 100644 spacetime-presence-ts/example/public/chat-state.js create mode 100644 spacetime-presence-ts/example/public/chat.css create mode 100644 spacetime-presence-ts/example/public/index.html create mode 100644 spacetime-presence-ts/example/public/styles.css create mode 100644 spacetime-presence-ts/example/public/ui.js create mode 100644 spacetime-presence-ts/example/scripts/test-ui-model.mjs create mode 100644 spacetime-presence-ts/example/server.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/package.json create mode 100644 spacetime-presence-ts/example/spacetimedb/src/chat-policy.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/src/domain.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/src/model.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-presence-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-presence-ts/example/src/app.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/link_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/my_auth_user_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/types.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/types.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/update_profile_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/whoami_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/create_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/create_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/delete_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/delete_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/delete_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/delete_thread_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/edit_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/edit_thread_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/files/types.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/get_attachment_file_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/heartbeat_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/index.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/join_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/join_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/leave_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/leave_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/link_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/list_my_sessions_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/mark_room_read_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_auth_user_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_chat_users_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_message_threads_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_presence_entries_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_rate_limit_status_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_attachments_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_members_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_message_reactions_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_messages_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_read_cursors_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_rooms_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_server_members_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_servers_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/my_thread_messages_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/pin_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/consume_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/types.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rename_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/rename_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/revoke_my_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/revoke_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/search_messages_procedure.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/send_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/send_thread_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/set_auth_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/set_display_name_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/set_room_category_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/set_room_privacy_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/set_status_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/start_typing_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/stop_typing_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/toggle_reaction_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/types.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/types/procedures.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/types/reducers.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/unlink_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/unpin_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/update_profile_reducer.ts create mode 100644 spacetime-presence-ts/example/src/codegen/app/whoami_procedure.ts create mode 100644 spacetime-presence-ts/example/tsconfig.json create mode 100644 spacetime-presence-ts/package.json create mode 100644 spacetime-presence-ts/scripts/test.ts create mode 100644 spacetime-presence-ts/spacetimedb/package.json create mode 100644 spacetime-presence-ts/spacetimedb/src/index.ts create mode 100644 spacetime-presence-ts/spacetimedb/tsconfig.json create mode 100644 spacetime-presence-ts/src/index.ts create mode 100644 spacetime-presence-ts/src/mounted/index.ts create mode 100644 spacetime-presence-ts/src/mounted/install.ts create mode 100644 spacetime-presence-ts/src/presence.ts create mode 100644 spacetime-presence-ts/src/submodule.ts create mode 100644 spacetime-presence-ts/src/tables.ts create mode 100644 spacetime-presence-ts/tsconfig.json create mode 100644 spacetime-rate-limit-ts/LICENSE.txt create mode 100644 spacetime-rate-limit-ts/README.md create mode 100644 spacetime-rate-limit-ts/example/.env.example create mode 100644 spacetime-rate-limit-ts/example/README.md create mode 100644 spacetime-rate-limit-ts/example/package.json create mode 100644 spacetime-rate-limit-ts/example/public/assets/brand.svg create mode 100644 spacetime-rate-limit-ts/example/public/assets/logo.svg create mode 100644 spacetime-rate-limit-ts/example/public/index.html create mode 100644 spacetime-rate-limit-ts/example/public/styles.css create mode 100644 spacetime-rate-limit-ts/example/public/ui.js create mode 100644 spacetime-rate-limit-ts/example/scripts/test-reactor-rules.ts create mode 100644 spacetime-rate-limit-ts/example/server.ts create mode 100644 spacetime-rate-limit-ts/example/spacetimedb/package.json create mode 100644 spacetime-rate-limit-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-rate-limit-ts/example/spacetimedb/src/model.ts create mode 100644 spacetime-rate-limit-ts/example/spacetimedb/src/reactor-rules.ts create mode 100644 spacetime-rate-limit-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-rate-limit-ts/example/src/app.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/buy_upgrade_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/index.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/overcharge_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/consume_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/types.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_demo_config_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_events_admin_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_events_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_limit_status_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_players_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_shop_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_state_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/repair_reactor_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reset_demo_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/run_sweep_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/set_player_color_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/start_reactor_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/tap_reactor_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/types.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/types/procedures.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/types/reducers.ts create mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/update_config_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/tsconfig.json create mode 100644 spacetime-rate-limit-ts/package.json create mode 100644 spacetime-rate-limit-ts/scripts/test.ts create mode 100644 spacetime-rate-limit-ts/spacetimedb/package.json create mode 100644 spacetime-rate-limit-ts/spacetimedb/src/index.ts create mode 100644 spacetime-rate-limit-ts/spacetimedb/src/install.ts create mode 100644 spacetime-rate-limit-ts/spacetimedb/src/submodule.ts create mode 100644 spacetime-rate-limit-ts/spacetimedb/tsconfig.json create mode 100644 spacetime-rate-limit-ts/src/index.ts create mode 100644 spacetime-rate-limit-ts/src/key.ts create mode 100644 spacetime-rate-limit-ts/src/limit.ts create mode 100644 spacetime-rate-limit-ts/src/submodule.ts create mode 100644 spacetime-rate-limit-ts/src/submodule/install.ts create mode 100644 spacetime-rate-limit-ts/src/submodule/operations.ts create mode 100644 spacetime-rate-limit-ts/src/submodule/schema.ts create mode 100644 spacetime-rate-limit-ts/tsconfig.json create mode 100644 spacetime-resend-ts/.gitignore create mode 100644 spacetime-resend-ts/LICENSE.txt create mode 100644 spacetime-resend-ts/README.md create mode 100644 spacetime-resend-ts/example/.env.example create mode 100644 spacetime-resend-ts/example/.gitignore create mode 100644 spacetime-resend-ts/example/README.md create mode 100644 spacetime-resend-ts/example/package.json create mode 100644 spacetime-resend-ts/example/public/assets/brand.svg create mode 100644 spacetime-resend-ts/example/public/index.html create mode 100644 spacetime-resend-ts/example/public/styles.css create mode 100644 spacetime-resend-ts/example/scripts/test-message.ts create mode 100644 spacetime-resend-ts/example/server.ts create mode 100644 spacetime-resend-ts/example/spacetimedb/package.json create mode 100644 spacetime-resend-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-resend-ts/example/spacetimedb/src/message.ts create mode 100644 spacetime-resend-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-resend-ts/example/src/app.ts create mode 100644 spacetime-resend-ts/example/tsconfig.json create mode 100644 spacetime-resend-ts/package.json create mode 100644 spacetime-resend-ts/scripts/test-resend-smoke.ts create mode 100644 spacetime-resend-ts/scripts/test-unit.ts create mode 100644 spacetime-resend-ts/src/index.ts create mode 100644 spacetime-resend-ts/src/submodule.ts create mode 100644 spacetime-resend-ts/src/submodule/auth.ts create mode 100644 spacetime-resend-ts/src/submodule/config.ts create mode 100644 spacetime-resend-ts/src/submodule/email-input.ts create mode 100644 spacetime-resend-ts/src/submodule/email_writes.ts create mode 100644 spacetime-resend-ts/src/submodule/http.ts create mode 100644 spacetime-resend-ts/src/submodule/install.ts create mode 100644 spacetime-resend-ts/src/submodule/operations.ts create mode 100644 spacetime-resend-ts/src/submodule/request.ts create mode 100644 spacetime-resend-ts/src/submodule/schema.ts create mode 100644 spacetime-resend-ts/src/submodule/text-validation.ts create mode 100644 spacetime-resend-ts/src/submodule/utils.ts create mode 100644 spacetime-resend-ts/src/submodule/webhook-metadata.ts create mode 100644 spacetime-resend-ts/src/submodule/webhooks.ts create mode 100644 spacetime-resend-ts/tsconfig.json create mode 100644 spacetime-retry-ts/LICENSE.txt create mode 100644 spacetime-retry-ts/README.md create mode 100644 spacetime-retry-ts/package.json create mode 100644 spacetime-retry-ts/scripts/test.ts create mode 100644 spacetime-retry-ts/spacetimedb/package.json create mode 100644 spacetime-retry-ts/spacetimedb/src/index.ts create mode 100644 spacetime-retry-ts/spacetimedb/src/submodule.ts create mode 100644 spacetime-retry-ts/spacetimedb/tsconfig.json create mode 100644 spacetime-retry-ts/src/index.ts create mode 100644 spacetime-retry-ts/src/kit.ts create mode 100644 spacetime-retry-ts/src/submodule.ts create mode 100644 spacetime-retry-ts/tsconfig.json create mode 100644 spacetime-stripe-ts/LICENSE.txt create mode 100644 spacetime-stripe-ts/README.md create mode 100644 spacetime-stripe-ts/example/.env.example create mode 100644 spacetime-stripe-ts/example/.gitignore create mode 100644 spacetime-stripe-ts/example/README.md create mode 100644 spacetime-stripe-ts/example/package.json create mode 100644 spacetime-stripe-ts/example/public/assets/brand.svg create mode 100644 spacetime-stripe-ts/example/public/assets/logo.svg create mode 100644 spacetime-stripe-ts/example/public/index.html create mode 100644 spacetime-stripe-ts/example/public/styles.css create mode 100644 spacetime-stripe-ts/example/public/ui.js create mode 100644 spacetime-stripe-ts/example/server.ts create mode 100644 spacetime-stripe-ts/example/spacetimedb/LICENSE.txt create mode 100644 spacetime-stripe-ts/example/spacetimedb/README.md create mode 100644 spacetime-stripe-ts/example/spacetimedb/package.json create mode 100644 spacetime-stripe-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-stripe-ts/example/spacetimedb/src/submodule/auth.ts create mode 100644 spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts create mode 100644 spacetime-stripe-ts/example/spacetimedb/src/submodule/schema.ts create mode 100644 spacetime-stripe-ts/example/spacetimedb/src/submodule/utils.ts create mode 100644 spacetime-stripe-ts/example/spacetimedb/src/submodule/webhooks.ts create mode 100644 spacetime-stripe-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-stripe-ts/example/src/app.ts create mode 100644 spacetime-stripe-ts/example/tsconfig.json create mode 100644 spacetime-stripe-ts/package.json create mode 100644 spacetime-stripe-ts/scripts/test-stripe-e2e.ts create mode 100644 spacetime-stripe-ts/scripts/test-stripe-smoke.ts create mode 100644 spacetime-stripe-ts/scripts/test-unit.ts create mode 100644 spacetime-stripe-ts/src/index.ts create mode 100644 spacetime-stripe-ts/src/submodule.ts create mode 100644 spacetime-stripe-ts/src/submodule/auth.ts create mode 100644 spacetime-stripe-ts/src/submodule/config.ts create mode 100644 spacetime-stripe-ts/src/submodule/http.ts create mode 100644 spacetime-stripe-ts/src/submodule/install.ts create mode 100644 spacetime-stripe-ts/src/submodule/limits.ts create mode 100644 spacetime-stripe-ts/src/submodule/operations.ts create mode 100644 spacetime-stripe-ts/src/submodule/operations/billing.ts create mode 100644 spacetime-stripe-ts/src/submodule/operations/queries.ts create mode 100644 spacetime-stripe-ts/src/submodule/operations/webhook.ts create mode 100644 spacetime-stripe-ts/src/submodule/router.ts create mode 100644 spacetime-stripe-ts/src/submodule/schema.ts create mode 100644 spacetime-stripe-ts/src/submodule/utils.ts create mode 100644 spacetime-stripe-ts/src/submodule/webhook-metadata.ts create mode 100644 spacetime-stripe-ts/src/submodule/webhook-request.ts create mode 100644 spacetime-stripe-ts/tsconfig.json create mode 100644 tools/check-example-assets.mjs create mode 100644 tools/check-spacetime-release.mjs create mode 100644 tools/consumer-install-check.mjs create mode 100644 tools/doc-check.mjs create mode 100644 tools/example-server-identity.ts create mode 100644 tools/release-check.mjs create mode 100644 tools/release-packages.mjs create mode 100644 tools/run-example-builds.mjs create mode 100644 tools/run-example-smokes.mjs create mode 100644 tools/run-example-tests.mjs create mode 100644 tools/run-module-builds.mjs create mode 100644 tools/run-package-checks.mjs create mode 100644 tools/run-production-audits.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b56dd41aa0c..e0c6352d82c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1686,3 +1686,37 @@ jobs: # - name: Print rows in the user table # if: always() # run: spacetime sql quickstart-chat "SELECT * FROM user" + + typescript-components: + needs: [merge_queue_noop] + if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} + name: TypeScript - Components + runs-on: spacetimedb-new-runner-2 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: ./.github/actions/setup-pnpm + with: + run_install: true + + - name: Install released SpacetimeDB toolchain + run: | + curl -sSf https://install.spacetimedb.com | sh -s -- --root-dir "$RUNNER_TEMP/spacetime" --yes + echo "$RUNNER_TEMP/spacetime/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/spacetime/bin/spacetime" version install 2.8.3 + "$RUNNER_TEMP/spacetime/bin/spacetime" version use 2.8.3 + + - name: Check components + run: pnpm components:check + + - name: Build component modules and examples + run: pnpm components:build + + - name: Audit packed production dependencies + run: pnpm components:audit:prod diff --git a/.gitignore b/.gitignore index 1f3b49ecd2d..4571c07d24f 100644 --- a/.gitignore +++ b/.gitignore @@ -205,6 +205,12 @@ __pycache__/ ## JetBrains .idea/ +# TypeScript component development outputs +.component-packs/ +.stdb-*/ +spacetime-*-ts/example/public/app.js +spacetime-*-ts/example/public/app.js.map + /protobuf cs-src/ crates/bench/spacetime.svg diff --git a/.prettierignore b/.prettierignore index 41800b56b39..632ca9e1116 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,9 @@ dist target .github coverage +**/public/app.js +**/public/app.js.map +**/src/codegen/** +**/ts-codegen/** +.stdb-* +.component-packs diff --git a/COMPONENTS.md b/COMPONENTS.md new file mode 100644 index 00000000000..2630d0c7753 --- /dev/null +++ b/COMPONENTS.md @@ -0,0 +1,156 @@ +# SpacetimeDB TypeScript Components + +Reusable packages for SpacetimeDB TypeScript modules. Submodules run inside a +module, own or extend transactional state, and use `ctx.http.fetch` when they +need an external API. Browser clients connect directly to SpacetimeDB. + +Mountable submodules target the released SpacetimeDB 2.8 TypeScript SDK and +CLI. Package peer dependencies accept compatible 2.x releases from 2.8.3 +onward. Repository development and release verification use version 2.8.3. + +## Start here + +- **Adding a component to an application:** follow + [Getting started](./COMPONENTS_GETTING_STARTED.md), then use the + package-specific README. +- **Evaluating the components:** choose a runnable application from the package + table. Example READMEs include the exact local database, port, credentials, + and first successful action. +- **Contributing to this repository:** use the repository-development workflow + below. + +A typical mountable component starts with: + +```bash +npm install @spacetimedb/rate-limit spacetimedb@^2.8.3 +``` + +```ts +import { schema } from 'spacetimedb/server'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; + +const spacetimedb = schema({ rateLimit }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + rateLimit.installRateLimit(ctx.as.rateLimit); +}); +``` + +The host application owns authorization and exposes operations and views for +its users. The package READMEs and full examples show that boundary. + +## Packages + +| Package | Purpose | Runnable example | +| ------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------- | +| [`@spacetimedb/agents`](./spacetime-agents-ts/) | Agent definitions, typed tools, model providers, and embeddings | [Multi-provider chat](./spacetime-agents-ts/example/) | +| [`@spacetimedb/api-keys`](./spacetime-api-keys-ts/) | API key issuance, verification, rotation, and audit history | [Colony sharing](./spacetime-api-keys-ts/example/) | +| [`@spacetimedb/auth`](./spacetime-auth-ts/) | Password and OAuth authentication, sessions, and profiles | [Authenticated notes](./spacetime-auth-ts/example/) | +| [`@spacetimedb/cron`](./spacetime-cron-ts/) | Durable calendar and interval scheduling | [Cron dashboard](./spacetime-cron-ts/example/) | +| [`@spacetimedb/crypto`](./spacetime-crypto-ts/) | Hashing, encoding, and webhook-signature helpers | Used by the provider examples | +| [`@spacetimedb/files`](./spacetime-files-ts/) | Transactional file storage, visibility, and serving | [Vault](./spacetime-files-ts/example/) | +| [`@spacetimedb/grid`](./spacetime-grid-ts/) | Square and hex grids, pathfinding, ranges, and movement | [Grid Tactics](./spacetime-grid-ts/example/) | +| [`@spacetimedb/lobby`](./spacetime-lobby-ts/) | Queues, rooms, ranked matching, and match results | [Starclash](./spacetime-lobby-ts/example/) | +| [`@spacetimedb/posthog`](./spacetime-posthog-ts/) | PostHog capture, outbox delivery, and feature flags | [Context Cafe](./spacetime-posthog-ts/example/) | +| [`@spacetimedb/presence`](./spacetime-presence-ts/) | Presence, heartbeat, activity, and expiration | [Presence Chat](./spacetime-presence-ts/example/) | +| [`@spacetimedb/rate-limit`](./spacetime-rate-limit-ts/) | Fixed-window rate limiting and bounded sweeps | [Powerhouse](./spacetime-rate-limit-ts/example/) | +| [`@spacetimedb/resend`](./spacetime-resend-ts/) | Resend email delivery and signed webhook ingestion | [Dispatch](./spacetime-resend-ts/example/) | +| [`@spacetimedb/retry`](./spacetime-retry-ts/) | Typed retry dispatch, backoff, and attempt history | [Cron dashboard](./spacetime-cron-ts/example/) | +| [`@spacetimedb/stripe`](./spacetime-stripe-ts/) | Stripe catalog, checkout, billing state, and webhooks | [Premium Store](./spacetime-stripe-ts/example/) | + +Each package ships TypeScript source, a BUSL-1.1 license, API documentation, +and a runnable integration example where the submodule needs host-module +wiring. + +## Submodule model + +Mountable packages expose `./submodule`. That entrypoint exports the schema, +registered operations, views, and an `install` helper. The host module +owns lifecycle hooks and route registration. + +Host-configured packages such as `agents`, `cron`, and `retry` keep +application-specific dispatch typed in the consuming module. Root and +documented subpath exports provide pure helpers. + +Shared rules: + +- Secrets live in private tables, never in public procedure arguments. +- Admin identities are seeded from the publishing owner during initialization. +- Per-user and per-membership data stays private and is exposed through scoped + views. +- Reducers use context time and randomness so execution remains deterministic. +- Scheduled work is bounded per invocation and leaves observable history. + +See [Component authoring](./COMPONENTS_AUTHORING.md) for package conventions. + +## Repository development + +Install the official SpacetimeDB launcher, select 2.8.3, and install workspace +dependencies before running the repository gates: + +```bash +spacetime version install 2.8.3 +spacetime version use 2.8.3 +pnpm install +``` + +The build gate rejects any CLI or embedded library version other than 2.8.3. +Component manifests use pnpm workspace references to the repository SDK. + +Package checks: + +```bash +pnpm components:check +pnpm components:build +pnpm components:consumer:check +``` + +`components:consumer:check` packs all 14 releases, installs them into a clean +temporary project with `spacetimedb@2.8.3`, resolves every public export, and +builds a host module containing all mountable components. + +Start the released standalone server with `spacetime start` when running an +integration example. Examples use the standard `local` server alias and port +`3000`. + +To publish and test every example with disposable local databases: + +```bash +pnpm components:browser:install +pnpm components:smoke:examples:ephemeral +``` + +This command requires an active local server and CLI login. It creates a unique +database for each example, generates client bindings, builds the browser app, +checks the HTTP surface, opens the app in Chromium, and exercises one safe UI +interaction. It fails on browser errors, unexpected HTTP errors, and missing +static assets. It removes each disposable database after the test. Provider +credentials are not used. + +Use the named-database command only when you intend to replace the normal local +example databases: + +```bash +pnpm components:smoke:examples:fresh +``` + +This command publishes with `--delete-data=always`. Its script name and required +confirmation flag make the data deletion explicit. Use each provider package's +opt-in test separately when validating real credentials. + +Run only the server and HTTP checks when Chromium is unavailable: + +```bash +pnpm components:smoke:examples:http:fresh +``` + +With a local SpacetimeDB server running, the Stripe and Resend synthetic smoke +tests need no provider credentials. Credentialed provider tests, such as +Stripe's sandbox E2E suite, are documented in the corresponding package +README. + +## Releasing + +Follow [NPM_RELEASE_CHECKLIST.md](./NPM_RELEASE_CHECKLIST.md) for authentication, +versioning, dependency order, dry runs, manual publication, and verification. diff --git a/COMPONENTS_AUTHORING.md b/COMPONENTS_AUTHORING.md new file mode 100644 index 00000000000..a234ec0dfce --- /dev/null +++ b/COMPONENTS_AUTHORING.md @@ -0,0 +1,131 @@ +# Authoring a SpacetimeDB TypeScript submodule + +This guide defines the public conventions for packages in this repository. +A package may be a pure helper, a host-configured factory, or a mountable +submodule. Every entry point must match the package's implemented capabilities. + +## Package shapes + +- **Helper:** pure functions or typed dispatch. Examples include `crypto`, + `agents`, and `cron`. +- **Factory:** creates tables and operations from host-supplied types or + handlers. The host mounts the returned pieces into its schema. +- **Mountable submodule:** exports a reusable schema surface and an installation + helper. The host owns initialization and route wiring. + +Demo-specific tables, model names, task variants, and business rules belong in +`example/` or local build fixtures. Published `./submodule` exports contain the +reusable surface. + +## Package setup + +Use the scoped `@spacetimedb/` package name and publish TypeScript source directly. +Declare `spacetimedb` as a peer dependency when the public API uses its types. + +```json +{ + "name": "@spacetimedb/your-thing", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { "types": "./src/index.ts", "default": "./src/index.ts" } + }, + "files": ["src", "LICENSE.txt", "README.md"], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "peerDependencies": { "spacetimedb": "workspace:^" }, + "devDependencies": { "spacetimedb": "workspace:*" } +} +``` + +Add subpath exports only when they are intentional public APIs. Every exported +path must be included in `files` and must work in the packed tarball. + +Use pnpm workspace references for internal development. pnpm converts +`workspace:^` to a compatible release range when it packs a package. Pin the +CI CLI to the repository SDK version so release results are reproducible. + +## Layout + +```text +spacetime-your-thing-ts/ +|-- src/ # published implementation and public types +|-- example/ # optional runnable integration +| `-- spacetimedb/ # example host module +|-- spacetimedb/ # optional canonical module fixture +|-- scripts/ # package tests and release helpers +|-- package.json +|-- README.md +`-- LICENSE.txt +``` + +Use `spacetime--ts` for every top-level package directory. Use +`spacetimedb` for every host or fixture module directory. Publish a fixture only +when it is a documented, reusable entry point. + +## Runtime conventions + +1. External HTTP belongs in a procedure or HTTP handler. Reducers remain + deterministic. +2. Use `ctx.timestamp` and context-provided randomness in module operations. +3. Store API keys and signing secrets in private tables. Routine public + operations accept product data and return safe results. +4. Seed the publishing owner as the initial admin during `init`. +5. Keep per-user data private and expose it through caller-scoped views. +6. Bound scheduled and batch work. Preserve useful status or attempt history. +7. Treat outbound side effects as at-least-once unless the integration supplies + and enforces an idempotency key. +8. Use `snake_case` for database table and operation names. Keep TypeScript + identifiers readable and consistent with the surrounding package. + +For scheduled-table forward references, capture the registered reducer and +validate the wiring during module definition. + +## README requirements + +Every package README uses these top-level sections in this order: + +1. `Install` +2. `Usage` +3. `API` +4. `Limitations` +5. `Testing` +6. `License` + +Start with one plain-language paragraph that states what the package does and +who owns persistence, authorization, and lifecycle wiring. Examples must be +syntactically valid and use current exports. Include performance numbers, +provider claims, and platform statements only when the repository verifies and +maintains them. + +Under `Usage`, include an `Integrate into an application` subsection. It must +identify the package as a helper, factory, or mountable submodule and show the +smallest complete host integration. Avoid unexplained identifiers in the first +snippet. If application-specific functions are unavoidable, label the snippet +as a skeleton and name every placeholder. Link full examples with repository +URLs that continue to work when npm renders the packed README. + +Example READMEs must include `Prerequisites`, `Quick start`, and `Use in your +project`. State that checked-in workspace dependencies are for repository +development and show the published npm install command. Environment-file setup +must use one cross-platform command or show both Bash and PowerShell forms. + +## Release checks + +Before publishing: + +```bash +pnpm install +pnpm components:check +pnpm components:build +``` + +The root lint command also validates Markdown links, TypeScript/JavaScript code +fences, stale work-in-progress markers, and the required README structure. +Follow [`NPM_RELEASE_CHECKLIST.md`](./NPM_RELEASE_CHECKLIST.md) for versioning, +npm authentication, publication order, and post-publish verification. diff --git a/COMPONENTS_GETTING_STARTED.md b/COMPONENTS_GETTING_STARTED.md new file mode 100644 index 00000000000..8b5d65a61e1 --- /dev/null +++ b/COMPONENTS_GETTING_STARTED.md @@ -0,0 +1,173 @@ +# Getting started + +Use this guide when you want to run an example or add one of these packages to +an existing SpacetimeDB TypeScript module. Repository contributors should use +the development commands in [Components](./COMPONENTS.md#repository-development). + +## Prerequisites + +- Node.js 20 or later. +- npm, or pnpm 10 when running this repository's examples. +- The official SpacetimeDB launcher with CLI version 2.8.3 selected. + +Install the launcher using the +[official SpacetimeDB installation guide](https://spacetimedb.com/docs/), then +select the release used by these packages: + +```bash +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime --version +``` + +For local development, start the standalone server in a separate terminal. It +runs in the foreground on port `3000` by default: + +```bash +spacetime start +``` + +In another terminal, verify the server and authenticate the identity that will +publish the module: + +```bash +spacetime server ping local +spacetime login +spacetime login show +``` + +## Run an example + +Each application under `/example` has its own credentials, port, and +first-use instructions. The common workflow is: + +```bash +cd /example +pnpm install +pnpm --dir spacetimedb install +pnpm run build:module:fresh +pnpm run dev +``` + +Every example stores its host module in `spacetimedb/`. A few examples are pnpm +workspaces that install both projects together. Follow the exact commands in +each example README. + +`build:module:fresh` deletes and recreates only that example's local database. +After the first run, use `build:module` when you want to preserve its rows. + +The checked-in examples use pnpm workspace dependencies so each example tests +the component source and SDK in this repository. Consumer projects install +published packages from npm. + +## Add a package to an application + +### 1. Install compatible releases + +Install the component in the directory that contains your SpacetimeDB module's +`package.json`. Install the TypeScript SDK as an explicit compatible peer: + +```bash +npm install @spacetimedb/ spacetimedb@^2.8.3 +``` + +The package README lists any companion components that must be installed too. +Use one package manager consistently in your application. The commands below +use npm; pnpm and compatible clients can install the same package versions. + +### 2. Choose the integration shape + +Packages in this repository have one of three shapes: + +- **Mountable submodule:** import `@spacetimedb//submodule`, add it + to `schema({ ... })`, and call its installer from the host `init` hook. +- **Factory:** construct tables and operations with application-specific typed + handlers, then register the returned pieces in the host schema. +- **Helper:** call its pure or context-aware functions from tables, reducers, + procedures, or handlers owned by the application. + +The package README identifies the shape and provides the package-specific code. +For a mountable submodule, the basic host structure is: + +```ts +import { schema } from 'spacetimedb/server'; +import * as component from '@spacetimedb//submodule'; + +const spacetimedb = schema({ component }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + component.installComponent(ctx.as.component); +}); +``` + +`component` and `installComponent` are placeholders. Use the exact namespace +and installer from the package README. The host owns its lifecycle hook. + +### 3. Add the application boundary + +A reusable component cannot decide who your users are or which browser actions +are safe. Before exposing it to a client: + +1. Map `ctx.sender` or your authentication session to an application subject. +2. Wrap component helpers in narrow host reducers or procedures. +3. Expose private component state through caller- or tenant-scoped host views. +4. Register only the HTTP routes your application needs. +5. Store provider credentials in private module state, never browser code or a + public table. + +The full examples show these boundaries. Reuse the boundary pattern and select +the product-specific tables or development helpers that fit your application. + +### 4. Publish and generate bindings + +From the application root, replace the placeholder paths with your layout: + +```bash +spacetime publish --server local --yes --module-path ./spacetimedb my-app +spacetime generate --lang typescript --out-dir ./src/module_bindings --module-path ./spacetimedb --yes +``` + +Publishing already builds the module. Use `--delete-data=always` only when you +intend to destroy the target database's data. + +### 5. Connect the client + +Import the generated connection into the browser. Server component code stays +inside the module: + +```ts +import { DbConnection, tables } from './module_bindings'; + +const connection = DbConnection.builder() + .withUri('ws://127.0.0.1:3000') + .withDatabaseName('my-app') + .onConnect(ctx => { + ctx.subscriptionBuilder().subscribe([tables.myApplicationView]); + }) + .build(); +``` + +The exact generated reducer, procedure, view, and table names come from the host +module you published. Generate bindings again whenever that public schema +changes. + +## Production checklist + +Before deploying an integration: + +- Replace example development servers and console mailers with production + infrastructure. +- Provision service identities and secrets explicitly at deployment time. +- Use TLS, secure cookies, host validation, request limits, and trusted proxy + configuration at the deployment boundary. +- Keep base tables private and verify that every public view is scoped to its + caller or tenant. +- Treat provider side effects as at-least-once and use idempotency keys where + supported. +- Test data-preserving upgrades before publishing over production data. + +For the underlying platform workflow, see the official +[TypeScript quickstart](https://spacetimedb.com/docs/quickstarts/typescript/), +[publishing guide](https://spacetimedb.com/docs/databases/building-publishing/), +and [client-binding guide](https://spacetimedb.com/docs/clients/codegen/). diff --git a/NPM_RELEASE_CHECKLIST.md b/NPM_RELEASE_CHECKLIST.md new file mode 100644 index 00000000000..a4a56c812ba --- /dev/null +++ b/NPM_RELEASE_CHECKLIST.md @@ -0,0 +1,148 @@ +# Publishing the Submodules to npm + +These packages publish under the public `@spacetimedb` scope. Publishing is +manual until a dedicated release workflow and npm trusted publisher are +configured. The packages require the released SpacetimeDB 2.8 submodule APIs. + +## Prerequisites + +1. Install Node.js 22 or later and a current npm CLI. +2. Confirm you have write access to the `@spacetimedb` npm organization. +3. Enable two-factor authentication on the npm account. +4. Authenticate and verify the registry account: + + ```bash + npm login + npm whoami + npm config get registry + ``` + + The registry must be `https://registry.npmjs.org/`. + +5. Install and select the exact CLI used by the release gates: + + ```bash + spacetime version install 2.8.3 + spacetime version use 2.8.3 + spacetime --version + npm view spacetimedb@2.8.3 version + ``` + + Both the CLI tool and embedded library must report `2.8.3`. Package + development dependencies resolve the SDK from this pnpm workspace. + +## Release gates + +Run from the repository root: + +```bash +pnpm install --frozen-lockfile +pnpm components:check +pnpm components:build +pnpm components:consumer:check +pnpm --dir spacetime-stripe-ts run test:smoke +pnpm --dir spacetime-resend-ts run test:smoke +``` + +The release check validates metadata, exports, README structure, publishable +dependency ranges, lifecycle boundaries, forbidden Node-only imports, and the +contents of every npm tarball. The package checks run TypeScript validation and +all non-credentialed unit suites. + +The build gate first verifies the released 2.8.3 CLI, then compiles 22 server +fixtures and regenerates and bundles all 12 browser examples. The Stripe and +Resend smoke suites publish dedicated local databases and use synthetic signed +webhooks, so they require a running local SpacetimeDB server but no provider +credentials. Stripe's `test:stripe:e2e` suite remains opt-in because it uses a +real Stripe sandbox and Stripe CLI session. + +Before publishing, also confirm: + +- The worktree contains only intended release changes. +- The commit to release is on the protected default branch. +- Every changed package has the intended version. +- `CHANGELOG` or release notes describe user-visible changes. +- No `.env`, credential, log, generated binding, example build, or + `node_modules` file appears in `pnpm pack --json`. + +## Versioning + +For the first publication, use the reviewed version already recorded in the +manifest. npm never permits overwriting an existing name and version. + +Check a package before choosing a version: + +```bash +npm view @spacetimedb/crypto version +``` + +An npm `E404` means the package name has not been published. For an existing +package, update the version and defer Git tagging: + +```bash +cd spacetime-crypto-ts +npm version patch --no-git-tag-version +``` + +Use `minor` or `major` when the change warrants it. If an internal dependency +receives a version outside a consumer's current range, update the consumer +manifest before publishing. + +## Publish order + +Publish dependency foundations before their consumers: + +1. `@spacetimedb/crypto` +2. `@spacetimedb/rate-limit` +3. Packages with no unpublished internal dependency: `agents`, `cron`, + `grid`, `lobby`, `posthog`, `presence`, and `retry` +4. `@spacetimedb/api-keys`, `@spacetimedb/files`, `@spacetimedb/auth`, + `@spacetimedb/resend`, and `@spacetimedb/stripe` + +Independent packages within steps 1-3 may be released in +any order. Wait for each foundation version to become visible through +`npm view` before publishing its consumers. + +## Dry run and publish + +Run these commands from the package directory. The explicit access flag is +important for the first publication of an organization-scoped public package. + +```bash +pnpm pack --json +pnpm publish --dry-run --access public +pnpm publish --access public +``` + +Interactive publication requires 2FA. Do not pass an access token on the +command line or store it in the repository. npm also supports staged +publication (`npm stage publish`) when a separate 2FA approval step is desired. + +Immediately verify the published package: + +```bash +npm view @spacetimedb/crypto@0.1.0 --json +npm install --ignore-scripts @spacetimedb/crypto@0.1.0 +``` + +For packages with `./submodule`, verify the installed package contains that +export and run a clean consumer typecheck before continuing to the next package. + +## After publication + +1. Tag the release commit using the repository's chosen tag convention. +2. Create release notes that list every published package and version. +3. Submit eligible packages to the SpacetimeDB submodule registry. +4. Configure npm trusted publishing for a future release workflow. Use Node + 22.14.0 or newer and npm 11.5.1 or newer in the publish job. Trusted + publishing uses short-lived OIDC credentials and automatically records npm + provenance for eligible public packages; the workflow needs + `id-token: write`. New trusted-publisher configurations must explicitly + allow `npm publish`, `npm stage publish`, or both. + +References: + +- [Publishing scoped public packages](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/) +- [npm two-factor authentication](https://docs.npmjs.com/about-two-factor-authentication/) +- [Trusted publishing](https://docs.npmjs.com/trusted-publishers/) +- [npm provenance](https://docs.npmjs.com/generating-provenance-statements/) diff --git a/README.md b/README.md index 2df4c9ad1e7..caf0796c8d7 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,11 @@ Connect from any of these platforms: | **C#** (standalone and Unity) | [Get started](https://spacetimedb.com/docs/quickstarts/c-sharp) | | **C++** (Unreal Engine) | [Get started](https://spacetimedb.com/docs/quickstarts/c-plus-plus) | +## TypeScript Components + +Reusable TypeScript components and their runnable examples are listed in +[SpacetimeDB TypeScript Components](./COMPONENTS.md). + ## Running with Docker ```bash diff --git a/eslint.config.js b/eslint.config.js index ffe5c709e54..cd3133ccd5d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,6 +20,9 @@ export default tseslint.config( '**/dist/**', '**/build/**', '**/coverage/**', + '**/public/app.js', + '**/src/codegen/**', + '**/ts-codegen/**', '**/templates/angular-ts/.angular/**', ], }, @@ -70,7 +73,9 @@ export default tseslint.config( './templates/angular-ts/tsconfig.app.json', './docs/tsconfig.json', ], - projectService: true, + projectService: { + allowDefaultProject: ['tools/example-server-identity.ts'], + }, tsconfigRootDir: __dirname, }, }, @@ -118,6 +123,13 @@ export default tseslint.config( ], }, }, + { + files: ['spacetime-*-ts/**/example/public/ui.js'], + languageOptions: { + sourceType: 'script', + globals: globals.browser, + }, + }, { files: ['templates/angular-ts/src/**/*.ts'], rules: { diff --git a/package.json b/package.json index a97c459e51c..6474b65dd1a 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,26 @@ "type": "module", "scripts": { "run-all": "pnpm -r -F ./crates/bindings-typescript -F ./crates/bindings-typescript/examples/quickstart-chat -F ./crates/bindings-typescript/test-app -F ./docs -F \"./templates/**\" run", - "format": "pnpm run-all format && prettier eslint.config.js --write", - "lint": "pnpm run-all lint && prettier eslint.config.js --check", + "format": "pnpm run-all format && pnpm components:format && prettier eslint.config.js --write", + "lint": "pnpm run-all lint && pnpm components:lint && prettier eslint.config.js --check", "build": "pnpm run-all build", - "test": "pnpm run-all test", + "test": "pnpm run-all test && pnpm components:test", "generate": "pnpm run-all generate", - "clean": "pnpm -r exec rimraf dist .tsbuildinfo coverage" + "clean": "pnpm -r exec rimraf dist .tsbuildinfo coverage", + "components:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"COMPONENTS*.md\" \"NPM_RELEASE_CHECKLIST.md\" \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", + "components:lint": "node tools/doc-check.mjs && node tools/release-check.mjs && node tools/check-example-assets.mjs && eslint \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts && pnpm -r --filter \"./spacetime-*-ts\" run lint", + "components:test": "node tools/run-package-checks.mjs && node tools/run-example-tests.mjs", + "components:toolchain:check": "node tools/check-spacetime-release.mjs", + "components:build": "pnpm components:toolchain:check && pnpm --dir crates/bindings-typescript run build && node tools/run-module-builds.mjs && node tools/run-example-builds.mjs", + "components:consumer:check": "node tools/consumer-install-check.mjs", + "components:audit:prod": "node tools/run-production-audits.mjs", + "components:test:cron:local": "pnpm --dir spacetime-cron-ts run test:module:local && pnpm --dir spacetime-cron-ts run test:recovery", + "components:browser:install": "playwright install chromium", + "components:smoke:examples:http:fresh": "node tools/run-example-smokes.mjs --confirm-delete-data", + "components:smoke:examples:ephemeral": "node tools/run-example-smokes.mjs --ephemeral --browser", + "components:smoke:examples:fresh": "node tools/run-example-smokes.mjs --confirm-delete-data --browser", + "components:check": "pnpm components:lint && pnpm components:test && pnpm components:consumer:check", + "components:release:check:local": "pnpm components:check && pnpm components:build && pnpm components:audit:prod && pnpm components:test:cron:local && pnpm components:smoke:examples:ephemeral" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -25,6 +39,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "playwright": "1.62.1", "prettier": "^3.3.3", "rimraf": "^6.0.1", "typescript": "~5.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ed3e919ac8..c4931549668 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: globals: specifier: ^15.14.0 version: 15.15.0 + playwright: + specifier: 1.62.1 + version: 1.62.1 prettier: specifier: ^3.3.3 version: 3.6.2 @@ -49,7 +52,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.6.3) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) codex-plugin/scripts: devDependencies: @@ -134,7 +137,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) brotli-size-cli: specifier: ^1.0.0 version: 1.0.0 @@ -155,10 +158,10 @@ importers: version: 5.46.4 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.18.0)(typescript@5.9.3) + version: 10.9.2(@types/node@25.9.5)(typescript@5.9.3) tsup: specifier: ^8.1.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -167,10 +170,10 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) crates/bindings-typescript/case-conversion-test-client: dependencies: @@ -205,13 +208,13 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) typescript: specifier: ^5.2.2 version: 5.9.2 vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) docs: dependencies: @@ -340,6 +343,1051 @@ importers: specifier: workspace:^ version: link:../../crates/bindings-typescript + spacetime-agents-ts: + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-agents-ts/example: + dependencies: + '@spacetimedb/agents': + specifier: workspace:* + version: link:.. + '@spacetimedb/auth': + specifier: workspace:* + version: link:../../spacetime-auth-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-agents-ts/example/spacetimedb: + dependencies: + '@spacetimedb/agents': + specifier: workspace:* + version: link:../.. + '@spacetimedb/auth': + specifier: workspace:* + version: link:../../../spacetime-auth-ts + '@spacetimedb/files': + specifier: workspace:* + version: link:../../../spacetime-files-ts + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../../spacetime-rate-limit-ts + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-agents-ts/spacetimedb: + dependencies: + '@spacetimedb/agents': + specifier: workspace:* + version: link:.. + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-api-keys-ts: + dependencies: + '@spacetimedb/crypto': + specifier: workspace:^ + version: link:../spacetime-crypto-ts + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-api-keys-ts/example: + dependencies: + '@spacetimedb/api-keys': + specifier: workspace:* + version: link:.. + '@spacetimedb/crypto': + specifier: workspace:* + version: link:../../spacetime-crypto-ts + '@spacetimedb/grid': + specifier: workspace:* + version: link:../../spacetime-grid-ts + '@spacetimedb/presence': + specifier: workspace:* + version: link:../../spacetime-presence-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-api-keys-ts/example/spacetimedb: + dependencies: + '@spacetimedb/api-keys': + specifier: workspace:* + version: link:../.. + '@spacetimedb/crypto': + specifier: workspace:* + version: link:../../../spacetime-crypto-ts + '@spacetimedb/grid': + specifier: workspace:* + version: link:../../../spacetime-grid-ts + '@spacetimedb/presence': + specifier: workspace:* + version: link:../../../spacetime-presence-ts + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-auth-ts: + dependencies: + '@noble/curves': + specifier: ^2.2.0 + version: 2.3.0 + '@noble/hashes': + specifier: ^1.4.0 + version: 1.8.0 + '@spacetimedb/rate-limit': + specifier: workspace:^ + version: link:../spacetime-rate-limit-ts + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-auth-ts/example: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:.. + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../spacetime-rate-limit-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-auth-ts/example/spacetimedb: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:../.. + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../../spacetime-rate-limit-ts + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-auth-ts/spacetimedb: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:.. + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../spacetime-rate-limit-ts + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-cron-ts: + dependencies: + cron-parser: + specifier: 5.5.0 + version: 5.5.0 + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-cron-ts/example: + dependencies: + '@spacetimedb/cron': + specifier: workspace:* + version: link:.. + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-cron-ts/example/spacetimedb: + dependencies: + '@spacetimedb/cron': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-cron-ts/spacetimedb: + dependencies: + '@spacetimedb/cron': + specifier: workspace:* + version: link:.. + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-crypto-ts: + dependencies: + '@noble/hashes': + specifier: ^2.2.0 + version: 2.3.0 + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-files-ts: + dependencies: + '@spacetimedb/crypto': + specifier: workspace:^ + version: link:../spacetime-crypto-ts + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-files-ts/example: + dependencies: + '@spacetimedb/files': + specifier: workspace:* + version: link:.. + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-files-ts/example/spacetimedb: + dependencies: + '@spacetimedb/files': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-grid-ts: + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.22.3 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-grid-ts/example: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:../../spacetime-auth-ts + '@spacetimedb/grid': + specifier: workspace:* + version: link:.. + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../spacetime-rate-limit-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-grid-ts/example/spacetimedb: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:../../../spacetime-auth-ts + '@spacetimedb/grid': + specifier: workspace:* + version: link:../.. + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../../spacetime-rate-limit-ts + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-lobby-ts: + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-lobby-ts/example: + dependencies: + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-lobby-ts/example/spacetimedb: + dependencies: + '@spacetimedb/lobby': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-posthog-ts: + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-posthog-ts/example: + dependencies: + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-posthog-ts/example/spacetimedb: + dependencies: + '@spacetimedb/posthog': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-presence-ts: + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-presence-ts/example: + dependencies: + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-presence-ts/example/spacetimedb: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:../../../spacetime-auth-ts + '@spacetimedb/files': + specifier: workspace:* + version: link:../../../spacetime-files-ts + '@spacetimedb/presence': + specifier: workspace:* + version: link:../.. + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../../spacetime-rate-limit-ts + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-presence-ts/spacetimedb: + dependencies: + '@spacetimedb/presence': + specifier: workspace:* + version: link:.. + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-rate-limit-ts: + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-rate-limit-ts/example: + dependencies: + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-rate-limit-ts/example/spacetimedb: + dependencies: + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-rate-limit-ts/spacetimedb: + dependencies: + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:.. + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-resend-ts: + dependencies: + '@spacetimedb/crypto': + specifier: workspace:^ + version: link:../spacetime-crypto-ts + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-resend-ts/example: + dependencies: + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + resend: + specifier: ^6.12.2 + version: 6.22.1 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + spacetime-resend-ts/example/spacetimedb: + dependencies: + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../../spacetime-rate-limit-ts + '@spacetimedb/resend': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + spacetime-retry-ts: + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-retry-ts/spacetimedb: + dependencies: + '@spacetimedb/retry': + specifier: workspace:* + version: link:.. + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-stripe-ts: + dependencies: + '@spacetimedb/crypto': + specifier: workspace:^ + version: link:../spacetime-crypto-ts + stripe: + specifier: ^22.1.0 + version: 22.5.0(@types/node@25.9.5) + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-stripe-ts/example: + dependencies: + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + spacetime-stripe-ts/example/spacetimedb: + dependencies: + '@spacetimedb/stripe': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.9.5 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + templates/angular-ts: dependencies: '@angular/common': @@ -366,10 +1414,10 @@ importers: devDependencies: '@angular/build': specifier: ^21.2.12 - version: 21.2.13(@angular/compiler-cli@21.2.15(@angular/compiler@21.2.15)(typescript@5.9.3))(@angular/compiler@21.2.15)(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2))(@angular/platform-browser@21.2.15(@angular/common@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)))(@types/node@24.3.0)(chokidar@5.0.0)(jiti@2.6.1)(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + version: 21.2.13(@angular/compiler-cli@21.2.15(@angular/compiler@21.2.15)(typescript@5.9.3))(@angular/compiler@21.2.15)(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2))(@angular/platform-browser@21.2.15(@angular/common@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)))(@types/node@25.9.5)(chokidar@5.0.0)(jiti@2.6.1)(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(yaml@2.8.2) '@angular/cli': specifier: ^21.2.12 - version: 21.2.13(@types/node@24.3.0)(chokidar@5.0.0) + version: 21.2.13(@types/node@25.9.5)(chokidar@5.0.0) '@angular/compiler-cli': specifier: ^21.2.12 version: 21.2.15(@angular/compiler@21.2.15)(typescript@5.9.3) @@ -404,7 +1452,7 @@ importers: version: 5.6.3 vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) templates/bun-ts: dependencies: @@ -454,7 +1502,7 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.0.2(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.0.2(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -481,10 +1529,10 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.6.3) vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vitest: specifier: 3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) templates/chat-react-ts/spacetimedb: dependencies: @@ -519,7 +1567,7 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.0.2(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.0.2(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -543,7 +1591,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.6.3) vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) templates/keynote-2: dependencies: @@ -641,13 +1689,13 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.0.2(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.0.2(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) typescript: specifier: ~5.6.2 version: 5.6.3 vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) templates/money-exchange-react-ts: dependencies: @@ -681,7 +1729,7 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.0.2(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.0.2(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -708,16 +1756,16 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.6.3) vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vitest: specifier: 3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) templates/nuxt-ts: dependencies: nuxt: specifier: ~3.16.0 - version: 3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2) + version: 3.16.2(@parcel/watcher@2.5.6)(@types/node@25.9.5)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2) spacetimedb: specifier: workspace:* version: link:../../crates/bindings-typescript @@ -749,13 +1797,13 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.0.2(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.0.2(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) typescript: specifier: ~5.6.2 version: 5.6.3 vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) templates/remix-ts: dependencies: @@ -783,7 +1831,7 @@ importers: devDependencies: '@remix-run/dev': specifier: ^2.16.0 - version: 2.17.4(@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(@remix-run/serve@2.17.4(typescript@5.6.3))(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.6.3))(tsx@4.21.0)(typescript@5.6.3)(vite@5.4.21(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1))(yaml@2.8.2) + version: 2.17.4(@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(@remix-run/serve@2.17.4(typescript@5.6.3))(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(ts-node@10.9.2(@types/node@25.9.5)(typescript@5.6.3))(tsx@4.23.12)(typescript@5.6.3)(vite@5.4.21(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1))(yaml@2.8.2) '@types/react': specifier: ^18.3.18 version: 18.3.23 @@ -795,7 +1843,7 @@ importers: version: 5.6.3 vite: specifier: ^5.4.0 - version: 5.4.21(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1) + version: 5.4.21(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1) templates/solid-ts: dependencies: @@ -811,10 +1859,10 @@ importers: version: 5.6.3 vite: specifier: ^7.1.5 - version: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vite-plugin-solid: specifier: ^2.11.8 - version: 2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) templates/svelte-ts: dependencies: @@ -824,7 +1872,7 @@ importers: devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^5.1.1 - version: 5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) svelte: specifier: ^5.0.0 version: 5.46.4 @@ -836,7 +1884,7 @@ importers: version: 5.6.3 vite: specifier: ^6.4.1 - version: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) templates/tanstack-ts: dependencies: @@ -857,7 +1905,7 @@ importers: version: 1.162.8(@tanstack/query-core@5.90.19)(@tanstack/react-query@5.90.19(react@19.2.4))(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.162.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start': specifier: ^1.162.0 - version: 1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.102.0) + version: 1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(webpack@5.102.0) react: specifier: ^19.0.0 version: 19.2.4 @@ -879,16 +1927,16 @@ importers: version: 19.2.3(@types/react@19.1.13) '@vitejs/plugin-react': specifier: ^4.3.0 - version: 4.7.0(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) typescript: specifier: ^5.7.2 version: 5.9.3 vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) templates/vue-ts: dependencies: @@ -901,13 +1949,13 @@ importers: devDependencies: '@vitejs/plugin-vue': specifier: ^5.2.4 - version: 5.2.4(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) + version: 5.2.4(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) typescript: specifier: ~5.6.2 version: 5.6.3 vite: specifier: ^6.4.1 - version: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vue-tsc: specifier: ^2.2.0 version: 2.2.12(typescript@5.6.3) @@ -2559,14 +3607,14 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.2': - resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -2601,14 +3649,14 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.2': - resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -2643,14 +3691,14 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.2': - resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -2685,14 +3733,14 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.2': - resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -2727,14 +3775,14 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.2': - resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -2769,14 +3817,14 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.2': - resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -2811,14 +3859,14 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.2': - resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -2853,14 +3901,14 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': - resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -2895,14 +3943,14 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.2': - resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -2937,14 +3985,14 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.2': - resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -2979,14 +4027,14 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.2': - resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -3021,14 +4069,14 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.2': - resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -3063,14 +4111,14 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.2': - resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -3105,14 +4153,14 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.2': - resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -3147,14 +4195,14 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.2': - resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -3189,14 +4237,14 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.2': - resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -3231,14 +4279,14 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.2': - resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -3261,14 +4309,14 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.2': - resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -3303,14 +4351,14 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': - resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -3333,14 +4381,14 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.2': - resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -3375,14 +4423,14 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': - resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -3399,14 +4447,14 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.2': - resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -3441,14 +4489,14 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.2': - resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -3483,14 +4531,14 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.2': - resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -3525,14 +4573,14 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.2': - resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -3567,14 +4615,14 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.2': - resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -4144,49 +5192,42 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.1.1': resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.1.1': resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.1.1': resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.1.1': resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.1.1': resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.1.1': resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/nice-openharmony-arm64@1.1.1': resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} @@ -4222,6 +5263,18 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@noble/curves@2.3.0': + resolution: {integrity: sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + '@node-rs/jieba-android-arm-eabi@1.10.4': resolution: {integrity: sha512-MhyvW5N3Fwcp385d0rxbCWH42kqDBatQTyP8XbnYbju2+0BO/eTeCCLYj7Agws4pwxn2LtdldXRSKavT7WdzNA==} engines: {node: '>= 10'} @@ -4660,42 +5713,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-wasm@2.5.6': resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} @@ -5704,145 +6751,121 @@ packages: resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.56.0': resolution: {integrity: sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.50.2': resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.56.0': resolution: {integrity: sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.50.2': resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.56.0': resolution: {integrity: sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.50.2': resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-musl@4.56.0': resolution: {integrity: sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.50.2': resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-gnu@4.56.0': resolution: {integrity: sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.56.0': resolution: {integrity: sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.50.2': resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.56.0': resolution: {integrity: sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.56.0': resolution: {integrity: sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.50.2': resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.56.0': resolution: {integrity: sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.50.2': resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-musl@4.56.0': resolution: {integrity: sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.50.2': resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.56.0': resolution: {integrity: sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.50.2': resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.56.0': resolution: {integrity: sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.50.2': resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-linux-x64-musl@4.56.0': resolution: {integrity: sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.56.0': resolution: {integrity: sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==} @@ -6006,6 +7029,9 @@ packages: '@speed-highlight/core@1.2.14': resolution: {integrity: sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -6513,8 +7539,8 @@ packages: '@types/node@22.18.0': resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} - '@types/node@24.3.0': - resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} '@types/object-inspect@1.13.0': resolution: {integrity: sha512-lwGTVESDDV+XsQ1pH4UifpJ1f7OtXzQ6QBOX2Afq2bM/T3oOt8hF6exJMjjIjtEWeAN2YAo25J7HxWh97CCz9w==} @@ -8252,6 +9278,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@5.5.0: + resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} + engines: {node: '>=18'} + croner@9.1.0: resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==} engines: {node: '>=18.0'} @@ -8926,13 +9956,13 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.2: - resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -9196,6 +10226,9 @@ packages: fast-npm-meta@0.4.8: resolution: {integrity: sha512-ybZVlDZ2PkO79dosM+6CLZfKWRH8MF0PiWlw8M4mVWJl8IEJrPfxYc7Tsu830Dwj/R96LKXfePGTSzKWbPJ08w==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -9379,6 +10412,11 @@ packages: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -10584,6 +11622,10 @@ packages: lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -11834,6 +12876,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -11842,6 +12894,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.5: + resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==} + postcss-attribute-case-insensitive@7.0.1: resolution: {integrity: sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==} engines: {node: '>=18'} @@ -12999,6 +14054,15 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resend@6.22.1: + resolution: {integrity: sha512-VqYhB6zqsifujJfZsiTKBVcZaKih0XitdXHCte4x4d7KH4D0QvPx5GvqcypCn2y64tK0C/6OskpQvsaSJaHHBw==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -13491,6 +14555,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -13596,6 +14663,15 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@22.5.0: + resolution: {integrity: sha512-QVwMwriC0bbySx6R4dpsvJ0W//GojC1kwWVS6rPSoVqDUIZX4Hy3TaUrd2AZeXEAaKbfWIjQjvo3vKAReHZ0vQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + structured-clone-es@1.0.0: resolution: {integrity: sha512-FL8EeKFFyNQv5cMnXI31CIMCsFarSVI2bF0U0ImeNE3g/F1IvJQyqzOXxPBRXiwQfyBTlbNe88jh1jFW0O/jiQ==} @@ -13966,6 +15042,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + tuf-js@4.1.0: resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -14029,6 +15110,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -14050,8 +15136,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.10.0: - resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici@6.21.3: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} @@ -14394,8 +15480,8 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -15351,7 +16437,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular/build@21.2.13(@angular/compiler-cli@21.2.15(@angular/compiler@21.2.15)(typescript@5.9.3))(@angular/compiler@21.2.15)(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2))(@angular/platform-browser@21.2.15(@angular/common@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)))(@types/node@24.3.0)(chokidar@5.0.0)(jiti@2.6.1)(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.21.0)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': + '@angular/build@21.2.13(@angular/compiler-cli@21.2.15(@angular/compiler@21.2.15)(typescript@5.9.3))(@angular/compiler@21.2.15)(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2))(@angular/platform-browser@21.2.15(@angular/common@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)))(@types/node@25.9.5)(chokidar@5.0.0)(jiti@2.6.1)(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.23.12)(typescript@5.9.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(yaml@2.8.2)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2102.13(chokidar@5.0.0) @@ -15360,8 +16446,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 - '@inquirer/confirm': 5.1.21(@types/node@24.3.0) - '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + '@inquirer/confirm': 5.1.21(@types/node@25.9.5) + '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) beasties: 0.4.1 browserslist: 4.28.1 esbuild: 0.27.3 @@ -15382,14 +16468,14 @@ snapshots: tslib: 2.8.1 typescript: 5.9.3 undici: 7.24.4 - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) watchpack: 2.5.1 optionalDependencies: '@angular/core': 21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2) '@angular/platform-browser': 21.2.15(@angular/common@21.2.15(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.2.15(@angular/compiler@21.2.15)(rxjs@7.8.2)) lmdb: 3.5.1 postcss: 8.5.6 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - chokidar @@ -15403,13 +16489,13 @@ snapshots: - tsx - yaml - '@angular/cli@21.2.13(@types/node@24.3.0)(chokidar@5.0.0)': + '@angular/cli@21.2.13(@types/node@25.9.5)(chokidar@5.0.0)': dependencies: '@angular-devkit/architect': 0.2102.13(chokidar@5.0.0) '@angular-devkit/core': 21.2.13(chokidar@5.0.0) '@angular-devkit/schematics': 21.2.13(chokidar@5.0.0) - '@inquirer/prompts': 7.10.1(@types/node@24.3.0) - '@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.10.1(@types/node@24.3.0))(@types/node@24.3.0)(listr2@9.0.5) + '@inquirer/prompts': 7.10.1(@types/node@25.9.5) + '@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.10.1(@types/node@25.9.5))(@types/node@25.9.5)(listr2@9.0.5) '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6) '@schematics/angular': 21.2.13(chokidar@5.0.0) '@yarnpkg/lockfile': 1.1.0 @@ -17939,10 +19025,10 @@ snapshots: '@esbuild/aix-ppc64@0.27.0': optional: true - '@esbuild/aix-ppc64@0.27.2': + '@esbuild/aix-ppc64@0.27.3': optional: true - '@esbuild/aix-ppc64@0.27.3': + '@esbuild/aix-ppc64@0.28.2': optional: true '@esbuild/android-arm64@0.17.6': @@ -17960,10 +19046,10 @@ snapshots: '@esbuild/android-arm64@0.27.0': optional: true - '@esbuild/android-arm64@0.27.2': + '@esbuild/android-arm64@0.27.3': optional: true - '@esbuild/android-arm64@0.27.3': + '@esbuild/android-arm64@0.28.2': optional: true '@esbuild/android-arm@0.17.6': @@ -17981,10 +19067,10 @@ snapshots: '@esbuild/android-arm@0.27.0': optional: true - '@esbuild/android-arm@0.27.2': + '@esbuild/android-arm@0.27.3': optional: true - '@esbuild/android-arm@0.27.3': + '@esbuild/android-arm@0.28.2': optional: true '@esbuild/android-x64@0.17.6': @@ -18002,10 +19088,10 @@ snapshots: '@esbuild/android-x64@0.27.0': optional: true - '@esbuild/android-x64@0.27.2': + '@esbuild/android-x64@0.27.3': optional: true - '@esbuild/android-x64@0.27.3': + '@esbuild/android-x64@0.28.2': optional: true '@esbuild/darwin-arm64@0.17.6': @@ -18023,10 +19109,10 @@ snapshots: '@esbuild/darwin-arm64@0.27.0': optional: true - '@esbuild/darwin-arm64@0.27.2': + '@esbuild/darwin-arm64@0.27.3': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@esbuild/darwin-arm64@0.28.2': optional: true '@esbuild/darwin-x64@0.17.6': @@ -18044,10 +19130,10 @@ snapshots: '@esbuild/darwin-x64@0.27.0': optional: true - '@esbuild/darwin-x64@0.27.2': + '@esbuild/darwin-x64@0.27.3': optional: true - '@esbuild/darwin-x64@0.27.3': + '@esbuild/darwin-x64@0.28.2': optional: true '@esbuild/freebsd-arm64@0.17.6': @@ -18065,10 +19151,10 @@ snapshots: '@esbuild/freebsd-arm64@0.27.0': optional: true - '@esbuild/freebsd-arm64@0.27.2': + '@esbuild/freebsd-arm64@0.27.3': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@esbuild/freebsd-arm64@0.28.2': optional: true '@esbuild/freebsd-x64@0.17.6': @@ -18086,10 +19172,10 @@ snapshots: '@esbuild/freebsd-x64@0.27.0': optional: true - '@esbuild/freebsd-x64@0.27.2': + '@esbuild/freebsd-x64@0.27.3': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@esbuild/freebsd-x64@0.28.2': optional: true '@esbuild/linux-arm64@0.17.6': @@ -18107,10 +19193,10 @@ snapshots: '@esbuild/linux-arm64@0.27.0': optional: true - '@esbuild/linux-arm64@0.27.2': + '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/linux-arm64@0.27.3': + '@esbuild/linux-arm64@0.28.2': optional: true '@esbuild/linux-arm@0.17.6': @@ -18128,10 +19214,10 @@ snapshots: '@esbuild/linux-arm@0.27.0': optional: true - '@esbuild/linux-arm@0.27.2': + '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/linux-arm@0.27.3': + '@esbuild/linux-arm@0.28.2': optional: true '@esbuild/linux-ia32@0.17.6': @@ -18149,10 +19235,10 @@ snapshots: '@esbuild/linux-ia32@0.27.0': optional: true - '@esbuild/linux-ia32@0.27.2': + '@esbuild/linux-ia32@0.27.3': optional: true - '@esbuild/linux-ia32@0.27.3': + '@esbuild/linux-ia32@0.28.2': optional: true '@esbuild/linux-loong64@0.17.6': @@ -18170,10 +19256,10 @@ snapshots: '@esbuild/linux-loong64@0.27.0': optional: true - '@esbuild/linux-loong64@0.27.2': + '@esbuild/linux-loong64@0.27.3': optional: true - '@esbuild/linux-loong64@0.27.3': + '@esbuild/linux-loong64@0.28.2': optional: true '@esbuild/linux-mips64el@0.17.6': @@ -18191,10 +19277,10 @@ snapshots: '@esbuild/linux-mips64el@0.27.0': optional: true - '@esbuild/linux-mips64el@0.27.2': + '@esbuild/linux-mips64el@0.27.3': optional: true - '@esbuild/linux-mips64el@0.27.3': + '@esbuild/linux-mips64el@0.28.2': optional: true '@esbuild/linux-ppc64@0.17.6': @@ -18212,10 +19298,10 @@ snapshots: '@esbuild/linux-ppc64@0.27.0': optional: true - '@esbuild/linux-ppc64@0.27.2': + '@esbuild/linux-ppc64@0.27.3': optional: true - '@esbuild/linux-ppc64@0.27.3': + '@esbuild/linux-ppc64@0.28.2': optional: true '@esbuild/linux-riscv64@0.17.6': @@ -18233,10 +19319,10 @@ snapshots: '@esbuild/linux-riscv64@0.27.0': optional: true - '@esbuild/linux-riscv64@0.27.2': + '@esbuild/linux-riscv64@0.27.3': optional: true - '@esbuild/linux-riscv64@0.27.3': + '@esbuild/linux-riscv64@0.28.2': optional: true '@esbuild/linux-s390x@0.17.6': @@ -18254,10 +19340,10 @@ snapshots: '@esbuild/linux-s390x@0.27.0': optional: true - '@esbuild/linux-s390x@0.27.2': + '@esbuild/linux-s390x@0.27.3': optional: true - '@esbuild/linux-s390x@0.27.3': + '@esbuild/linux-s390x@0.28.2': optional: true '@esbuild/linux-x64@0.17.6': @@ -18275,10 +19361,10 @@ snapshots: '@esbuild/linux-x64@0.27.0': optional: true - '@esbuild/linux-x64@0.27.2': + '@esbuild/linux-x64@0.27.3': optional: true - '@esbuild/linux-x64@0.27.3': + '@esbuild/linux-x64@0.28.2': optional: true '@esbuild/netbsd-arm64@0.24.2': @@ -18290,10 +19376,10 @@ snapshots: '@esbuild/netbsd-arm64@0.27.0': optional: true - '@esbuild/netbsd-arm64@0.27.2': + '@esbuild/netbsd-arm64@0.27.3': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@esbuild/netbsd-arm64@0.28.2': optional: true '@esbuild/netbsd-x64@0.17.6': @@ -18311,10 +19397,10 @@ snapshots: '@esbuild/netbsd-x64@0.27.0': optional: true - '@esbuild/netbsd-x64@0.27.2': + '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@esbuild/netbsd-x64@0.28.2': optional: true '@esbuild/openbsd-arm64@0.24.2': @@ -18326,10 +19412,10 @@ snapshots: '@esbuild/openbsd-arm64@0.27.0': optional: true - '@esbuild/openbsd-arm64@0.27.2': + '@esbuild/openbsd-arm64@0.27.3': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@esbuild/openbsd-arm64@0.28.2': optional: true '@esbuild/openbsd-x64@0.17.6': @@ -18347,10 +19433,10 @@ snapshots: '@esbuild/openbsd-x64@0.27.0': optional: true - '@esbuild/openbsd-x64@0.27.2': + '@esbuild/openbsd-x64@0.27.3': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@esbuild/openbsd-x64@0.28.2': optional: true '@esbuild/openharmony-arm64@0.25.9': @@ -18359,10 +19445,10 @@ snapshots: '@esbuild/openharmony-arm64@0.27.0': optional: true - '@esbuild/openharmony-arm64@0.27.2': + '@esbuild/openharmony-arm64@0.27.3': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@esbuild/openharmony-arm64@0.28.2': optional: true '@esbuild/sunos-x64@0.17.6': @@ -18380,10 +19466,10 @@ snapshots: '@esbuild/sunos-x64@0.27.0': optional: true - '@esbuild/sunos-x64@0.27.2': + '@esbuild/sunos-x64@0.27.3': optional: true - '@esbuild/sunos-x64@0.27.3': + '@esbuild/sunos-x64@0.28.2': optional: true '@esbuild/win32-arm64@0.17.6': @@ -18401,10 +19487,10 @@ snapshots: '@esbuild/win32-arm64@0.27.0': optional: true - '@esbuild/win32-arm64@0.27.2': + '@esbuild/win32-arm64@0.27.3': optional: true - '@esbuild/win32-arm64@0.27.3': + '@esbuild/win32-arm64@0.28.2': optional: true '@esbuild/win32-ia32@0.17.6': @@ -18422,10 +19508,10 @@ snapshots: '@esbuild/win32-ia32@0.27.0': optional: true - '@esbuild/win32-ia32@0.27.2': + '@esbuild/win32-ia32@0.27.3': optional: true - '@esbuild/win32-ia32@0.27.3': + '@esbuild/win32-ia32@0.28.2': optional: true '@esbuild/win32-x64@0.17.6': @@ -18443,10 +19529,10 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true - '@esbuild/win32-x64@0.27.2': + '@esbuild/win32-x64@0.27.3': optional: true - '@esbuild/win32-x64@0.27.3': + '@esbuild/win32-x64@0.28.2': optional: true '@eslint-community/eslint-utils@4.7.0(eslint@9.33.0(jiti@2.6.1))': @@ -18854,128 +19940,128 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@24.3.0)': + '@inquirer/checkbox@4.3.2(@types/node@25.9.5)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/type': 3.0.10(@types/node@25.9.5) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/confirm@5.1.21(@types/node@24.3.0)': + '@inquirer/confirm@5.1.21(@types/node@25.9.5)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.3.0) - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) + '@inquirer/type': 3.0.10(@types/node@25.9.5) optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/core@10.3.2(@types/node@24.3.0)': + '@inquirer/core@10.3.2(@types/node@25.9.5)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/type': 3.0.10(@types/node@25.9.5) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/editor@4.2.23(@types/node@24.3.0)': + '@inquirer/editor@4.2.23(@types/node@25.9.5)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.3.0) - '@inquirer/external-editor': 1.0.3(@types/node@24.3.0) - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) + '@inquirer/external-editor': 1.0.3(@types/node@25.9.5) + '@inquirer/type': 3.0.10(@types/node@25.9.5) optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/expand@4.0.23(@types/node@24.3.0)': + '@inquirer/expand@4.0.23(@types/node@25.9.5)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.3.0) - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) + '@inquirer/type': 3.0.10(@types/node@25.9.5) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/external-editor@1.0.3(@types/node@24.3.0)': + '@inquirer/external-editor@1.0.3(@types/node@25.9.5)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@24.3.0)': + '@inquirer/input@4.3.1(@types/node@25.9.5)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.3.0) - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) + '@inquirer/type': 3.0.10(@types/node@25.9.5) optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/number@3.0.23(@types/node@24.3.0)': + '@inquirer/number@3.0.23(@types/node@25.9.5)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.3.0) - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) + '@inquirer/type': 3.0.10(@types/node@25.9.5) optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/password@4.0.23(@types/node@24.3.0)': + '@inquirer/password@4.0.23(@types/node@25.9.5)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.3.0) - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) + '@inquirer/type': 3.0.10(@types/node@25.9.5) optionalDependencies: - '@types/node': 24.3.0 - - '@inquirer/prompts@7.10.1(@types/node@24.3.0)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.3.0) - '@inquirer/confirm': 5.1.21(@types/node@24.3.0) - '@inquirer/editor': 4.2.23(@types/node@24.3.0) - '@inquirer/expand': 4.0.23(@types/node@24.3.0) - '@inquirer/input': 4.3.1(@types/node@24.3.0) - '@inquirer/number': 3.0.23(@types/node@24.3.0) - '@inquirer/password': 4.0.23(@types/node@24.3.0) - '@inquirer/rawlist': 4.1.11(@types/node@24.3.0) - '@inquirer/search': 3.2.2(@types/node@24.3.0) - '@inquirer/select': 4.4.2(@types/node@24.3.0) + '@types/node': 25.9.5 + + '@inquirer/prompts@7.10.1(@types/node@25.9.5)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.9.5) + '@inquirer/confirm': 5.1.21(@types/node@25.9.5) + '@inquirer/editor': 4.2.23(@types/node@25.9.5) + '@inquirer/expand': 4.0.23(@types/node@25.9.5) + '@inquirer/input': 4.3.1(@types/node@25.9.5) + '@inquirer/number': 3.0.23(@types/node@25.9.5) + '@inquirer/password': 4.0.23(@types/node@25.9.5) + '@inquirer/rawlist': 4.1.11(@types/node@25.9.5) + '@inquirer/search': 3.2.2(@types/node@25.9.5) + '@inquirer/select': 4.4.2(@types/node@25.9.5) optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/rawlist@4.1.11(@types/node@24.3.0)': + '@inquirer/rawlist@4.1.11(@types/node@25.9.5)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.3.0) - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) + '@inquirer/type': 3.0.10(@types/node@25.9.5) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/search@3.2.2(@types/node@24.3.0)': + '@inquirer/search@3.2.2(@types/node@25.9.5)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/type': 3.0.10(@types/node@25.9.5) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/select@4.4.2(@types/node@24.3.0)': + '@inquirer/select@4.4.2(@types/node@25.9.5)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.3.0) + '@inquirer/core': 10.3.2(@types/node@25.9.5) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/type': 3.0.10(@types/node@25.9.5) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 - '@inquirer/type@3.0.10(@types/node@24.3.0)': + '@inquirer/type@3.0.10(@types/node@25.9.5)': optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 '@internationalized/date@3.10.0': dependencies: @@ -19093,10 +20179,10 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} - '@listr2/prompt-adapter-inquirer@3.0.5(@inquirer/prompts@7.10.1(@types/node@24.3.0))(@types/node@24.3.0)(listr2@9.0.5)': + '@listr2/prompt-adapter-inquirer@3.0.5(@inquirer/prompts@7.10.1(@types/node@25.9.5))(@types/node@25.9.5)(listr2@9.0.5)': dependencies: - '@inquirer/prompts': 7.10.1(@types/node@24.3.0) - '@inquirer/type': 3.0.10(@types/node@24.3.0) + '@inquirer/prompts': 7.10.1(@types/node@25.9.5) + '@inquirer/type': 3.0.10(@types/node@25.9.5) listr2: 9.0.5 transitivePeerDependencies: - '@types/node' @@ -19319,6 +20405,14 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/curves@2.3.0': + dependencies: + '@noble/hashes': 2.3.0 + + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.3.0': {} + '@node-rs/jieba-android-arm-eabi@1.10.4': optional: true @@ -19484,11 +20578,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@nuxt/cli@3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.3.5)': + '@nuxt/cli@3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.5.1)': dependencies: '@bomb.sh/tab': 0.0.12(cac@6.7.14)(citty@0.2.0) '@clack/prompts': 1.0.0 - c12: 3.3.3(magicast@0.3.5) + c12: 3.3.3(magicast@0.5.1) citty: 0.2.0 confbox: 0.2.4 consola: 3.4.2 @@ -19524,11 +20618,11 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@2.7.0(magicast@0.3.5)(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@nuxt/devtools-kit@2.7.0(magicast@0.3.5)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: '@nuxt/kit': 3.21.1(magicast@0.3.5) execa: 8.0.1 - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - magicast @@ -19543,12 +20637,12 @@ snapshots: prompts: 2.4.2 semver: 7.7.4 - '@nuxt/devtools@2.7.0(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3))': + '@nuxt/devtools@2.7.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3))': dependencies: - '@nuxt/devtools-kit': 2.7.0(magicast@0.3.5)(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/devtools-kit': 2.7.0(magicast@0.3.5)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) '@nuxt/devtools-wizard': 2.7.0 '@nuxt/kit': 3.21.1(magicast@0.3.5) - '@vue/devtools-core': 7.7.9(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) + '@vue/devtools-core': 7.7.9(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) '@vue/devtools-kit': 7.7.9 birpc: 2.9.0 consola: 3.4.2 @@ -19573,9 +20667,9 @@ snapshots: sirv: 3.0.2 structured-clone-es: 1.0.0 tinyglobby: 0.2.15 - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vite-plugin-inspect: 11.3.3(@nuxt/kit@3.21.1(magicast@0.3.5))(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) - vite-plugin-vue-tracer: 1.2.0(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vite-plugin-inspect: 11.3.3(@nuxt/kit@3.21.1(magicast@0.3.5))(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) + vite-plugin-vue-tracer: 1.2.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) which: 5.0.0 ws: 8.18.3 transitivePeerDependencies: @@ -19584,9 +20678,9 @@ snapshots: - utf-8-validate - vue - '@nuxt/kit@3.16.2(magicast@0.3.5)': + '@nuxt/kit@3.16.2(magicast@0.5.1)': dependencies: - c12: 3.3.3(magicast@0.3.5) + c12: 3.3.3(magicast@0.5.1) consola: 3.4.2 defu: 6.1.4 destr: 2.0.5 @@ -19644,21 +20738,21 @@ snapshots: pathe: 2.0.3 std-env: 3.10.0 - '@nuxt/telemetry@2.7.0(@nuxt/kit@3.16.2(magicast@0.3.5))': + '@nuxt/telemetry@2.7.0(@nuxt/kit@3.16.2(magicast@0.5.1))': dependencies: - '@nuxt/kit': 3.16.2(magicast@0.3.5) + '@nuxt/kit': 3.16.2(magicast@0.5.1) citty: 0.2.0 consola: 3.4.2 ofetch: 2.0.0-alpha.3 rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@3.16.2(@types/node@24.3.0)(eslint@9.33.0(jiti@2.6.1))(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2)': + '@nuxt/vite-builder@3.16.2(@types/node@25.9.5)(eslint@9.33.0(jiti@2.6.1))(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2)': dependencies: - '@nuxt/kit': 3.16.2(magicast@0.3.5) + '@nuxt/kit': 3.16.2(magicast@0.5.1) '@rollup/plugin-replace': 6.0.3(rollup@4.56.0) - '@vitejs/plugin-vue': 5.2.4(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) - '@vitejs/plugin-vue-jsx': 4.2.0(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) + '@vitejs/plugin-vue': 5.2.4(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) + '@vitejs/plugin-vue-jsx': 4.2.0(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) autoprefixer: 10.4.21(postcss@8.5.6) consola: 3.4.2 cssnano: 7.1.2(postcss@8.5.6) @@ -19684,9 +20778,9 @@ snapshots: ufo: 1.6.3 unenv: 2.0.0-rc.24 unplugin: 2.3.11 - vite: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vite-plugin-checker: 0.9.3(eslint@9.33.0(jiti@2.6.1))(optionator@0.9.4)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3)) + vite: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vite-plugin-checker: 0.9.3(eslint@9.33.0(jiti@2.6.1))(optionator@0.9.4)(typescript@5.6.3)(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3)) vue: 3.5.26(typescript@5.6.3) vue-bundle-renderer: 2.2.0 transitivePeerDependencies: @@ -20636,7 +21730,7 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@remix-run/dev@2.17.4(@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(@remix-run/serve@2.17.4(typescript@5.6.3))(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.6.3))(tsx@4.21.0)(typescript@5.6.3)(vite@5.4.21(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1))(yaml@2.8.2)': + '@remix-run/dev@2.17.4(@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(@remix-run/serve@2.17.4(typescript@5.6.3))(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(ts-node@10.9.2(@types/node@25.9.5)(typescript@5.6.3))(tsx@4.23.12)(typescript@5.6.3)(vite@5.4.21(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1))(yaml@2.8.2)': dependencies: '@babel/core': 7.28.6 '@babel/generator': 7.28.6 @@ -20653,7 +21747,7 @@ snapshots: '@remix-run/router': 1.23.2 '@remix-run/server-runtime': 2.17.4(typescript@5.6.3) '@types/mdx': 2.0.13 - '@vanilla-extract/integration': 6.5.0(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1) + '@vanilla-extract/integration': 6.5.0(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1) arg: 5.0.2 cacache: 17.1.4 chalk: 4.1.2 @@ -20681,7 +21775,7 @@ snapshots: pidtree: 0.6.0 postcss: 8.5.6 postcss-discard-duplicates: 5.1.0(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.6.3)) + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@25.9.5)(typescript@5.6.3)) postcss-modules: 6.0.1(postcss@8.5.6) prettier: 2.8.8 pretty-ms: 7.0.1 @@ -20692,13 +21786,13 @@ snapshots: set-cookie-parser: 2.7.2 tar-fs: 2.1.4 tsconfig-paths: 4.2.0 - valibot: 1.2.0(typescript@5.6.3) - vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + valibot: 1.4.2(typescript@5.6.3) + vite-node: 3.2.4(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) ws: 7.5.10 optionalDependencies: '@remix-run/serve': 2.17.4(typescript@5.6.3) typescript: 5.6.3 - vite: 5.4.21(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1) + vite: 5.4.21(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -21199,6 +22293,8 @@ snapshots: '@speed-highlight/core@1.2.14': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.0.0': {} '@supabase/auth-js@2.97.0': @@ -21243,25 +22339,25 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)))(svelte@5.46.4)(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) debug: 4.4.3 svelte: 5.46.4 - vite: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.46.4)(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)))(svelte@5.46.4)(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) debug: 4.4.3 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.21 svelte: 5.46.4 - vite: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vitefu: 1.1.1(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) transitivePeerDependencies: - supports-color @@ -21449,19 +22545,19 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.102.0)': + '@tanstack/react-start@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(webpack@5.102.0)': dependencies: '@tanstack/react-router': 1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-client': 1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-start-server': 1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-utils': 1.161.4 '@tanstack/start-client-core': 1.162.6 - '@tanstack/start-plugin-core': 1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.102.0) + '@tanstack/start-plugin-core': 1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(webpack@5.102.0) '@tanstack/start-server-core': 1.162.6 pathe: 2.0.3 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -21503,12 +22599,12 @@ snapshots: prettier: 3.6.2 recast: 0.23.11 source-map: 0.7.6 - tsx: 4.21.0 + tsx: 4.23.12 zod: 3.25.76 transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.102.0)': + '@tanstack/router-plugin@1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(webpack@5.102.0)': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) @@ -21525,8 +22621,8 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) webpack: 5.102.0 transitivePeerDependencies: - supports-color @@ -21561,7 +22657,7 @@ snapshots: '@tanstack/start-fn-stubs@1.161.4': {} - '@tanstack/start-plugin-core@1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.102.0)': + '@tanstack/start-plugin-core@1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(webpack@5.102.0)': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 @@ -21569,7 +22665,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.162.6 '@tanstack/router-generator': 1.162.6 - '@tanstack/router-plugin': 1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.102.0) + '@tanstack/router-plugin': 1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)))(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(webpack@5.102.0) '@tanstack/router-utils': 1.161.4 '@tanstack/start-client-core': 1.162.6 '@tanstack/start-server-core': 1.162.6 @@ -21581,8 +22677,8 @@ snapshots: srvx: 0.11.7 tinyglobby: 0.2.15 ufo: 1.6.3 - vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 3.25.76 transitivePeerDependencies: @@ -21869,10 +22965,9 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.3.0': + '@types/node@25.9.5': dependencies: - undici-types: 7.10.0 - optional: true + undici-types: 7.24.6 '@types/object-inspect@1.13.0': {} @@ -22188,7 +23283,7 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - '@vanilla-extract/integration@6.5.0(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1)': + '@vanilla-extract/integration@6.5.0(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1)': dependencies: '@babel/core': 7.28.6 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.6) @@ -22201,8 +23296,8 @@ snapshots: lodash: 4.17.21 mlly: 1.8.0 outdent: 0.8.0 - vite: 5.4.21(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1) - vite-node: 1.6.1(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1) + vite: 5.4.21(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1) + vite-node: 1.6.1(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -22238,11 +23333,11 @@ snapshots: '@vercel/oidc@3.0.3': {} - '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) - '@vitejs/plugin-react@4.7.0(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) @@ -22250,11 +23345,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) @@ -22262,11 +23357,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.0.2(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.0.2(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) @@ -22274,27 +23369,27 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.34 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue-jsx@4.2.0(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3))': + '@vitejs/plugin-vue-jsx@4.2.0(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.0-beta.34 '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0) - vite: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vue: 3.5.26(typescript@5.6.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3))': + '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3))': dependencies: - vite: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vue: 3.5.26(typescript@5.6.3) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -22309,7 +23404,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -22321,21 +23416,21 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) - '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -22482,14 +23577,14 @@ snapshots: '@vue/devtools-api@6.6.4': {} - '@vue/devtools-core@7.7.9(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3))': + '@vue/devtools-core@7.7.9(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3))': dependencies: '@vue/devtools-kit': 7.7.9 '@vue/devtools-shared': 7.7.9 mitt: 3.0.1 nanoid: 5.1.6 pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + vite-hot-client: 2.1.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) vue: 3.5.26(typescript@5.6.3) transitivePeerDependencies: - vite @@ -24549,6 +25644,10 @@ snapshots: create-require@1.1.1: {} + cron-parser@5.5.0: + dependencies: + luxon: 3.7.2 + croner@9.1.0: {} cross-spawn@7.0.6: @@ -25242,35 +26341,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.0 '@esbuild/win32-x64': 0.27.0 - esbuild@0.27.2: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.2 - '@esbuild/android-arm': 0.27.2 - '@esbuild/android-arm64': 0.27.2 - '@esbuild/android-x64': 0.27.2 - '@esbuild/darwin-arm64': 0.27.2 - '@esbuild/darwin-x64': 0.27.2 - '@esbuild/freebsd-arm64': 0.27.2 - '@esbuild/freebsd-x64': 0.27.2 - '@esbuild/linux-arm': 0.27.2 - '@esbuild/linux-arm64': 0.27.2 - '@esbuild/linux-ia32': 0.27.2 - '@esbuild/linux-loong64': 0.27.2 - '@esbuild/linux-mips64el': 0.27.2 - '@esbuild/linux-ppc64': 0.27.2 - '@esbuild/linux-riscv64': 0.27.2 - '@esbuild/linux-s390x': 0.27.2 - '@esbuild/linux-x64': 0.27.2 - '@esbuild/netbsd-arm64': 0.27.2 - '@esbuild/netbsd-x64': 0.27.2 - '@esbuild/openbsd-arm64': 0.27.2 - '@esbuild/openbsd-x64': 0.27.2 - '@esbuild/openharmony-arm64': 0.27.2 - '@esbuild/sunos-x64': 0.27.2 - '@esbuild/win32-arm64': 0.27.2 - '@esbuild/win32-ia32': 0.27.2 - '@esbuild/win32-x64': 0.27.2 - esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -25300,6 +26370,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.1.1: {} escalade@3.2.0: {} @@ -25662,6 +26761,8 @@ snapshots: fast-npm-meta@0.4.8: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fastq@1.19.1: @@ -25855,6 +26956,9 @@ snapshots: dependencies: minipass: 7.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -27227,6 +28331,8 @@ snapshots: lunr@2.3.9: {} + luxon@3.7.2: {} + lz-string@1.5.0: {} magic-string-ast@0.7.1: @@ -28360,7 +29466,7 @@ snapshots: defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 - esbuild: 0.27.2 + esbuild: 0.27.3 escape-string-regexp: 5.0.0 etag: 1.8.1 exsolve: 1.0.8 @@ -28608,19 +29714,19 @@ snapshots: schema-utils: 3.3.0 webpack: 5.102.0 - nuxt@3.16.2(@parcel/watcher@2.5.6)(@types/node@24.3.0)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2): + nuxt@3.16.2(@parcel/watcher@2.5.6)(@types/node@25.9.5)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2): dependencies: - '@nuxt/cli': 3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.3.5) + '@nuxt/cli': 3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.5.1) '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 2.7.0(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) - '@nuxt/kit': 3.16.2(magicast@0.3.5) + '@nuxt/devtools': 2.7.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) + '@nuxt/kit': 3.16.2(magicast@0.5.1) '@nuxt/schema': 3.16.2 - '@nuxt/telemetry': 2.7.0(@nuxt/kit@3.16.2(magicast@0.3.5)) - '@nuxt/vite-builder': 3.16.2(@types/node@24.3.0)(eslint@9.33.0(jiti@2.6.1))(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2) + '@nuxt/telemetry': 2.7.0(@nuxt/kit@3.16.2(magicast@0.5.1)) + '@nuxt/vite-builder': 3.16.2(@types/node@25.9.5)(eslint@9.33.0(jiti@2.6.1))(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2) '@oxc-parser/wasm': 0.60.0 '@unhead/vue': 2.1.4(vue@3.5.26(typescript@5.6.3)) '@vue/shared': 3.5.26 - c12: 3.3.3(magicast@0.3.5) + c12: 3.3.3(magicast@0.5.1) chokidar: 4.0.3 compatx: 0.1.8 consola: 3.4.2 @@ -28675,7 +29781,7 @@ snapshots: vue-router: 4.6.4(vue@3.5.26(typescript@5.6.3)) optionalDependencies: '@parcel/watcher': 2.5.6 - '@types/node': 24.3.0 + '@types/node': 25.9.5 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -29211,10 +30317,20 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} + postal-mime@2.7.5: {} + postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -29398,21 +30514,21 @@ snapshots: '@csstools/utilities': 2.0.0(postcss@8.5.6) postcss: 8.5.6 - postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.3.0)(typescript@5.6.3)): + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@types/node@25.9.5)(typescript@5.6.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.2 optionalDependencies: postcss: 8.5.6 - ts-node: 10.9.2(@types/node@24.3.0)(typescript@5.6.3) + ts-node: 10.9.2(@types/node@25.9.5)(typescript@5.6.3) - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.23.12)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.6 - tsx: 4.21.0 + tsx: 4.23.12 yaml: 2.8.2 postcss-loader@7.3.4(postcss@8.5.6)(typescript@5.6.3)(webpack@5.102.0): @@ -30514,6 +31630,11 @@ snapshots: requires-port@1.0.0: {} + resend@6.22.1: + dependencies: + postal-mime: 2.7.5 + standardwebhooks: 1.0.0 + reserved-identifiers@1.2.0: {} resolve-alpn@1.2.1: {} @@ -31131,6 +32252,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@1.5.0: {} statuses@2.0.1: {} @@ -31232,6 +32358,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@22.5.0(@types/node@25.9.5): + optionalDependencies: + '@types/node': 25.9.5 + structured-clone-es@1.0.0: {} style-to-js@1.1.17: @@ -31555,42 +32685,42 @@ snapshots: '@ts-morph/common': 0.20.0 code-block-writer: 12.0.0 - ts-node@10.9.2(@types/node@22.18.0)(typescript@5.9.3): + ts-node@10.9.2(@types/node@25.9.5)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.18.0 + '@types/node': 25.9.5 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.9.3 + typescript: 5.6.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optional: true - ts-node@10.9.2(@types/node@24.3.0)(typescript@5.6.3): + ts-node@10.9.2(@types/node@25.9.5)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 24.3.0 + '@types/node': 25.9.5 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.6.3 + typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true ts-pattern@5.0.5: {} @@ -31610,7 +32740,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.25.9) cac: 6.7.14 @@ -31621,7 +32751,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.23.12)(yaml@2.8.2) resolve-from: 5.0.0 rollup: 4.50.2 source-map: 0.8.0-beta.0 @@ -31640,11 +32770,17 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.2 + esbuild: 0.27.3 get-tsconfig: 4.10.1 optionalDependencies: fsevents: 2.3.3 + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tuf-js@4.1.0: dependencies: '@tufjs/models': 4.1.0 @@ -31716,6 +32852,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + ufo@1.6.1: {} ufo@1.6.3: {} @@ -31735,8 +32873,7 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.10.0: - optional: true + undici-types@7.24.6: {} undici@6.21.3: {} @@ -32102,10 +33239,14 @@ snapshots: v8-compile-cache-lib@3.0.1: {} - valibot@1.2.0(typescript@5.6.3): + valibot@1.4.2(typescript@5.6.3): optionalDependencies: typescript: 5.6.3 + valibot@1.4.2(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -32151,23 +33292,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-dev-rpc@1.1.0(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-dev-rpc@1.1.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)): dependencies: birpc: 2.9.0 - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vite-hot-client: 2.1.0(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vite-hot-client: 2.1.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) - vite-hot-client@2.1.0(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-hot-client@2.1.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)): dependencies: - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) - vite-node@1.6.1(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1): + vite-node@1.6.1(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1): dependencies: cac: 6.7.14 debug: 4.4.3 pathe: 1.1.2 picocolors: 1.1.1 - vite: 5.4.21(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1) + vite: 5.4.21(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1) transitivePeerDependencies: - '@types/node' - less @@ -32179,13 +33320,13 @@ snapshots: - supports-color - terser - vite-node@3.2.4(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -32200,13 +33341,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -32221,7 +33362,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.9.3(eslint@9.33.0(jiti@2.6.1))(optionator@0.9.4)(typescript@5.6.3)(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3)): + vite-plugin-checker@0.9.3(eslint@9.33.0(jiti@2.6.1))(optionator@0.9.4)(typescript@5.6.3)(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3)): dependencies: '@babel/code-frame': 7.28.6 chokidar: 4.0.3 @@ -32231,7 +33372,7 @@ snapshots: strip-ansi: 7.1.2 tiny-invariant: 1.3.3 tinyglobby: 0.2.15 - vite: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vscode-uri: 3.1.0 optionalDependencies: eslint: 9.33.0(jiti@2.6.1) @@ -32239,7 +33380,7 @@ snapshots: typescript: 5.6.3 vue-tsc: 2.2.12(typescript@5.6.3) - vite-plugin-inspect@11.3.3(@nuxt/kit@3.21.1(magicast@0.3.5))(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-plugin-inspect@11.3.3(@nuxt/kit@3.21.1(magicast@0.3.5))(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)): dependencies: ansis: 4.2.0 debug: 4.4.3 @@ -32249,14 +33390,14 @@ snapshots: perfect-debounce: 2.1.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vite-dev-rpc: 1.1.0(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vite-dev-rpc: 1.1.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) optionalDependencies: '@nuxt/kit': 3.21.1(magicast@0.3.5) transitivePeerDependencies: - supports-color - vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)): dependencies: '@babel/core': 7.29.0 '@types/babel__core': 7.20.5 @@ -32264,15 +33405,15 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.13 solid-refresh: 0.6.3(solid-js@1.9.13) - vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) optionalDependencies: '@testing-library/jest-dom': 6.7.0 transitivePeerDependencies: - supports-color optional: true - vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.7.0)(solid-js@1.9.13)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)): dependencies: '@babel/core': 7.29.0 '@types/babel__core': 7.20.5 @@ -32280,46 +33421,46 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.13 solid-refresh: 0.6.3(solid-js@1.9.13) - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) optionalDependencies: '@testing-library/jest-dom': 6.7.0 transitivePeerDependencies: - supports-color - vite-plugin-vue-tracer@1.2.0(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)): + vite-plugin-vue-tracer@1.2.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.0.8 magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vue: 3.5.26(typescript@5.6.3) - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@5.4.21(@types/node@24.3.0)(sass@1.97.3)(terser@5.43.1): + vite@5.4.21(@types/node@25.9.5)(sass@1.97.3)(terser@5.43.1): dependencies: esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.56.0 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 fsevents: 2.3.3 sass: 1.97.3 terser: 5.43.1 - vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -32328,15 +33469,15 @@ snapshots: rollup: 4.56.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 fsevents: 2.3.3 jiti: 2.6.1 sass: 1.97.3 terser: 5.43.1 - tsx: 4.21.0 + tsx: 4.23.12 yaml: 2.8.2 - vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -32350,10 +33491,10 @@ snapshots: jiti: 2.6.1 sass: 1.97.3 terser: 5.43.1 - tsx: 4.21.0 + tsx: 4.23.12 yaml: 2.8.2 - vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -32362,15 +33503,15 @@ snapshots: rollup: 4.50.2 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 fsevents: 2.3.3 jiti: 2.6.1 sass: 1.97.3 terser: 5.43.1 - tsx: 4.21.0 + tsx: 4.23.12 yaml: 2.8.2 - vite@7.3.2(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.2(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.4) @@ -32384,10 +33525,10 @@ snapshots: jiti: 2.6.1 sass: 1.97.3 terser: 5.43.1 - tsx: 4.21.0 + tsx: 4.23.12 yaml: 2.8.2 - vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.4) @@ -32396,31 +33537,31 @@ snapshots: rollup: 4.56.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 25.9.5 fsevents: 2.3.3 jiti: 2.6.1 sass: 1.97.3 terser: 5.43.1 - tsx: 4.21.0 + tsx: 4.23.12 yaml: 2.8.2 - vitefu@1.1.1(vite@6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)): optionalDependencies: - vite: 6.4.1(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) - vitefu@1.1.1(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)): optionalDependencies: - vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) - vitefu@1.1.1(vite@7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vitefu@1.1.1(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)): optionalDependencies: - vite: 7.3.2(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -32438,8 +33579,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -32459,11 +33600,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -32481,12 +33622,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) - vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 24.3.0 + '@types/node': 25.9.5 jsdom: 26.1.0 transitivePeerDependencies: - jiti diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ae08557f54..5ce55660405 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,4 +29,8 @@ packages: - 'modules/sdk-test-case-conversion-ts' - 'docs' - 'codex-plugin/scripts' + - 'spacetime-*-ts' + - 'spacetime-*-ts/example' + - 'spacetime-*-ts/spacetimedb' + - 'spacetime-*-ts/example/spacetimedb' minimumReleaseAge: 1440 diff --git a/spacetime-agents-ts/LICENSE.txt b/spacetime-agents-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-agents-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-agents-ts/README.md b/spacetime-agents-ts/README.md new file mode 100644 index 00000000000..1aa355b50dd --- /dev/null +++ b/spacetime-agents-ts/README.md @@ -0,0 +1,142 @@ +# @spacetimedb/agents + +Typed tools, agent definitions, chat-provider adapters, embeddings helpers, and +dispatch utilities for SpacetimeDB TypeScript modules. This pure helper leaves +persistence, authorization, and lifecycle hooks to the host module. + +## Install + +```bash +npm install @spacetimedb/agents spacetimedb@^2.8.3 +``` + +`spacetimedb` is a peer dependency. Keep its version aligned with the SDK used +to build the host module. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +## Usage + +### Integrate into an application + +This helper package supplies agent and provider primitives. Your host module +owns conversation tables, authorization, provider-key storage, and the +procedure that performs HTTP. Define the registry at module scope, then call +the provider from a procedure with `ctx.http`. + +Define tools with SpacetimeDB type builders. The declaration produces the JSON +Schema sent to the model and validates every returned tool call before the +handler runs. + +```ts +import { t } from 'spacetimedb/server'; +import { + agentTool, + callChat, + defineAgent, + makeAgentRegistry, + openRouterProvider, +} from '@spacetimedb/agents'; + +const getTime = agentTool('Return the current module time.', t.unit(), ctx => + String(ctx.timestamp.microsSinceUnixEpoch) +); + +const agents = { + support: defineAgent({ + defaultProvider: 'openrouter', + defaultModel: 'openai/gpt-4o-mini', + defaultSystemPrompt: 'Answer concisely.', + tools: { get_time: getTime }, + }), +}; + +const registry = makeAgentRegistry(agents); +const definition = registry.agentDef('support'); +if (!definition) throw new Error('unknown agent'); + +const result = callChat(ctx.http, openRouterProvider, { + apiKey, + model: definition.defaultModel, + system: definition.defaultSystemPrompt, + messages: [{ role: 'user', content: 'What time is it?' }], + tools: registry.llmToolDefsFor('support'), + retries: definition.defaultRetries, +}); +``` + +Run model calls from a procedure or HTTP handler. Reducers remain deterministic. +Keep API keys in private tables and pass the stored value to `callChat` at the +call site. + +The snippet uses application-owned `ctx` and `apiKey` values inside that +procedure. See the complete +[Agents host module](./example/spacetimedb/) +for private configuration, caller-scoped views, and an agent loop. + +## API + +- `agentTool(description, args, run)` defines a typed tool. +- `defineAgent(config)` applies defaults to an agent definition. +- `makeAgentDispatch(tools)` builds tool definitions and an invocation method. +- `makeAgentRegistry(agents)` selects agents and dispatches their tools. +- `typeBuilderToJsonSchema(typeBuilder)` converts supported tool arguments. +- `callChat(http, provider, request)` performs one synchronous chat request, + with optional immediate retries for retryable failures. +- `openRouterProvider`, `openAiProvider`, and `anthropicProvider` adapt their + providers' chat APIs. +- `openAiEmbeddingsProvider` and `openRouterEmbeddingsProvider` perform + embedding requests. +- `cosineSimilarity` and `topKByScore` provide in-memory ranking helpers. + +Documented subpath exports are `./kit`, `./openrouter`, `./providers`, +`./embeddings`, and `./stale-locks`. + +Tool dispatch rejects malformed JSON, missing and unknown fields, incorrect +types, unsafe integers, inputs above 64 KiB, arrays above 1,000 items, and tool +results above 64 KiB. Agent definitions validate turns, history, token, retry, +and RAG limits during initialization. + +### Application boundary + +Export a host procedure that loads a private provider key, calls the selected +agent, and returns an application-specific result. After generating client +bindings, that procedure is called like any other SpacetimeDB procedure: + +```ts +const answer = await conn.procedures.askSupport({ + message: 'How do I update my billing address?', +}); +``` + +`askSupport` belongs to the host module. Its implementation should authorize +the caller, load the API key from a private table, pass `ctx.http` to +`callChat`, and map provider errors to stable application errors. Conversation +rows should be written through `ctx.withTx` and exposed through caller-scoped +views. + +Package entrypoints: + +- `@spacetimedb/agents` exports the complete public surface. +- `@spacetimedb/agents/kit` exports typed agents, tools, and dispatch. +- `@spacetimedb/agents/providers` exports provider adapters. +- `@spacetimedb/agents/embeddings` exports embedding and ranking helpers. +- `@spacetimedb/agents/openrouter` exports the common chat request layer. +- `@spacetimedb/agents/stale-locks` exports the bounded stale-lock cleanup + helpers used by the reference host module. + +## Testing + +```bash +npm test --workspace @spacetimedb/agents +npm run lint --workspace @spacetimedb/agents +``` + +The unit suite uses mocked HTTP with deterministic provider fixtures. See the +[complete example](./example/) +for a host module and client. + +## License + +BUSL-1.1. See [`LICENSE.txt`](./LICENSE.txt). diff --git a/spacetime-agents-ts/example/.env.example b/spacetime-agents-ts/example/.env.example new file mode 100644 index 00000000000..2212cb7db60 --- /dev/null +++ b/spacetime-agents-ts/example/.env.example @@ -0,0 +1,40 @@ +# Copy to .env. The example server loads this on startup and bootstraps auth. +# LLM provider keys and agent tuning can be configured here or in the +# submodule/root .env. The example server seeds them on startup. + +# ---------------- Auth ---------------- +# Issuer URL is what gets embedded in the JWT and used for OAuth redirect +# construction. Must match the URL the browser loads the app from. +AUTH_ISSUER_URL=http://localhost:8789 + +# Optional auth service settings. AUTH_BASE_URL defaults to AUTH_ISSUER_URL. +# Use literal \n sequences when a PEM key is stored on one line. +AUTH_BASE_URL= +AUTH_COOKIE_NAME=stdb_auth +AUTH_SESSION_TTL_SECONDS=604800 +AUTH_ES256_PRIVATE_KEY_PEM= + +# OAuth provider credentials. Leave blank to hide those buttons in the login UI. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# ---------------- Agent providers ---------------- +OPENROUTER_API_KEY= +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +STALE_LOCK_THRESHOLD_SECS=900 +RATE_LIMIT_TOKENS_PER_WINDOW= +RATE_LIMIT_WINDOW_SECS= + +# ---------------- Static server ---------------- +HOST=127.0.0.1 +PORT=8789 + +# STDB endpoints. The browser connects via WebSocket; the express server +# proxies /auth/* over HTTP to the same instance. +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_APP_DATABASE=spacetime-agents-example +STDB_SERVER=http://127.0.0.1:3000 diff --git a/spacetime-agents-ts/example/.gitignore b/spacetime-agents-ts/example/.gitignore new file mode 100644 index 00000000000..ca141d51ce1 --- /dev/null +++ b/spacetime-agents-ts/example/.gitignore @@ -0,0 +1,5 @@ +.env +node_modules/ +public/app.js +public/app.js.map +src/codegen/ diff --git a/spacetime-agents-ts/example/README.md b/spacetime-agents-ts/example/README.md new file mode 100644 index 00000000000..5c96374091d --- /dev/null +++ b/spacetime-agents-ts/example/README.md @@ -0,0 +1,229 @@ +# Agents example + +This example is a multi-provider chat application built with +[`@spacetimedb/agents`](../). Agent execution, thread state, tools, usage +accounting, and model requests live in SpacetimeDB. The browser connects directly +to SpacetimeDB; the Node server serves static files and proxies the module's auth +and file HTTP handlers. + +## What this demonstrates + +- Defining typed agents and tools with `@spacetimedb/agents/kit`. +- Running an agent loop from a SpacetimeDB procedure with OpenRouter, OpenAI, or + Anthropic. +- Isolating threads, messages, locks, files, and embeddings by authenticated user. +- Publishing user-scoped views over private tables. +- Tool calls, cancellation, response regeneration, history summarization, and RAG. +- Per-message token accounting and optional per-user token limits. +- Bootstrapping auth and storing provider configuration in private module state. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity. A fresh publish seeds the publisher as the initial auth + and agents administrator. +- At least one supported model-provider API key for successful model responses. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +Confirm the server and login before continuing: + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-agents-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +# Add OPENROUTER_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY to .env. +pnpm run build:module:fresh +pnpm run dev +``` + +Open , create an account, create a thread, and send a +message. + +`build:module:fresh` deletes and recreates only the local `spacetime-agents-example` +database. Use `pnpm run build:module` to republish while preserving existing rows. + +## Use in your project + +This workspace tests the component source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/agents spacetimedb@^2.8.3 +``` + +Start with the package's +[integration guide](../README.md#integrate-into-an-application). Copy the agent +registry and procedure boundary you need; the example's auth, files, RAG, and UI +are application-specific integrations around the helper. + +## Configuration + +The server loads non-empty values from the repository, package, and example +`.env` files. The example-local file has highest priority; environment variables +set by the launching process are never overwritten. + +| Variable | Default | Purpose | +| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------ | +| `OPENROUTER_API_KEY` | empty | Enables OpenRouter-backed agents. | +| `OPENAI_API_KEY` | empty | Enables OpenAI-backed agents. | +| `ANTHROPIC_API_KEY` | empty | Enables Anthropic-backed agents. | +| `STALE_LOCK_THRESHOLD_SECS` | `900` | Age at which the lock sweeper may remove an abandoned thread lock. | +| `RATE_LIMIT_TOKENS_PER_WINDOW` | empty | Optional per-user prompt-plus-completion token cap. | +| `RATE_LIMIT_WINDOW_SECS` | empty | Sliding-window duration used with the token cap. | +| `AUTH_ISSUER_URL` | `http://localhost:8789` | JWT issuer and OAuth redirect origin. | +| `AUTH_BASE_URL` | `AUTH_ISSUER_URL` | Public base URL used by auth routes and redirects. | +| `AUTH_COOKIE_NAME` | `stdb_auth` | Name of the session cookie. | +| `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | +| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by auth setup | Optional fixed ES256 signing key. Use literal `\n` in `.env`. | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | empty | Enables Google OAuth when both values are present. | +| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | empty | Enables GitHub OAuth when both values are present. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth/file proxy. | +| `STDB_SERVER` | `STDB_HTTP` | CLI target used for startup configuration. | +| `STDB_APP_DATABASE` | `spacetime-agents-example` | Published database name. | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8789` | Static-server port. | + +On startup, the logged-in CLI identity calls `set_auth_config`, +`set_agent_secret`, and `set_api_key` for each configured provider. Provider keys +are stored in private module tables and are not returned by `/api/config`. + +## Architecture + +```text +Browser + -> /auth/* and /files HTTP requests through the local same-origin proxy + -> SpacetimeDB WebSocket for reducers, procedures, and subscriptions + +SpacetimeDB module + -> authenticated user/session mapping + -> caller-scoped thread, message, lock, file, and embedding views + -> agent procedure -> provider HTTPS API +``` + +The browser subscribes to `my_threads`, `my_thread_locks`, `my_files`, and +`my_auth_user`. It subscribes to `my_messages` only for the active thread. These +views resolve the authenticated user from the linked connection and filter rows +server-side. Browser subscriptions use these views exclusively. + +`send_message` is a procedure because model calls require `ProcedureCtx.http`. +Each completed turn commits messages through its own transaction, and +SpacetimeDB subscriptions deliver progress to the browser. + +## Agent behavior + +Configuration is resolved in this order: + +1. Per-thread overrides. +2. Operator-managed rows in `agent_override`. +3. Defaults in `spacetimedb/src/agents/`. + +The registered `chat` agent exposes the example tools; the `summarizer` agent +compacts long conversation history. Defensive limits cap user content and +tool output. A lock keyed by thread prevents two agent loops from interleaving on +the same thread, and a scheduled sweeper removes locks abandoned beyond the +configured threshold. + +Successful assistant messages record prompt and completion token counts. The UI +shows those values per message. The optional token window rejects new work with +`agent.rate_limited:/` after the configured per-user cap is reached. + +## Adding an agent or tool + +Agents are registered by key in `spacetimedb/src/agents/index.ts`. The registry key +is the runtime name stored on each thread. + +```ts +import { defineAgent } from '@spacetimedb/agents/kit'; +import myTool from '../tools/myTool'; + +export default defineAgent({ + defaultModel: 'openai/gpt-4o-mini', + defaultSystemPrompt: 'Give concise, factual answers.', + tools: { my_tool: myTool }, +}); +``` + +Tools live in `spacetimedb/src/tools/` and use `agentTool` with a SpacetimeDB type +for their input. Import a tool only into agents that should be allowed to call it. +After changing an agent or tool, republish the module and regenerate the client. + +## Administration and security + +- A fresh publish seeds the publishing owner in the private + `auth_admin_identity` and `agent_admin_identity` tables. +- Public reducers never grant the first caller administrator access. +- The startup configuration calls run as the logged-in CLI identity. +- Browser users are not administrators by default. Grant a development identity + only with `add_agent_admin_identity` called by an existing administrator. +- Model-provider keys, auth signing material, `.env`, and generated local tokens + must not be committed. +- The included server is a development server. Put TLS, host validation, secret + management, and process supervision at the deployment boundary in production. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm --dir spacetimedb run test:unit +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For an end-to-end check, fresh-publish the database, start the server, sign up in +the browser, create a thread, and confirm all of the following: + +1. The thread appears after creation and remains after a reload. +2. A message receives a normal assistant response with a valid provider key. +3. Token usage appears on the assistant message. +4. Stop and regenerate update the current thread while preserving account + isolation. +5. A second account cannot subscribe to or mutate the first account's threads. + +Use a valid provider key for the release smoke test. Invalid keys cover only the +error path. + +## Troubleshooting + +- **Startup configuration is rejected:** confirm that the CLI is logged in as the + database owner or a registered administrator, and that `STDB_SERVER` targets + the same host used by the publish command. +- **The browser cannot connect:** make sure `STDB_URI`, `STDB_HTTP`, and + `STDB_SERVER` address the same SpacetimeDB instance. +- **Provider calls fail:** verify that the chosen agent has a key for its provider + and inspect the module logs for the upstream status. +- **OAuth redirects to the wrong origin:** set `AUTH_ISSUER_URL` to the exact + browser-visible origin, including scheme and port. +- **A stale browser identity follows a fresh publish:** clear the example's site + data and sign in again. + +## Important files + +- `spacetimedb/src/index.ts` - schema, views, auth integration, and agent procedures. +- `spacetimedb/src/loop.ts` - provider-independent agent loop. +- `spacetimedb/src/agents/` - registered agent definitions. +- `spacetimedb/src/tools/` - typed tools available to agents. +- `spacetimedb/scripts/test-loop.ts` - model-mocked loop tests. +- `server.ts` - startup configuration and same-origin HTTP proxy. +- `src/app.ts` - browser connection, subscriptions, and UI bridge. +- `public/index.html` - application structure. +- `public/ui.js` - DOM state, rendering, and interaction handling. +- `public/styles.css` - application presentation. diff --git a/spacetime-agents-ts/example/package.json b/spacetime-agents-ts/example/package.json new file mode 100644 index 00000000000..ca7c1c40f95 --- /dev/null +++ b/spacetime-agents-ts/example/package.json @@ -0,0 +1,30 @@ +{ + "name": "spacetime-agents-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", + "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-agents-example && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-agents-example && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "test:unit": "node scripts/test-markdown.mjs", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/agents": "workspace:*", + "@spacetimedb/auth": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-agents-ts/example/public/assets/brand.svg b/spacetime-agents-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-agents-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-agents-ts/example/public/assets/logo.svg b/spacetime-agents-ts/example/public/assets/logo.svg new file mode 100644 index 00000000000..adaf36cbf9c --- /dev/null +++ b/spacetime-agents-ts/example/public/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/spacetime-agents-ts/example/public/index.html b/spacetime-agents-ts/example/public/index.html new file mode 100644 index 00000000000..dc65fefe76e --- /dev/null +++ b/spacetime-agents-ts/example/public/index.html @@ -0,0 +1,494 @@ + + + + + + + SpacetimeDB Agents + + + +
+ + SpacetimeDB Agents +
+ +
+
+ +

Welcome to Agents

+

Sign in to continue.

+ +
+ + +
+ +
or
+ +
+ + +
+ +
+ + +
+ + + +

+ Forgot password? +

+

+ Don't have an account? + Sign up +

+
+
+ + + + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + + + + diff --git a/spacetime-agents-ts/example/public/markdown.js b/spacetime-agents-ts/example/public/markdown.js new file mode 100644 index 00000000000..e6c0a54a646 --- /dev/null +++ b/spacetime-agents-ts/example/public/markdown.js @@ -0,0 +1,46 @@ +export function escapeHtml(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function renderMarkdown(source) { + if (!source) return ''; + + const codeBlocks = []; + let rendered = source.replace(/```(?:[\w-]*)\n([\s\S]*?)```/g, (_, code) => { + const index = codeBlocks.length; + codeBlocks.push(code); + return `\n\n\uE000CODE_BLOCK_${index}\uE001\n\n`; + }); + + rendered = escapeHtml(rendered); + rendered = rendered.replace( + /`([^`\n]+)`/g, + (_, code) => `${code}` + ); + rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '$1'); + rendered = rendered.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); + rendered = rendered.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1' + ); + rendered = rendered + .split(/\n{2,}/) + .filter(Boolean) + .map(paragraph => { + const codeBlockMatch = paragraph.match(/^\uE000CODE_BLOCK_(\d+)\uE001$/); + if (codeBlockMatch) { + const code = codeBlocks[Number(codeBlockMatch[1])]; + if (code !== undefined) { + return `
${escapeHtml(code)}
`; + } + } + return `

${paragraph.replace(/\n/g, '
')}

`; + }) + .join(''); + return rendered; +} diff --git a/spacetime-agents-ts/example/public/styles.css b/spacetime-agents-ts/example/public/styles.css new file mode 100644 index 00000000000..8a5b36bbdca --- /dev/null +++ b/spacetime-agents-ts/example/public/styles.css @@ -0,0 +1,1389 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=Source+Code+Pro:wght@400;500;600&display=swap'); + +:root { + /* Tokens match spacetime-web/spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + + /* Aliases retained for this app's existing rules. */ + --color-fg: var(--color-white); + --color-muted: var(--color-n4); +} +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-fg); + background: var(--color-shade7); + overflow: hidden; +} +button { + font: inherit; +} +input, +textarea, +select { + font: inherit; + color: var(--color-fg); + background: var(--color-shade6); + border: 1px solid #1a2a35; + border-radius: var(--radius-sm); + padding: 8px 10px; +} +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} + +/* Compact scrollbars matching the SpacetimeDB dashboard. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) var(--color-shade7); +} +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 2px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade3); +} +*::-webkit-scrollbar-corner { + background: var(--color-shade7); +} + +.shell { + width: min(1320px, calc(100% - 32px)); + margin: 14px auto; + height: calc(100dvh - 28px); + display: flex; + gap: 14px; + min-height: 0; +} +.sidebar { + width: 280px; + flex: 0 0 280px; + transition: + flex-basis 0.18s ease, + width 0.18s ease; +} +.sidebar.collapsed { + width: 48px; + flex: 0 0 48px; +} +.sidebar.collapsed .brand, +.sidebar.collapsed .sidebar-new, +.sidebar.collapsed .threads, +.sidebar.collapsed .conn { + display: none; +} +.sidebar.collapsed .sidebar-head { + padding: 8px; + justify-content: center; +} +.sidebar.collapsed .sidebar-foot { + padding: 8px; + margin-top: auto; + justify-content: center; +} +.sidebar-head { + padding: 10px 12px; + border-bottom: 1px solid #142732; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.sidebar-new { + padding: 10px; + border-bottom: 1px solid #142732; +} +.sidebar-new .btn { + width: 100%; +} +.sidebar-foot { + padding: 8px 10px; + border-top: 1px solid #142732; + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-ibm); + font-size: 12px; + color: var(--color-muted); +} +.sidebar-foot .conn { + flex: 1; +} +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + font-size: 14px; + min-width: 0; +} +.brand-logo { + height: 22px; + width: auto; + flex: 0 0 auto; + display: block; +} +.brand-sub { + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 500; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; + flex: 0 0 auto; +} +.brand-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.empty-hero .hero-logo { + width: 96px; + height: auto; + margin: 0 auto 8px; + display: block; +} +.conn { + font-family: var(--font-ibm); + font-size: 12px; + color: var(--color-muted); +} +.conn.ok { + color: var(--color-green); +} +.conn.err { + color: var(--color-red); +} /* connection broken = vivid red */ +.conn.warn { + color: var(--color-yellow); +} +.btn.toggle { + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + color: var(--color-muted); + font-family: var(--font-ibm); + cursor: pointer; +} +.btn.toggle:hover { + color: var(--color-fg); +} +.btn { + padding: 7px 12px; + border-radius: var(--radius-sm); + border: 1px solid #1f3947; + background: #0f1a22; + color: var(--color-fg); + cursor: pointer; +} +.btn:hover { + background: #142433; +} +.btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +/* Buttons match spacetimedb.com Button.module.css: + primary = n3 bg, n8 text, white hover, green active, green focus outline + danger = bordered, soft orange text, soft orange hover wash */ +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); + font-weight: 600; +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} +.btn.danger { + background: transparent; + color: var(--color-orange); + border-color: #5a2222; +} +.btn.danger:hover:not(:disabled) { + background: rgba(255, 158, 158, 0.08); + border-color: var(--color-orange); +} +.btn.small { + font-size: 12px; + padding: 4px 8px; +} +.btn.icon { + padding: 0; + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-muted); +} +.btn.icon:hover:not(:disabled) { + color: var(--color-fg); +} + +.sidebar, +.chat { + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: var(--color-shade6); + display: flex; + flex-direction: column; + min-height: 0; +} +.chat { + flex: 1; + min-width: 0; +} +.chat .head { + padding: 10px 14px; + border-bottom: 1px solid #142732; + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-ibm); + font-size: 12px; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.chat .head .agent-tag { + background: #14303d; + border: 1px solid #1f4555; + color: var(--color-blue); + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + text-transform: none; + letter-spacing: 0; +} +.empty-hero { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + padding: 32px; + text-align: center; +} +.empty-hero h1 { + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--color-fg); +} +.empty-hero p { + margin: 0; + color: var(--color-muted); + font-size: 14px; + max-width: 480px; +} + +.threads { + flex: 1; + overflow-y: auto; + padding: 6px; +} +.thread-item { + padding: 8px 32px 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 14px; + line-height: 1.3; + position: relative; + display: flex; + align-items: center; + gap: 8px; +} +.thread-item:hover { + background: #122029; +} +.thread-item.active { + background: #101d24; + color: var(--color-fg); +} +.thread-item .row-menu-btn { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + color: var(--color-muted); + cursor: pointer; + border-radius: 3px; + display: none; + align-items: center; + justify-content: center; +} +.thread-item:hover .row-menu-btn, +.thread-item.active .row-menu-btn, +.thread-item.menu-open .row-menu-btn { + display: inline-flex; +} +.thread-item .row-menu-btn:hover { + background: #1a3848; + color: var(--color-fg); +} +.row-menu { + position: absolute; + right: 0; + top: calc(100% + 2px); + min-width: 130px; + background: var(--color-shade5); + border: 1px solid #17303b; + border-radius: var(--radius-sm); + padding: 4px; + z-index: 50; + box-shadow: 0 4px 16px #0006; +} +.row-menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + background: transparent; + border: none; + color: var(--color-fg); + font-family: var(--font-inter); + font-size: 13px; + padding: 6px 8px; + border-radius: 3px; + cursor: pointer; +} +.row-menu button:hover { + background: #122029; +} +.row-menu button.danger { + color: var(--color-orange); +} +.row-menu button.danger:hover { + background: #2a1212; +} +.thread-item .title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.thread-item .badge { + background: #14303d; + border: 1px solid #1f4555; + color: var(--color-blue); + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; +} +.thread-item .busy-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-yellow); + animation: pulse 1.4s ease-in-out infinite; +} +@keyframes pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} +.empty { + color: var(--color-muted); + font-size: 13px; + padding: 14px; + text-align: center; +} + +.chat .head .title { + font-weight: 600; + font-size: 15px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat .head .agent-tag { + background: #14303d; + border: 1px solid #1f4555; + color: var(--color-blue); + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-family: var(--font-ibm); +} +.chat .head .actions { + display: flex; + gap: 6px; +} +.chat .messages { + flex: 1; + overflow-y: auto; + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 12px; +} +.msg { + max-width: 78%; + border: 1px solid #142732; + background: #0e1a22; + border-radius: var(--radius); + padding: 10px 12px; + font-size: 14px; + line-height: 1.45; + position: relative; +} +.msg .who { + font-size: 11px; + color: var(--color-muted); + font-family: var(--font-ibm); + text-transform: uppercase; + margin-bottom: 4px; + letter-spacing: 0.04em; +} +.msg .body { + word-break: break-word; +} +.msg .body p { + margin: 0 0 8px; +} +.msg .body p:last-child { + margin-bottom: 0; +} +.msg .body code { + background: #061015; + border-radius: 3px; + padding: 1px 5px; + font-family: var(--font-ibm); + font-size: 13px; +} +.msg .body pre { + background: #061015; + border-radius: var(--radius-sm); + padding: 10px 12px; + overflow-x: auto; + margin: 6px 0; +} +.msg .body pre code { + background: none; + padding: 0; +} +.msg .body a { + color: var(--color-green); +} +.msg .body strong { + font-weight: 600; +} +.msg .body em { + font-style: italic; +} +.msg.user { + align-self: flex-end; + background: #0f2530; + border-color: #1f4555; +} +.msg.assistant { + align-self: flex-start; +} +.msg.tool { + align-self: flex-start; + background: #1a1a0d; + border-color: #3b3520; + font-family: var(--font-ibm); + font-size: 13px; +} +.msg.tool .who { + color: var(--color-yellow); +} +.msg.error { + border-color: #5a2222; + background: #2a1212; +} +.msg.error .who { + color: var(--color-orange); +} +.msg.error .body { + font-family: var(--font-ibm); + font-size: 12px; +} +.msg .toolcalls { + margin-top: 6px; + background: #0a1418; + border-radius: var(--radius-sm); + font-family: var(--font-ibm); + font-size: 12px; + color: var(--color-purple); +} +.msg .toolcalls summary { + padding: 6px 8px; + cursor: pointer; + color: var(--color-muted); + user-select: none; +} +.msg .toolcalls[open] summary { + color: var(--color-purple); +} +.msg .toolcalls pre { + margin: 0; + padding: 0 8px 8px; + white-space: pre-wrap; + word-break: break-word; +} +.msg-footer { + margin-top: 8px; + display: flex; + align-items: center; + gap: 4px; + font-family: var(--font-ibm); + font-size: 11px; + color: var(--color-muted); + opacity: 0; + transition: opacity 0.1s; +} +.msg:hover .msg-footer, +.msg.error .msg-footer { + opacity: 1; +} +.msg-footer .spacer { + flex: 1; +} +.msg-footer .usage { + white-space: nowrap; +} +.msg-footer button { + background: transparent; + border: none; + color: var(--color-muted); + cursor: pointer; + border-radius: 3px; + width: 22px; + height: 22px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.msg-footer button:hover { + color: var(--color-fg); + background: #142732; +} +.msg-footer button.danger:hover { + color: var(--color-orange); +} +.msg-footer .label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-left: 2px; +} +.code-block { + position: relative; +} +.code-block .copy { + position: absolute; + top: 4px; + right: 4px; + background: #0a1418; + border: 1px solid #1f3947; + color: var(--color-muted); + font-family: var(--font-ibm); + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + cursor: pointer; + opacity: 0; + transition: opacity 0.1s; +} +.code-block:hover .copy { + opacity: 1; +} +.code-block .copy:hover { + color: var(--color-fg); +} +.msg .usage { + margin-top: 6px; + font-family: var(--font-ibm); + font-size: 11px; + color: var(--color-muted); +} + +.composer-wrap { + padding: 12px 14px 14px; + border-top: 1px solid #142732; +} +.composer-meta { + font-family: var(--font-ibm); + font-size: 11px; + color: var(--color-muted); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; +} +.composer-meta #composer-model-label { + color: var(--color-green); + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; +} +.composer-meta #composer-model-label:hover { + color: var(--color-white); +} +.model-popover { + position: absolute; + background: var(--color-shade6); + border: 1px solid #1f4555; + border-radius: var(--radius-sm); + padding: 4px; + z-index: 1000; + min-width: 320px; + max-width: 440px; + max-height: 420px; + display: flex; + flex-direction: column; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4); +} +.model-popover input.search { + background: var(--color-shade7); + border: 1px solid #1a2a35; + color: var(--color-fg); + font-family: var(--font-ibm); + font-size: 12px; + padding: 6px 8px; + border-radius: 2px; + margin-bottom: 4px; + outline: none; +} +.model-popover input.search:focus { + border-color: var(--color-green); +} +.model-popover .list { + overflow-y: auto; + flex: 1; +} +.model-popover .empty { + font-family: var(--font-ibm); + font-size: 11px; + color: var(--color-muted); + padding: 10px; + text-align: center; +} +.model-popover button { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + color: var(--color-shade1); + font-family: var(--font-ibm); + font-size: 12px; + padding: 6px 10px; + border-radius: 2px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.model-popover button:hover { + background: var(--color-shade5); +} +.model-popover button.active { + color: var(--color-green); +} +.model-popover button.active::before { + content: '✓ '; +} +.composer { + display: flex; + gap: 8px; + align-items: flex-end; + background: var(--color-shade5); + border: 1px solid #1a2a35; + border-radius: var(--radius); + padding: 8px; +} +.composer:focus-within { + border-color: var(--color-green); +} +.composer textarea { + flex: 1; + resize: none; + min-height: 36px; + max-height: 160px; + background: transparent; + border: none; + outline: none; + padding: 6px 4px; +} +.composer textarea:focus { + border: none; + outline: none; + box-shadow: none; +} +.pending-thumb { + position: relative; + width: 64px; + height: 64px; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid #1f4555; +} +.pending-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.pending-thumb .x { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + background: #000a; + color: #fff; + border-radius: 50%; + border: none; + font-size: 11px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.msg-image { + margin-top: 6px; + max-width: 280px; + max-height: 280px; + border-radius: var(--radius-sm); + display: block; + border: 1px solid #1f4555; + background: #071116; + cursor: zoom-in; +} +.msg-file-link { + margin-top: 6px; + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 280px; + min-height: 28px; + padding: 6px 8px; + border: 1px solid #1f4555; + border-radius: var(--radius-sm); + color: var(--color-blue); + background: #071116; + font-size: 12px; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.typing { + align-self: flex-start; + display: flex; + align-items: baseline; + gap: 6px; + padding: 12px 14px; + background: #0e1a22; + border: 1px solid #142732; + border-radius: var(--radius); + color: var(--color-muted); + font-size: 12px; + font-family: var(--font-ibm); +} +.typing .dots { + display: inline-flex; + align-items: baseline; + gap: 3px; +} +.typing .dots span { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--color-muted); + display: inline-block; + animation: typing-bounce 1.2s ease-in-out infinite; +} +.typing .dots span:nth-child(2) { + animation-delay: 0.15s; +} +.typing .dots span:nth-child(3) { + animation-delay: 0.3s; +} +@keyframes typing-bounce { + 0%, + 70%, + 100% { + transform: translateY(0); + opacity: 0.35; + } + 35% { + transform: translateY(-4px); + opacity: 1; + } +} + +.backdrop { + position: fixed; + inset: 0; + background: #04080a99; + display: none; + align-items: center; + justify-content: center; + z-index: 100; +} +.backdrop.open { + display: flex; +} +.modal { + width: min(560px, calc(100% - 32px)); + background: var(--color-shade5); + border: 1px solid #17303b; + border-radius: var(--radius-lg); + padding: 20px; + max-height: calc(100dvh - 64px); + overflow-y: auto; +} +.modal h2 { + margin: 0 0 6px; + font-size: 18px; +} +.modal p { + margin: 0 0 14px; + color: var(--color-muted); + font-size: 13px; +} +.field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} +.field label { + font-size: 12px; + color: var(--color-muted); + font-family: var(--font-ibm); +} +.field input, +.field textarea, +.field select { + width: 100%; +} +.field-row { + display: flex; + gap: 12px; +} +.field-row .field { + flex: 1; +} +.modal .actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} +.image-viewer { + width: min(1120px, calc(100% - 32px)); + max-height: calc(100dvh - 32px); + padding: 0; + overflow: hidden; + background: #071116; + border: 1px solid #214858; +} +.image-viewer:focus { + outline: none; +} +.image-viewer-head { + height: 44px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 10px 0 14px; + border-bottom: 1px solid #17303b; + background: #0b151b; +} +.image-viewer-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-muted); + font-size: 12px; + font-family: var(--font-ibm); +} +.image-viewer-actions { + flex: none; + display: flex; + align-items: center; + gap: 6px; +} +.image-viewer-body { + height: min(760px, calc(100dvh - 78px)); + display: flex; + align-items: center; + justify-content: center; + background: #05090c; +} +.image-viewer-body img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + display: block; +} + +.toast { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + padding: 10px 16px; + border-radius: var(--radius); + font-size: 13px; + background: #0e1a22; + border: 1px solid #1f4555; + color: var(--color-fg); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + z-index: 200; +} +.toast.show { + opacity: 1; +} +.toast.err { + border-color: #5a2222; + background: #2a1212; +} +.toast.ok { + border-color: #1f5a32; + background: #102a17; +} + +.boot-splash { + position: fixed; + inset: 0; + z-index: 9999; + background: var(--color-shade7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + color: var(--color-green); + transition: opacity 200ms ease; +} +.boot-splash svg { + animation: boot-pulse 1.4s ease-in-out infinite; +} +.boot-splash-label { + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-n4); +} +.boot-splash.fading { + opacity: 0; + pointer-events: none; +} +@keyframes boot-pulse { + 0%, + 100% { + opacity: 0.4; + transform: scale(0.95); + } + 50% { + opacity: 1; + transform: scale(1); + } +} + +.auth-shell { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 50; + background: + radial-gradient( + ellipse 80% 50% at 50% 0%, + var(--color-green-20), + transparent 60% + ), + var(--color-shade7); +} +.auth-card { + width: 100%; + max-width: 380px; + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + padding: 28px; + display: flex; + flex-direction: column; + gap: 12px; +} +.auth-logo { + width: 56px; + height: auto; + margin: 0 auto 4px; + display: block; +} +.auth-card h1 { + font-family: var(--font-inter); + font-size: 18px; + font-weight: 700; + margin: 0; + text-align: center; + color: var(--color-n1); +} +.auth-sub { + font-family: var(--font-inter); + font-size: 13px; + color: var(--color-n4); + margin: 0 0 8px; + text-align: center; +} +.auth-oauth { + display: flex; + flex-direction: column; + gap: 8px; +} +.btn.oauth { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px 14px; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 500; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + color: var(--color-n1); + border-radius: var(--radius-sm); + cursor: pointer; +} +.btn.oauth:hover:not(:disabled) { + background: var(--color-shade4); + border-color: var(--color-n4); +} +.btn.oauth svg { + flex-shrink: 0; + width: 16px; + height: 16px; +} +.btn.block { + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} +.auth-divider { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0; + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.auth-divider::before, +.auth-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--color-shade4); +} +.auth-field { + display: flex; + flex-direction: column; + gap: 4px; +} +.auth-field label { + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-n4); +} +.auth-field input { + background: var(--color-shade6); + border: 1px solid var(--color-shade4); + color: var(--color-n1); + font-family: var(--font-inter); + font-size: 13px; + padding: 8px 10px; + border-radius: var(--radius-sm); + outline: none; +} +.auth-field input:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +.auth-foot { + margin: 0; + text-align: center; + font-family: var(--font-inter); + font-size: 12px; + color: var(--color-n4); +} +.auth-foot a { + color: var(--color-green); + cursor: pointer; + text-decoration: none; + font-weight: 600; +} +.auth-foot a:hover { + text-decoration: underline; +} +.auth-card .btn.primary.block { + margin-top: 4px; +} +/* Lock down sizing so the card renders identically across apps + regardless of their per-app global input/.btn rules. */ +.auth-card { + width: 380px; + gap: 12px; +} +.auth-card .auth-logo { + width: 56px; + height: 56px; +} +.auth-card h1 { + font-size: 18px; + line-height: 24px; +} +.auth-card .auth-sub { + font-size: 13px; + line-height: 18px; +} +.auth-card .auth-field input, +.auth-card .btn { + height: 40px; + box-sizing: border-box; + width: 100%; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; +} +.auth-card .auth-field input { + padding: 0 12px; +} +.auth-card .btn.oauth { + padding: 0 14px; +} +.auth-card .auth-field label { + line-height: 14px; +} +.auth-card .auth-foot { + font-size: 12px; + line-height: 18px; +} + +/* ============================================================ + User panel + ============================================================ */ +.user-panel { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--color-shade7); + border-top: 1px solid #1a2a35; + margin-top: auto; + min-width: 0; +} +.user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-blue); + color: var(--color-shade7); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 13px; + flex: 0 0 32px; + text-transform: uppercase; + font-family: var(--font-ibm); + position: relative; + overflow: visible; +} +.user-avatar.has-image { + background: var(--color-shade5); +} +.user-avatar img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + display: block; +} +.user-avatar::after { + content: ''; + position: absolute; + bottom: -1px; + right: -1px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-red); + border: 2px solid var(--color-shade7); +} +.user-avatar.online::after { + background: var(--color-green); +} +.user-avatar.warn::after { + background: var(--color-yellow); +} +.user-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.15; +} +.user-name { + font-size: 13px; + font-weight: 600; + color: var(--color-fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.user-status { + font-family: var(--font-ibm); + font-size: 10px; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar.collapsed .user-meta { + display: none; +} +.sidebar.collapsed .user-panel { + justify-content: center; +} +.sidebar.collapsed #btn-logout { + display: none; +} + +/* Inherit existing icon button styling for #btn-logout/#btn-settings */ +.user-panel .btn.icon { + width: 28px; + height: 28px; + flex: 0 0 28px; +} +.user-panel #btn-logout:hover { + color: var(--color-red); +} diff --git a/spacetime-agents-ts/example/public/ui.js b/spacetime-agents-ts/example/public/ui.js new file mode 100644 index 00000000000..f3f72394e14 --- /dev/null +++ b/spacetime-agents-ts/example/public/ui.js @@ -0,0 +1,1193 @@ +import { renderMarkdown } from './markdown.js'; + +const $ = id => document.getElementById(id); +let activeThreadId = null; +function selectThread(newId) { + activeThreadId = newId; + window.stdb?.setActiveThread(newId); +} +let allThreads = []; +let allMessages = []; +let allAttachments = {}; // messageId -> attachment metadata +let pendingAttachments = []; // {mimeType, filename, bytes} not yet sent +let lockedThreads = new Map(); // threadId -> cancelRequested +let overrides = new Map(); // agentName -> AgentOverride row + +let inFlightSend = new Set(); +let configState = { kind: 'unknown' }; +let connState = 'connecting'; + +// Confirmation dialog +// Usage: const ok = await confirmDialog({ title, body, confirmText, danger }); +let confirmResolver = null; +function confirmDialog({ + title = 'Confirm', + body = '', + confirmText = 'OK', + danger = false, +} = {}) { + $('confirm-title').textContent = title; + $('confirm-body').textContent = body; + const ok = $('confirm-ok'); + ok.textContent = confirmText; + ok.className = 'btn ' + (danger ? 'danger' : 'primary'); + $('confirm-backdrop').classList.add('open'); + setTimeout(() => ok.focus(), 50); + return new Promise(resolve => { + confirmResolver = resolve; + }); +} +function closeConfirm(result) { + $('confirm-backdrop').classList.remove('open'); + if (confirmResolver) { + confirmResolver(result); + confirmResolver = null; + } +} +$('confirm-ok').addEventListener('click', () => closeConfirm(true)); +$('confirm-cancel').addEventListener('click', () => closeConfirm(false)); +// Esc cancels, Enter confirms when the dialog is open. +document.addEventListener('keydown', e => { + if (!$('confirm-backdrop').classList.contains('open')) return; + if (e.key === 'Escape') { + e.preventDefault(); + closeConfirm(false); + } else if (e.key === 'Enter') { + e.preventDefault(); + closeConfirm(true); + } +}); +// Click on backdrop (not modal) cancels. +$('confirm-backdrop').addEventListener('click', e => { + if (e.target === $('confirm-backdrop')) closeConfirm(false); +}); + +// Image viewer +function openImageViewer(src, title) { + $('image-full').src = src; + $('image-full').alt = title; + $('image-title').textContent = title; + $('image-open').href = src; + $('image-backdrop').classList.add('open'); + $('image-dialog').focus({ preventScroll: true }); +} +function closeImageViewer() { + $('image-backdrop').classList.remove('open'); + $('image-full').removeAttribute('src'); + $('image-open').href = '#'; +} +$('image-close').addEventListener('click', closeImageViewer); +$('image-backdrop').addEventListener('click', e => { + if (e.target === $('image-backdrop')) closeImageViewer(); +}); +document.addEventListener('keydown', e => { + if (!$('image-backdrop').classList.contains('open')) return; + if (e.key === 'Escape') { + e.preventDefault(); + closeImageViewer(); + } +}); + +// Toast messages +let toastTimer = null; +function toast(kind, text) { + const el = $('toast'); + el.className = 'toast ' + kind; + el.textContent = text; + void el.offsetWidth; + el.classList.add('show'); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove('show'), 2400); +} + +// Connection state +window.addEventListener('stdb:connState', e => { + const { state, detail } = e.detail; + connState = state; + const text = $('user-status'); + if (text) { + if (state === 'connected') { + text.className = 'user-status ok'; + text.textContent = 'online'; + } else if (state === 'connecting') { + text.className = 'user-status warn'; + text.textContent = 'connecting…'; + } else if (state === 'idle') { + text.className = 'user-status'; + text.textContent = 'idle'; + } else { + text.className = 'user-status err'; + text.textContent = detail ? `error: ${detail}` : 'error'; + } + } + updateButtons(); +}); + +window.addEventListener('stdb:ready', () => { + $('btn-settings').disabled = false; + updateButtons(); +}); + +// Sidebar collapse toggle. +$('btn-toggle-sidebar').addEventListener('click', () => { + const sb = $('sidebar'); + sb.classList.toggle('collapsed'); + $('btn-toggle-sidebar').textContent = sb.classList.contains('collapsed') + ? '»' + : '«'; +}); + +// Hero "+ New chat" delegates to the sidebar's handler. +$('btn-new-thread-hero').addEventListener('click', () => + $('btn-new-thread').click() +); + +// Configuration +window.addEventListener('stdb:config', e => { + configState = e.detail.state; + if (configState.kind === 'unconfigured') { + openSetup(); + } else if (configState.kind === 'configured') { + closeSetup(); + } + updateButtons(); +}); + +function openSetup() { + const isFirst = configState.kind !== 'configured'; + $('setup-cancel').style.display = isFirst ? 'none' : ''; + if (configState.kind === 'configured') { + $('cfg-stalelock').value = String( + configState.status.staleLockThresholdSecs + ); + $('cfg-rl-tokens').value = + configState.status.rateLimitTokensPerWindow != null + ? String(configState.status.rateLimitTokensPerWindow) + : ''; + $('cfg-rl-window').value = + configState.status.rateLimitWindowSecs != null + ? String(configState.status.rateLimitWindowSecs) + : ''; + const providers = configState.status.configuredProviders; + $('cfg-configured').textContent = + providers.length === 0 ? 'none' : providers.join(', '); + } else { + $('cfg-configured').textContent = 'none'; + } + $('setup-backdrop').classList.add('open'); + setTimeout(() => $('cfg-apikey').focus(), 50); +} +function closeSetup() { + $('setup-backdrop').classList.remove('open'); +} + +$('setup-cancel').addEventListener('click', closeSetup); +$('setup-backdrop').addEventListener('click', e => { + if (e.target === $('setup-backdrop')) closeSetup(); +}); + +$('setup-form').addEventListener('submit', async e => { + e.preventDefault(); + if (!window.stdb) return toast('err', 'STDB not ready'); + + const provider = $('cfg-provider').value; + const apiKey = $('cfg-apikey').value.trim(); + const staleLockThresholdSecs = Number.parseInt($('cfg-stalelock').value, 10); + const rlTokensRaw = $('cfg-rl-tokens').value.trim(); + const rlWindowRaw = $('cfg-rl-window').value.trim(); + const rateLimitTokensPerWindow = + rlTokensRaw === '' ? undefined : Number.parseInt(rlTokensRaw, 10); + const rateLimitWindowSecs = + rlWindowRaw === '' ? undefined : Number.parseInt(rlWindowRaw, 10); + + if (!Number.isFinite(staleLockThresholdSecs) || staleLockThresholdSecs < 1) { + return toast('err', 'Stale-lock threshold must be ≥ 1'); + } + const haveTokens = rateLimitTokensPerWindow !== undefined; + const haveWindow = rateLimitWindowSecs !== undefined; + if (haveTokens !== haveWindow) { + return toast('err', 'Set both rate-limit fields, or leave both blank'); + } + if ( + haveTokens && + (!Number.isFinite(rateLimitTokensPerWindow) || rateLimitTokensPerWindow < 1) + ) { + return toast('err', 'Rate-limit token cap must be ≥ 1'); + } + if ( + haveWindow && + (!Number.isFinite(rateLimitWindowSecs) || rateLimitWindowSecs < 1) + ) { + return toast('err', 'Rate-limit window must be ≥ 1'); + } + + const btn = $('setup-save'); + btn.disabled = true; + btn.textContent = 'Saving…'; + try { + await window.stdb.setAgentSecret({ + staleLockThresholdSecs, + rateLimitTokensPerWindow, + rateLimitWindowSecs, + }); + if (apiKey) { + await window.stdb.setApiKey(provider, apiKey); + $('cfg-apikey').value = ''; + } + toast('ok', 'Saved'); + } catch (err) { + toast('err', err.message ?? String(err)); + } finally { + btn.disabled = false; + btn.textContent = 'Save'; + } +}); + +$('btn-settings').addEventListener('click', openSetup); + +// Agent overrides +window.addEventListener('stdb:overrides', e => { + overrides = new Map((e.detail.overrides ?? []).map(o => [o.agentName, o])); + renderMessages(); +}); + +// Locks +window.addEventListener('stdb:locks', e => { + lockedThreads = new Map(e.detail.locks); + renderThreads(); + renderMessages(); + updateButtons(); +}); + +// Threads +window.addEventListener('stdb:threads', e => { + allThreads = e.detail.threads; + renderThreads(); + if (activeThreadId === null && allThreads.length > 0) { + selectThread(allThreads[0].id); + renderThreads(); + } + if ( + activeThreadId !== null && + !allThreads.some(t => t.id === activeThreadId) + ) { + selectThread(allThreads[0]?.id ?? null); + } + // Re-render the chat header so title/model updates on the active thread flow through. + if (activeThreadId !== null) renderMessages(); + updateButtons(); +}); + +function renderThreads() { + const list = $('thread-list'); + if (allThreads.length === 0) { + list.innerHTML = '
no threads yet
'; + return; + } + list.innerHTML = ''; + for (const t of allThreads) { + const item = document.createElement('div'); + item.className = 'thread-item' + (t.id === activeThreadId ? ' active' : ''); + item.dataset.threadId = String(t.id); + const titleEl = document.createElement('span'); + titleEl.className = 'title'; + titleEl.textContent = t.title ?? `Thread #${t.id}`; + if (lockedThreads.has(t.id)) { + const dot = document.createElement('span'); + dot.className = 'busy-dot'; + dot.title = lockedThreads.get(t.id) ? 'stopping' : 'thinking'; + item.appendChild(dot); + } + item.appendChild(titleEl); + + const menuBtn = document.createElement('button'); + menuBtn.className = 'row-menu-btn'; + menuBtn.title = 'More'; + menuBtn.setAttribute('aria-label', 'More actions'); + menuBtn.innerHTML = + ''; + menuBtn.addEventListener('click', e => { + e.stopPropagation(); + openRowMenu(item, t.id); + }); + item.appendChild(menuBtn); + + item.addEventListener('click', () => { + selectThread(t.id); + renderThreads(); + renderMessages(); + updateButtons(); + }); + list.appendChild(item); + } +} + +// "+ New chat" creates a thread immediately with the default agent +// (first registered). System prompt override + agent-specific +// tweaks live in the Rename modal after creation. +const DEFAULT_AGENT_PREFERENCE = ['chat']; +function pickDefaultAgent() { + const agents = + configState.kind === 'configured' ? configState.status.agents : []; + const names = agents.map(a => a.name); + for (const pref of DEFAULT_AGENT_PREFERENCE) { + if (names.includes(pref)) return pref; + } + return names[0]; +} + +// thread → effective model (thread.modelOverride ?? agent override ?? agent code default) +function effectiveModelFor(thread) { + if (!thread) return ''; + if (thread.modelOverride) return thread.modelOverride; + const ov = overrides.get(thread.agentName); + if (ov?.model != null) return ov.model; + if (configState.kind !== 'configured') return ''; + const ai = configState.status.agents.find(a => a.name === thread.agentName); + return ai?.defaultModel ?? ''; +} +$('btn-new-thread').addEventListener('click', async () => { + if (configState.kind !== 'configured' || !window.stdb) return; + const agentName = pickDefaultAgent(); + if (!agentName) return toast('err', 'no agents registered'); + try { + const id = await window.stdb.startThread({ + agentName, + title: undefined, + systemPromptOverride: undefined, + metadata: undefined, + }); + selectThread(id); + renderThreads(); + renderMessages(); + updateButtons(); + $('composer-input').focus(); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); + +// Thread row menu +let openMenuEl = null; +function closeRowMenu() { + if (openMenuEl) { + openMenuEl.parentElement?.classList.remove('menu-open'); + openMenuEl.remove(); + openMenuEl = null; + } +} +function openRowMenu(itemEl, threadId) { + if (openMenuEl && openMenuEl.dataset.threadId === String(threadId)) { + closeRowMenu(); + return; + } + closeRowMenu(); + const menu = document.createElement('div'); + menu.className = 'row-menu'; + menu.dataset.threadId = String(threadId); + const renameBtn = document.createElement('button'); + renameBtn.innerHTML = + 'Rename'; + renameBtn.addEventListener('click', e => { + e.stopPropagation(); + closeRowMenu(); + openRenameFor(threadId); + }); + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'danger'; + deleteBtn.innerHTML = + 'Delete'; + deleteBtn.addEventListener('click', e => { + e.stopPropagation(); + closeRowMenu(); + deleteThreadConfirmed(threadId); + }); + menu.appendChild(renameBtn); + menu.appendChild(deleteBtn); + itemEl.appendChild(menu); + itemEl.classList.add('menu-open'); + openMenuEl = menu; +} +document.addEventListener('click', () => closeRowMenu()); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeRowMenu(); +}); + +// Thread rename and delete actions +let renameTargetId = null; +function openRenameFor(threadId) { + const t = allThreads.find(x => x.id === threadId); + if (!t) return; + renameTargetId = threadId; + $('rename-title').value = t.title ?? ''; + $('rename-prompt').value = t.systemPromptOverride ?? ''; + $('rename-backdrop').classList.add('open'); + setTimeout(() => $('rename-title').focus(), 50); +} +async function deleteThreadConfirmed(threadId) { + if (!window.stdb) return; + const t = allThreads.find(x => x.id === threadId); + const label = t?.title ?? `Thread #${threadId}`; + const ok = await confirmDialog({ + title: 'Delete chat', + body: `Delete "${label}" and all its messages? This can't be undone.`, + confirmText: 'Delete', + danger: true, + }); + if (!ok) return; + try { + await window.stdb.deleteThread(threadId); + if (activeThreadId === threadId) { + selectThread(null); + renderMessages(); + } + renderThreads(); + updateButtons(); + } catch (err) { + toast('err', err.message ?? String(err)); + } +} +$('rename-cancel').addEventListener('click', () => + $('rename-backdrop').classList.remove('open') +); +$('rename-backdrop').addEventListener('click', e => { + if (e.target === $('rename-backdrop')) + $('rename-backdrop').classList.remove('open'); +}); +$('rename-form').addEventListener('submit', async e => { + e.preventDefault(); + if (!window.stdb || renameTargetId === null) return; + const titleRaw = $('rename-title').value.trim(); + const promptRaw = $('rename-prompt').value.trim(); + try { + await window.stdb.updateThread({ + threadId: renameTargetId, + title: titleRaw ? titleRaw : undefined, + systemPromptOverride: promptRaw ? promptRaw : undefined, + modelOverride: undefined, + metadata: undefined, + clearTitle: !titleRaw, + clearSystemPromptOverride: !promptRaw, + clearModelOverride: false, + clearMetadata: false, + }); + $('rename-backdrop').classList.remove('open'); + renameTargetId = null; + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); + +// Messages +window.addEventListener('stdb:messages', e => { + allMessages = e.detail.messages; + allAttachments = e.detail.attachments ?? {}; + renderMessages(); +}); + +function renderMessages() { + const wrap = $('msg-list'); + const head = $('chat-head'); + const headLabel = $('chat-agent-label'); + const composer = $('composer'); + const hero = $('empty-hero'); + + // Empty state: hide messages + composer + head, show the hero. + if (activeThreadId === null) { + head.hidden = true; + hero.style.display = 'flex'; + wrap.style.display = 'none'; + composer.hidden = true; + return; + } + hero.style.display = 'none'; + wrap.style.display = 'flex'; + composer.hidden = false; + + const t = allThreads.find(x => x.id === activeThreadId); + head.hidden = false; + const titleStr = t?.title ?? `Thread #${activeThreadId}`; + headLabel.textContent = titleStr; + if (t) { + const model = effectiveModelFor(t) || t.agentName || 'Unavailable'; + $('composer-model-label').textContent = model; + } + + const msgs = allMessages.filter( + m => m.id !== undefined && m.threadId === activeThreadId + ); + if (msgs.length === 0 && !lockedThreads.has(activeThreadId)) { + wrap.innerHTML = '
no messages yet - say hello
'; + return; + } + wrap.innerHTML = ''; + const wasNearBottom = + wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 100; + let lastUserMessage = null; + let lastAssistantIdx = -1; + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role === 'assistant') { + lastAssistantIdx = i; + break; + } + } + for (let i = 0; i < msgs.length; i++) { + const m = msgs[i]; + if (m.role === 'user') lastUserMessage = m.content; + + const node = document.createElement('div'); + const errCls = m.isError ? ' error' : ''; + node.className = `msg ${m.role}${errCls}`; + const who = document.createElement('div'); + who.className = 'who'; + who.textContent = m.role + (m.isError ? ' · error' : ''); + const body = document.createElement('div'); + body.className = 'body'; + if (m.role === 'assistant' && !m.isError) { + body.innerHTML = renderMarkdown(m.content) || '(empty)'; + // Wrap each
 in .code-block + add copy button.
+      body.querySelectorAll('pre').forEach(pre => {
+        const wrap = document.createElement('div');
+        wrap.className = 'code-block';
+        pre.parentNode.insertBefore(wrap, pre);
+        wrap.appendChild(pre);
+        const btn = document.createElement('button');
+        btn.className = 'copy';
+        btn.textContent = 'copy';
+        btn.addEventListener('click', () => {
+          const code = pre.textContent ?? '';
+          navigator.clipboard.writeText(code).then(
+            () => {
+              btn.textContent = 'copied';
+              setTimeout(() => (btn.textContent = 'copy'), 1200);
+            },
+            () => {}
+          );
+        });
+        wrap.appendChild(btn);
+      });
+    } else {
+      body.textContent = m.content;
+    }
+    node.appendChild(who);
+    node.appendChild(body);
+
+    const msgAtts = allAttachments[String(m.id)] ?? allAttachments[m.id] ?? [];
+    for (const att of msgAtts) {
+      const fileUrl = `/files?id=${encodeURIComponent(att.fileId.toString())}&v=${encodeURIComponent(att.sha256Hex ?? '')}`;
+      const filename = att.filename ?? 'attachment';
+      if (String(att.mimeType ?? '').startsWith('image/')) {
+        const img = document.createElement('img');
+        img.className = 'msg-image';
+        img.src = fileUrl;
+        img.alt = filename;
+        img.loading = 'lazy';
+        img.title = filename;
+        img.addEventListener('click', () => openImageViewer(fileUrl, filename));
+        img.addEventListener(
+          'error',
+          () => {
+            const link = document.createElement('a');
+            link.className = 'msg-file-link';
+            link.href = fileUrl;
+            link.target = '_blank';
+            link.rel = 'noopener';
+            link.textContent = filename;
+            img.replaceWith(link);
+          },
+          { once: true }
+        );
+        node.appendChild(img);
+      } else {
+        const link = document.createElement('a');
+        link.className = 'msg-file-link';
+        link.href = fileUrl;
+        link.target = '_blank';
+        link.rel = 'noopener';
+        link.textContent = filename;
+        node.appendChild(link);
+      }
+    }
+
+    if (m.toolCallsJson) {
+      const tc = document.createElement('details');
+      tc.className = 'toolcalls';
+      const summary = document.createElement('summary');
+      let callList;
+      try {
+        callList = JSON.parse(m.toolCallsJson);
+      } catch {
+        callList = [];
+      }
+      summary.textContent = `▸ ${callList.length} tool call${callList.length === 1 ? '' : 's'}`;
+      const pre = document.createElement('pre');
+      pre.textContent = formatToolCalls(callList);
+      tc.appendChild(summary);
+      tc.appendChild(pre);
+      node.appendChild(tc);
+    }
+
+    // Single hover-revealed footer: icon actions left, usage right.
+    if (m.role === 'assistant') {
+      const footer = document.createElement('div');
+      footer.className = 'msg-footer';
+
+      if (m.content) {
+        const copyBtn = document.createElement('button');
+        copyBtn.title = 'Copy';
+        copyBtn.setAttribute('aria-label', 'Copy');
+        copyBtn.innerHTML =
+          '';
+        copyBtn.addEventListener('click', () => {
+          navigator.clipboard.writeText(m.content).then(
+            () => {
+              copyBtn.title = 'Copied';
+              setTimeout(() => (copyBtn.title = 'Copy'), 1200);
+            },
+            () => toast('err', 'clipboard write failed')
+          );
+        });
+        footer.appendChild(copyBtn);
+      }
+
+      if (i === lastAssistantIdx && !lockedThreads.has(activeThreadId)) {
+        const regen = document.createElement('button');
+        regen.title = 'Regenerate';
+        regen.setAttribute('aria-label', 'Regenerate');
+        regen.innerHTML =
+          '';
+        regen.addEventListener('click', async () => {
+          if (!window.stdb || activeThreadId === null) return;
+          regen.disabled = true;
+          try {
+            await window.stdb.regenerateResponse(activeThreadId);
+          } catch (err) {
+            toast('err', err.message ?? String(err));
+          } finally {
+            regen.disabled = false;
+          }
+        });
+        footer.appendChild(regen);
+      }
+
+      if (m.isError && lastUserMessage !== null) {
+        const retry = document.createElement('button');
+        retry.title = 'Retry';
+        retry.setAttribute('aria-label', 'Retry');
+        retry.innerHTML =
+          '';
+        const userMsg = lastUserMessage;
+        retry.addEventListener('click', async () => {
+          if (!window.stdb || activeThreadId === null) return;
+          retry.disabled = true;
+          try {
+            await window.stdb.sendMessage(activeThreadId, userMsg);
+          } catch (err) {
+            toast('err', err.message ?? String(err));
+          } finally {
+            retry.disabled = false;
+          }
+        });
+        footer.appendChild(retry);
+      }
+
+      const spacer = document.createElement('span');
+      spacer.className = 'spacer';
+      footer.appendChild(spacer);
+
+      if (
+        !m.isError &&
+        (m.promptTokens !== undefined || m.completionTokens !== undefined)
+      ) {
+        const u = document.createElement('span');
+        u.className = 'usage';
+        const pt = m.promptTokens ?? '?';
+        const ct = m.completionTokens ?? '?';
+        u.textContent = `${pt} in · ${ct} out`;
+        footer.appendChild(u);
+      }
+
+      node.appendChild(footer);
+    }
+
+    wrap.appendChild(node);
+  }
+
+  if (lockedThreads.has(activeThreadId)) {
+    const t = document.createElement('div');
+    t.className = 'typing';
+    t.innerHTML =
+      'agent is thinking';
+    wrap.appendChild(t);
+  }
+
+  if (wasNearBottom) wrap.scrollTop = wrap.scrollHeight;
+}
+
+function formatToolCalls(arr) {
+  return arr
+    .map(c => {
+      const args = c.function?.arguments ?? '';
+      return `→ ${c.function?.name ?? '?'}(${args})`;
+    })
+    .join('\n');
+}
+
+// Message composer
+const MAX_ATTACH_BYTES = 4_000_000;
+const MAX_ATTACH_COUNT = 4;
+const MAX_ATTACH_TOTAL_BYTES = 12_000_000;
+function renderPendingAttachments() {
+  const wrap = $('pending-attachments');
+  if (pendingAttachments.length === 0) {
+    wrap.style.display = 'none';
+    wrap.innerHTML = '';
+    return;
+  }
+  wrap.style.display = 'flex';
+  wrap.innerHTML = '';
+  pendingAttachments.forEach((a, idx) => {
+    const t = document.createElement('div');
+    t.className = 'pending-thumb';
+    const blob = new Blob([a.bytes], { type: a.mimeType });
+    const url = URL.createObjectURL(blob);
+    t.innerHTML = ``;
+    t.querySelector('.x').addEventListener('click', () => {
+      pendingAttachments.splice(idx, 1);
+      renderPendingAttachments();
+    });
+    wrap.appendChild(t);
+  });
+}
+
+// Model picker
+// Model list comes from OpenRouter's /api/v1/models so it's always
+// current. Cached per page load.
+let modelListCache = null;
+let modelListPromise = null;
+async function getModelList() {
+  if (modelListCache) return modelListCache;
+  if (modelListPromise) return modelListPromise;
+  modelListPromise = (async () => {
+    const res = await fetch('https://openrouter.ai/api/v1/models');
+    if (!res.ok) throw new Error(`openrouter /models -> ${res.status}`);
+    const body = await res.json();
+    const ids = (body?.data ?? [])
+      .map(m => m?.id)
+      .filter(id => typeof id === 'string')
+      .sort();
+    modelListCache = ids;
+    return ids;
+  })();
+  try {
+    return await modelListPromise;
+  } finally {
+    modelListPromise = null;
+  }
+}
+
+let modelPopover = null;
+function closeModelPopover() {
+  if (modelPopover) {
+    modelPopover.remove();
+    modelPopover = null;
+  }
+}
+async function pickModel(model) {
+  closeModelPopover();
+  if (!window.stdb || activeThreadId === null) return;
+  try {
+    await window.stdb.updateThread({
+      threadId: activeThreadId,
+      title: undefined,
+      systemPromptOverride: undefined,
+      modelOverride: model,
+      metadata: undefined,
+      clearTitle: false,
+      clearSystemPromptOverride: false,
+      clearModelOverride: false,
+      clearMetadata: false,
+    });
+  } catch (err) {
+    toast('err', err.message ?? String(err));
+  }
+}
+function renderModelList(listEl, ids, current, filter) {
+  listEl.innerHTML = '';
+  const q = filter.trim().toLowerCase();
+  const filtered = q ? ids.filter(id => id.toLowerCase().includes(q)) : ids;
+  if (filtered.length === 0) {
+    const empty = document.createElement('div');
+    empty.className = 'empty';
+    empty.textContent = 'no matches';
+    listEl.appendChild(empty);
+    return;
+  }
+  // Keep the active model at the top if it's in the filtered set.
+  const ordered = filtered.includes(current)
+    ? [current, ...filtered.filter(id => id !== current)]
+    : filtered;
+  // Cap the rendered count for performance; search to find the rest.
+  const SHOW_LIMIT = 200;
+  for (const id of ordered.slice(0, SHOW_LIMIT)) {
+    const btn = document.createElement('button');
+    btn.textContent = id;
+    btn.title = id;
+    if (id === current) btn.classList.add('active');
+    btn.addEventListener('click', () => pickModel(id));
+    listEl.appendChild(btn);
+  }
+  if (ordered.length > SHOW_LIMIT) {
+    const more = document.createElement('div');
+    more.className = 'empty';
+    more.textContent = `…and ${ordered.length - SHOW_LIMIT} more - refine the search`;
+    listEl.appendChild(more);
+  }
+}
+async function openModelPopover(anchor) {
+  closeModelPopover();
+  const t = allThreads.find(x => x.id === activeThreadId);
+  if (!t) return;
+  const current = effectiveModelFor(t);
+  modelPopover = document.createElement('div');
+  modelPopover.className = 'model-popover';
+  const search = document.createElement('input');
+  search.type = 'text';
+  search.className = 'search';
+  search.placeholder = 'filter models…';
+  const list = document.createElement('div');
+  list.className = 'list';
+  const loading = document.createElement('div');
+  loading.className = 'empty';
+  loading.textContent = 'loading models…';
+  list.appendChild(loading);
+  modelPopover.appendChild(search);
+  modelPopover.appendChild(list);
+  document.body.appendChild(modelPopover);
+  // Anchor by bottom edge so the popover grows upward.
+  const r = anchor.getBoundingClientRect();
+  modelPopover.style.left = `${r.left}px`;
+  modelPopover.style.bottom = `${window.innerHeight - r.top + 4}px`;
+  search.focus();
+
+  let ids;
+  try {
+    ids = await getModelList();
+  } catch (err) {
+    loading.textContent = `couldn't load: ${err.message ?? err}`;
+    return;
+  }
+  // Popover may have been closed during the await.
+  if (!modelPopover) return;
+  renderModelList(list, ids, current, '');
+  search.addEventListener('input', () =>
+    renderModelList(list, ids, current, search.value)
+  );
+}
+$('composer-model-label').addEventListener('click', e => {
+  e.stopPropagation();
+  if (modelPopover) closeModelPopover();
+  else openModelPopover(e.currentTarget);
+});
+document.addEventListener('click', e => {
+  if (modelPopover && !modelPopover.contains(e.target)) closeModelPopover();
+});
+document.addEventListener('keydown', e => {
+  if (e.key === 'Escape') closeModelPopover();
+});
+
+$('btn-attach').addEventListener('click', () => $('file-input').click());
+$('file-input').addEventListener('change', async e => {
+  const file = e.target.files?.[0];
+  e.target.value = '';
+  if (!file) return;
+  if (
+    !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type)
+  ) {
+    return toast('err', `unsupported image type: ${file.type}`);
+  }
+  if (pendingAttachments.length >= MAX_ATTACH_COUNT) {
+    return toast('err', `at most ${MAX_ATTACH_COUNT} attachments are allowed`);
+  }
+  if (file.size > MAX_ATTACH_BYTES) {
+    return toast(
+      'err',
+      `attachment too large (${file.size} > ${MAX_ATTACH_BYTES})`
+    );
+  }
+  const totalBytes =
+    pendingAttachments.reduce((sum, item) => sum + item.bytes.length, 0) +
+    file.size;
+  if (totalBytes > MAX_ATTACH_TOTAL_BYTES) {
+    return toast(
+      'err',
+      `attachments exceed the ${MAX_ATTACH_TOTAL_BYTES / 1_000_000} MB total limit`
+    );
+  }
+  const bytes = new Uint8Array(await file.arrayBuffer());
+  pendingAttachments.push({
+    mimeType: file.type,
+    filename: file.name,
+    bytes,
+  });
+  renderPendingAttachments();
+});
+
+const composer = $('composer-input');
+composer.addEventListener('input', () => {
+  composer.style.height = 'auto';
+  composer.style.height = Math.min(composer.scrollHeight, 160) + 'px';
+});
+composer.addEventListener('keydown', e => {
+  if (e.key === 'Enter' && !e.shiftKey) {
+    e.preventDefault();
+    $('btn-send').click();
+  }
+});
+
+$('btn-stop').addEventListener('click', async () => {
+  if (!window.stdb || activeThreadId === null) return;
+  const tid = activeThreadId;
+  if (!lockedThreads.has(tid) || lockedThreads.get(tid) === true) return;
+  try {
+    await window.stdb.requestCancel(tid);
+  } catch (err) {
+    toast('err', err.message ?? String(err));
+  }
+});
+
+$('btn-send').addEventListener('click', async () => {
+  if (!window.stdb || activeThreadId === null) return;
+  const tid = activeThreadId;
+  if (lockedThreads.has(tid) || inFlightSend.has(tid)) return;
+
+  const text = composer.value.trim();
+  const atts = pendingAttachments.slice();
+  if (!text && atts.length === 0) return;
+
+  inFlightSend.add(tid);
+  composer.value = '';
+  composer.style.height = 'auto';
+  pendingAttachments = [];
+  renderPendingAttachments();
+  updateButtons();
+
+  const threadRow = allThreads.find(x => x.id === tid);
+  const noTitleYet =
+    threadRow && (threadRow.title == null || threadRow.title === '');
+  const noPriorUser = !allMessages.some(
+    m => m.threadId === tid && m.role === 'user'
+  );
+
+  try {
+    await window.stdb.sendMessage(tid, text, atts);
+  } catch (err) {
+    toast('err', err.message ?? String(err));
+  } finally {
+    inFlightSend.delete(tid);
+    updateButtons();
+    if (activeThreadId === tid) composer.focus();
+  }
+
+  // After the first message lands, ask the summarizer to title the
+  // thread. Idempotent server-side; failures are silently ignored.
+  if (noTitleYet && noPriorUser && window.stdb) {
+    window.stdb.generateThreadTitle(tid).catch(() => {});
+  }
+});
+
+function updateButtons() {
+  const ready =
+    connState === 'connected' &&
+    configState.kind === 'configured' &&
+    !!window.stdb;
+  $('btn-new-thread').disabled = !ready;
+  $('btn-new-thread-hero').disabled = !ready;
+  const tid = activeThreadId;
+  const locked = tid !== null && lockedThreads.has(tid);
+  const busy = tid !== null && (locked || inFlightSend.has(tid));
+  const canSend = ready && tid !== null && !busy;
+  $('composer-input').disabled = !canSend;
+  $('btn-send').disabled = !canSend;
+  $('btn-attach').disabled = !canSend;
+  $('btn-send').textContent = busy ? 'thinking…' : 'Send';
+  $('btn-send').hidden = locked;
+  $('btn-stop').hidden = !locked;
+  $('btn-stop').disabled = !locked || lockedThreads.get(tid) === true;
+  $('btn-stop').textContent = lockedThreads.get(tid) ? 'stopping…' : 'Stop';
+}
+
+// Auth view + login card + user panel + agent strip
+let currentUserState = null;
+let authMode = 'login'; // 'login' | 'signup' | 'forgot'
+
+function setAuthMode(mode) {
+  authMode = mode;
+  const title = $('auth-title');
+  const sub = $('auth-sub');
+  const submit = $('auth-submit');
+  const togglePrompt = $('toggle-prompt');
+  const toggleLink = $('toggle-link');
+  const forgotFoot = $('forgot-link').parentElement;
+  const passField = $('auth-pass').closest('.auth-field');
+  const nameField = $('auth-name-field');
+  if (mode === 'signup') {
+    title.textContent = 'Create an account';
+    sub.textContent = 'Sign up to start chatting.';
+    submit.textContent = 'Create account';
+    togglePrompt.textContent = 'Already have an account?';
+    toggleLink.textContent = 'Sign in';
+    forgotFoot.hidden = true;
+    nameField.hidden = false;
+    $('auth-pass').autocomplete = 'new-password';
+    passField.hidden = false;
+  } else if (mode === 'forgot') {
+    title.textContent = 'Reset password';
+    sub.textContent = "Enter your email and we'll send you a reset link.";
+    submit.textContent = 'Send reset link';
+    togglePrompt.textContent = 'Remembered it?';
+    toggleLink.textContent = 'Sign in';
+    forgotFoot.hidden = true;
+    nameField.hidden = true;
+    passField.hidden = true;
+  } else {
+    title.textContent = 'Welcome to Agents';
+    sub.textContent = 'Sign in to continue.';
+    submit.textContent = 'Sign in';
+    togglePrompt.textContent = "Don't have an account?";
+    toggleLink.textContent = 'Sign up';
+    forgotFoot.hidden = false;
+    nameField.hidden = true;
+    $('auth-pass').autocomplete = 'current-password';
+    passField.hidden = false;
+  }
+}
+
+$('toggle-link').addEventListener('click', () => {
+  setAuthMode(authMode === 'login' ? 'signup' : 'login');
+});
+$('forgot-link').addEventListener('click', () => setAuthMode('forgot'));
+
+$('auth-form').addEventListener('submit', async e => {
+  e.preventDefault();
+  if (!window.auth) return;
+  const email = $('auth-email').value.trim();
+  const password = $('auth-pass').value;
+  const submit = $('auth-submit');
+  submit.disabled = true;
+  try {
+    if (authMode === 'signup') {
+      const name = $('auth-name').value.trim() || undefined;
+      await window.auth.signup({ email, password, name });
+    } else if (authMode === 'forgot') {
+      await window.auth.forgotPassword(email);
+      toast('ok', 'Reset link sent. Check the STDB module log (dev mailer).');
+      setAuthMode('login');
+    } else {
+      await window.auth.login({ email, password });
+    }
+  } catch (err) {
+    toast('err', err.message ?? String(err));
+  } finally {
+    submit.disabled = false;
+  }
+});
+
+$('oauth-google').addEventListener('click', () =>
+  window.auth?.oauthStart('google')
+);
+$('oauth-github').addEventListener('click', () =>
+  window.auth?.oauthStart('github')
+);
+
+$('btn-logout').addEventListener('click', async () => {
+  if (!window.auth) return;
+  try {
+    await window.auth.logout();
+  } catch (err) {
+    toast('err', err.message ?? String(err));
+  }
+});
+
+function renderUserPanel() {
+  const u = currentUserState;
+  const av = $('user-avatar');
+  const setAvatarInitial = () => {
+    av.classList.remove('has-image');
+    const initial = u
+      ? (u.name?.trim() || u.email || '?').slice(0, 1).toUpperCase()
+      : '?';
+    av.replaceChildren(document.createTextNode(initial));
+  };
+  if (!u) {
+    setAvatarInitial();
+    $('user-name').textContent = 'Unavailable';
+    return;
+  }
+  const imageUrl = typeof u.image === 'string' ? u.image.trim() : '';
+  if (imageUrl) {
+    av.classList.add('has-image');
+    const img = document.createElement('img');
+    img.alt = '';
+    img.referrerPolicy = 'no-referrer';
+    img.addEventListener('error', setAvatarInitial, { once: true });
+    img.src = imageUrl;
+    av.replaceChildren(img);
+  } else {
+    setAvatarInitial();
+  }
+  $('user-name').textContent = u.name?.trim() || u.email;
+}
+
+function applyConnStateToAvatar(state) {
+  const av = $('user-avatar');
+  av.classList.toggle('online', state === 'connected');
+  av.classList.toggle('warn', state === 'connecting' || state === 'idle');
+  $('user-status').textContent =
+    state === 'connected'
+      ? 'online'
+      : state === 'connecting'
+        ? 'connecting…'
+        : state === 'idle'
+          ? 'idle'
+          : 'offline';
+}
+
+// Swap views based on auth state.
+function showAuthView() {
+  $('auth-shell').hidden = false;
+  $('shell').hidden = true;
+}
+function showChatView() {
+  $('auth-shell').hidden = true;
+  $('shell').hidden = false;
+}
+
+function dismissBootSplash() {
+  const splash = document.getElementById('bootSplash');
+  if (!splash) return;
+  splash.classList.add('fading');
+  setTimeout(() => splash.remove(), 250);
+}
+window.addEventListener('auth:ready', () => {
+  if (!currentUserState) showAuthView();
+  dismissBootSplash();
+});
+setTimeout(dismissBootSplash, 4000);
+window.addEventListener('auth:state', e => {
+  currentUserState = e.detail.user;
+  if (currentUserState) {
+    showChatView();
+    renderUserPanel();
+  } else {
+    showAuthView();
+  }
+});
+
+// Mirror conn state into the user-panel status dot.
+window.addEventListener('stdb:connState', e => {
+  applyConnStateToAvatar(e.detail.state);
+});
+
+// Initial view: assume auth-shell until auth:state proves otherwise.
+showAuthView();
diff --git a/spacetime-agents-ts/example/scripts/test-markdown.mjs b/spacetime-agents-ts/example/scripts/test-markdown.mjs
new file mode 100644
index 00000000000..e48cd954953
--- /dev/null
+++ b/spacetime-agents-ts/example/scripts/test-markdown.mjs
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import { escapeHtml, renderMarkdown } from '../public/markdown.js';
+
+assert.equal(
+  escapeHtml(``),
+  '<script data-x="'">&</script>'
+);
+assert.equal(renderMarkdown(''), '');
+assert.equal(
+  renderMarkdown('**bold** and *italic* and `code`'),
+  '

bold and italic and code

' +); +assert.equal( + renderMarkdown('[docs](https://example.com/path)'), + '

docs

' +); +assert.equal( + renderMarkdown('[unsafe](javascript:alert(1))'), + '

[unsafe](javascript:alert(1))

' +); +assert.equal( + renderMarkdown(''), + '

<img src=x onerror=alert(1)>

' +); +assert.equal( + renderMarkdown('```html\nnot HTML\n```'), + '
<strong>not HTML</strong>\n
' +); +assert.equal( + renderMarkdown('before\n```text\ninside\n```\nafter'), + '

before

inside\n

after

' +); +assert.equal(renderMarkdown(' BLOCK0 '), '

BLOCK0

'); + +console.log('agents markdown tests passed'); diff --git a/spacetime-agents-ts/example/server.ts b/spacetime-agents-ts/example/server.ts new file mode 100644 index 00000000000..ba3442434f4 --- /dev/null +++ b/spacetime-agents-ts/example/server.ts @@ -0,0 +1,263 @@ +// Express + static. Browser connects to STDB directly via WebSocket; this +// server also proxies module HTTP handlers so cookies stay same-origin. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared env supplies secrets; example-local env supplies app defaults. +// Blank placeholders in the example .env should not erase shared secrets. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8789', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-agents-example'; +const AUTH_ISSUER_URL = + process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; +const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME ?? 'stdb_auth'; +const AUTH_SESSION_TTL_SECONDS = Number.parseInt( + process.env.AUTH_SESSION_TTL_SECONDS ?? `${60 * 60 * 24 * 7}`, + 10 +); +if ( + !Number.isInteger(AUTH_SESSION_TTL_SECONDS) || + AUTH_SESSION_TTL_SECONDS <= 0 +) { + throw new Error('AUTH_SESSION_TTL_SECONDS must be a positive integer'); +} +const GOOGLE_OAUTH_ENABLED = Boolean( + process.env.GOOGLE_CLIENT_ID?.trim() && + process.env.GOOGLE_CLIENT_SECRET?.trim() +); +const GITHUB_OAUTH_ENABLED = Boolean( + process.env.GITHUB_CLIENT_ID?.trim() && + process.env.GITHUB_CLIENT_SECRET?.trim() +); +const STDB_SERVER = process.env.STDB_SERVER ?? STDB_HTTP; +const SPACETIME_BIN = 'spacetime'; + +function configuredValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function configuredPem(value: string | undefined): string | undefined { + return configuredValue(value)?.replace(/\\n/g, '\n'); +} + +const opt = (value: string | undefined) => + value === undefined ? JSON.stringify([1, []]) : JSON.stringify([0, value]); + +function configureAuthFromEnv(): void { + const args = [ + JSON.stringify(AUTH_ISSUER_URL), + opt(AUTH_BASE_URL), + opt(AUTH_COOKIE_NAME), + JSON.stringify([0, AUTH_SESSION_TTL_SECONDS]), + opt(configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM)), + opt(configuredValue(process.env.GOOGLE_CLIENT_ID)), + opt(configuredValue(process.env.GOOGLE_CLIENT_SECRET)), + opt(configuredValue(process.env.GITHUB_CLIENT_ID)), + opt(configuredValue(process.env.GITHUB_CLIENT_SECRET)), + ]; + + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, STDB_APP_DB, 'set_auth_config', ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`auth config bootstrap failed (exit ${result.status})`); + } +} + +function optU32(value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) return JSON.stringify([1, []]); + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 0xffff_ffff) { + throw new Error(`invalid u32 env value: ${trimmed}`); + } + return JSON.stringify([0, parsed]); +} + +function callReducer(name: string, args: string[]): void { + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, STDB_APP_DB, name, ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`${name} bootstrap failed (exit ${result.status})`); + } +} + +function seedApiKey(provider: string, key: string | undefined): boolean { + const configured = configuredValue(key); + if (!configured) return false; + callReducer('set_api_key', [ + JSON.stringify(provider), + JSON.stringify(configured), + ]); + return true; +} + +function configureAgentsFromEnv(): void { + callReducer('set_agent_secret', [ + optU32(process.env.STALE_LOCK_THRESHOLD_SECS), + optU32(process.env.RATE_LIMIT_TOKENS_PER_WINDOW), + optU32(process.env.RATE_LIMIT_WINDOW_SECS), + ]); + + const seededProviders = [ + seedApiKey('openrouter', process.env.OPENROUTER_API_KEY) + ? 'openrouter' + : undefined, + seedApiKey('openai', process.env.OPENAI_API_KEY) ? 'openai' : undefined, + seedApiKey('anthropic', process.env.ANTHROPIC_API_KEY) + ? 'anthropic' + : undefined, + ].filter((provider): provider is string => Boolean(provider)); + + if (seededProviders.length > 0) { + console.log(`[agents] seeded provider keys: ${seededProviders.join(', ')}`); + } else { + console.log('[agents] no provider keys found in env'); + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +// Reset-password email link serves the SPA so the frontend can read ?token=... +// Must be registered BEFORE the /auth proxy below. +app.get('/auth/password/reset', (_req: Request, res: Response) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +function proxyStdbRoute(prefix: string) { + return async (req: Request, res: Response) => { + const mountedUrl = req.url.startsWith('/?') ? req.url.slice(1) : req.url; + const fullPath = `${prefix}${mountedUrl}`; + const qIdx = fullPath.indexOf('?'); + const subpath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${subpath}${query}`; + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + delete headers.host; + delete headers['content-length']; + headers['x-forwarded-proto'] = headers['x-forwarded-proto'] ?? req.protocol; + + const init: RequestInit = { + method: req.method, + headers, + redirect: 'manual', + }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = JSON.stringify(req.body); + headers['content-type'] = 'application/json'; + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, val); + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + res.send(buf); + } catch (err) { + res.status(502).json({ + error: 'upstream_unreachable', + detail: (err as Error).message, + }); + } + }; +} + +app.use('/auth', proxyStdbRoute('/auth')); +app.use('/files', proxyStdbRoute('/files')); + +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, app: STDB_APP_DB }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + stdbUri: STDB_URI, + appDatabase: STDB_APP_DB, + auth: { + issuerUrl: AUTH_ISSUER_URL, + baseUrl: AUTH_BASE_URL, + cookieName: AUTH_COOKIE_NAME, + sessionTtlSeconds: AUTH_SESSION_TTL_SECONDS, + hasEs256PrivateKeyPem: Boolean( + configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM) + ), + }, + oauth: { + google: GOOGLE_OAUTH_ENABLED, + github: GITHUB_OAUTH_ENABLED, + }, + }); +}); + +try { + console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); + configureAuthFromEnv(); + console.log(`[auth] bootstrapped env config issuer=${AUTH_ISSUER_URL}`); + configureAgentsFromEnv(); +} catch (err) { + console.error( + `[auth] env config bootstrap failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[auth] is the SpacetimeDB host running and the agents example module published?' + ); + process.exit(1); +} + +app.listen(PORT, HOST, () => { + console.log(`Agents test app running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*, /files)`); + console.log(` Database -> ${STDB_APP_DB}`); +}); diff --git a/spacetime-agents-ts/example/spacetimedb/package.json b/spacetime-agents-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..f47f633491b --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/package.json @@ -0,0 +1,23 @@ +{ + "name": "spacetime-agents-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-agents-example", + "test:unit": "tsx scripts/test-loop.ts" + }, + "dependencies": { + "@spacetimedb/agents": "workspace:*", + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/files": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts b/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts new file mode 100644 index 00000000000..93339d36139 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts @@ -0,0 +1,1557 @@ +// Pure-Node tests for the extracted agent loop. Mocks HTTP + LoopTx. + +import { + runAgentLoop, + buildLlmMessages, + type LoopTx, + type LoopMessage, + type LoopConfig, +} from '../src/loop.ts'; +import { isStaleLock } from '../src/sweeper.ts'; +import { + ATTACHMENT_COUNT_MAX, + ATTACHMENT_TOTAL_BYTES_MAX, + attachmentValidationError, +} from '../src/attachments.ts'; +import { + pickSummarizationCandidates, + buildSummarizerUserContent, + augmentSystemWithSummary, + formatMessagesForSummarizer, +} from '../src/summarize.ts'; +import type { HttpLike } from '@spacetimedb/agents/openrouter'; +import type { InvokeResult } from '@spacetimedb/agents/kit'; + +let failures = 0; +function assert(cond: boolean, msg: string): void { + if (!cond) { + process.stderr.write(` FAIL: ${msg}\n`); + failures++; + } else { + process.stdout.write(` ${msg} OK\n`); + } +} + +const eq = (a: unknown, b: unknown): boolean => + JSON.stringify(a) === JSON.stringify(b); + +const png = (length: number) => ({ mimeType: 'image/png', bytes: { length } }); +assert( + attachmentValidationError([png(10)]) === undefined, + 'accepts a supported attachment' +); +assert( + attachmentValidationError([ + { mimeType: 'text/plain', bytes: { length: 10 } }, + ]) === 'agent.unsupported_attachment_mime:text/plain', + 'rejects an unsupported attachment type' +); +assert( + attachmentValidationError( + Array.from({ length: ATTACHMENT_COUNT_MAX + 1 }, () => png(1)) + ) === + `agent.too_many_attachments:${ATTACHMENT_COUNT_MAX + 1}/${ATTACHMENT_COUNT_MAX}`, + 'rejects too many attachments' +); +assert( + attachmentValidationError([ + png(3_000_000), + png(3_000_000), + png(3_000_000), + png(ATTACHMENT_TOTAL_BYTES_MAX - 9_000_000 + 1), + ]) === + `agent.attachments_too_large:${ATTACHMENT_TOTAL_BYTES_MAX + 1}/${ATTACHMENT_TOTAL_BYTES_MAX}`, + 'rejects excessive aggregate attachment bytes' +); + +function makeFakeStore(): { + tx: LoopTx; + messages: LoopMessage[]; + toolInvocations: Array<{ name: string; input: string }>; + toolHandlers: Map InvokeResult>; + withTxCalls: number; + bumpedThreads: bigint[]; + cancelledThreads: Set; + setTool(name: string, handler: (input: string) => InvokeResult): void; + cancel(threadId: bigint): void; +} { + const messages: LoopMessage[] = []; + const toolInvocations: Array<{ name: string; input: string }> = []; + const toolHandlers = new Map InvokeResult>(); + const bumpedThreads: bigint[] = []; + const cancelledThreads = new Set(); + let nextId = 1n; + + const tx: LoopTx = { + listMessages(threadId) { + return messages + .filter(m => m.threadId === threadId) + .slice() + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + }, + appendMessage(row) { + messages.push({ id: nextId++, attachments: [], ...row }); + }, + bumpThread(threadId) { + bumpedThreads.push(threadId); + }, + invokeTool(name, inputJson) { + toolInvocations.push({ name, input: inputJson }); + const h = toolHandlers.get(name); + if (!h) return { result: `unknown tool: ${name}`, isError: true }; + return h(inputJson); + }, + isCancelRequested(threadId) { + return cancelledThreads.has(threadId); + }, + }; + + return { + tx, + messages, + toolInvocations, + toolHandlers, + bumpedThreads, + cancelledThreads, + withTxCalls: 0, + setTool(name, handler) { + toolHandlers.set(name, handler); + }, + cancel(threadId) { + cancelledThreads.add(threadId); + }, + }; +} + +type FakeHttpResponse = { status: number; body: string } | { throws: Error }; +type FakeRequestBody = { + model?: unknown; + messages: Array<{ + role: string; + content?: unknown; + tool_calls?: Array<{ id: string }>; + tool_call_id?: string; + }>; + max_tokens?: unknown; + response_format?: unknown; + [key: string]: unknown; +}; + +function makeFakeHttp(responses: FakeHttpResponse[]): { + http: HttpLike; + requests: Array<{ + url: string; + method: string; + headers: Record; + body: FakeRequestBody; + }>; +} { + const requests: Array<{ + url: string; + method: string; + headers: Record; + body: FakeRequestBody; + }> = []; + let i = 0; + const http: HttpLike = { + fetch(url, init) { + const next = responses[i++]; + requests.push({ + url, + method: init.method, + headers: init.headers, + body: init.body + ? (JSON.parse(init.body) as FakeRequestBody) + : { messages: [] }, + }); + if (!next) + throw new Error(`fake http: no more canned responses (call #${i})`); + if ('throws' in next) throw next.throws; + return { status: next.status, text: () => next.body }; + }, + }; + return { http, requests }; +} + +function llmReply(opts: { + content?: string | null; + toolCalls?: Array<{ id: string; name: string; args: object }>; + finish?: string; + usage?: { prompt: number; completion: number }; +}): FakeHttpResponse { + const finish = + opts.finish ?? + (opts.toolCalls && opts.toolCalls.length > 0 ? 'tool_calls' : 'stop'); + const tool_calls = opts.toolCalls?.map(c => ({ + id: c.id, + type: 'function', + function: { name: c.name, arguments: JSON.stringify(c.args) }, + })); + const u = opts.usage ?? { prompt: 10, completion: 5 }; + return { + status: 200, + body: JSON.stringify({ + model: 'fake/model', + choices: [ + { + finish_reason: finish, + message: { + content: opts.content ?? null, + tool_calls, + }, + }, + ], + usage: { + prompt_tokens: u.prompt, + completion_tokens: u.completion, + total_tokens: u.prompt + u.completion, + }, + }), + }; +} + +function withTxAdapter(tx: LoopTx): (fn: (lt: LoopTx) => R) => R { + return fn => fn(tx); +} + +import { openRouterProvider } from '@spacetimedb/agents/providers'; + +const baseCfg: LoopConfig = { + provider: openRouterProvider, + apiKey: 'sk-test', + model: 'anthropic/claude-3.5-sonnet', + systemPrompt: 'you are a test assistant', + maxTurns: 5, + maxHistoryMessages: 50, + maxTokens: undefined, + retries: 2, + responseFormat: undefined, +}; + +process.stdout.write('agent loop tests\n'); + +// 1. Single-turn text reply +{ + const store = makeFakeStore(); + // Seed with a user message so buildLlmMessages includes it. + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + const { http } = makeFakeHttp([ + llmReply({ content: 'hello!', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistantMsgs = store.messages.filter(m => m.role === 'assistant'); + assert( + assistantMsgs.length === 1, + `single-turn: 1 assistant message inserted` + ); + assert( + assistantMsgs[0].content === 'hello!', + `single-turn: assistant content is 'hello!'` + ); + assert( + assistantMsgs[0].isError === false, + `single-turn: not flagged as error` + ); + assert( + assistantMsgs[0].toolCallsJson === undefined, + `single-turn: no toolCallsJson` + ); +} + +// 2. Tool call -> tool result -> follow-up text (2 turns) +{ + const store = makeFakeStore(); + store.setTool('echo', inp => { + const args = JSON.parse(inp); + return { result: `echoed: ${args.message}`, isError: false }; + }); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'echo hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http, requests } = makeFakeHttp([ + llmReply({ + content: '', + toolCalls: [{ id: 'call_1', name: 'echo', args: { message: 'hi' } }], + }), + llmReply({ content: 'I echoed it.', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert(requests.length === 2, `tool-call flow: 2 LLM calls made`); + + const inserted = store.messages.filter(m => m.id > 1n); // skip seed user msg + assert( + inserted.length === 3, + `tool-call flow: assistant + tool + assistant inserted (got ${inserted.length})` + ); + assert( + inserted[0].role === 'assistant' && inserted[0].toolCallsJson !== undefined, + `tool-call flow: 1st insert is assistant w/ tool_calls` + ); + assert( + inserted[1].role === 'tool' && + inserted[1].content === 'echoed: hi' && + inserted[1].toolCallId === 'call_1', + `tool-call flow: 2nd insert is tool result, correctly linked to call_1` + ); + assert( + inserted[2].role === 'assistant' && + inserted[2].content === 'I echoed it.' && + inserted[2].toolCallsJson === undefined, + `tool-call flow: 3rd insert is final assistant text` + ); + assert( + store.toolInvocations.length === 1 && + store.toolInvocations[0].name === 'echo', + `tool-call flow: echo invoked once` + ); + + // 2nd LLM call must carry the assistant-with-tool_calls + tool result. + const secondReqMsgs = requests[1].body.messages; + const lastTwo = secondReqMsgs.slice(-2); + assert( + lastTwo[0].role === 'assistant' && + Array.isArray(lastTwo[0].tool_calls) && + lastTwo[0].tool_calls.length === 1, + `tool-call flow: 2nd request includes assistant-with-tool_calls` + ); + assert( + lastTwo[1].role === 'tool' && + lastTwo[1].tool_call_id === 'call_1' && + lastTwo[1].content === 'echoed: hi', + `tool-call flow: 2nd request includes tool result message` + ); +} + +// 3. Multiple tool calls in one turn +{ + const store = makeFakeStore(); + store.setTool('echo', inp => ({ + result: `echoed: ${JSON.parse(inp).message}`, + isError: false, + })); + store.setTool('upper', inp => ({ + result: String(JSON.parse(inp).text).toUpperCase(), + isError: false, + })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'do both', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http } = makeFakeHttp([ + llmReply({ + content: null, + toolCalls: [ + { id: 'a', name: 'echo', args: { message: 'one' } }, + { id: 'b', name: 'upper', args: { text: 'two' } }, + ], + }), + llmReply({ content: 'done', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const tools = store.messages.filter(m => m.role === 'tool'); + assert(tools.length === 2, `multi-tool: 2 tool result messages`); + assert( + tools[0].toolCallId === 'a' && tools[0].content === 'echoed: one', + `multi-tool: a -> echo result` + ); + assert( + tools[1].toolCallId === 'b' && tools[1].content === 'TWO', + `multi-tool: b -> upper result` + ); + assert(store.toolInvocations.length === 2, `multi-tool: 2 invocations`); +} + +// 4. HTTP error path +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + const { http } = makeFakeHttp([ + { status: 401, body: '{"error":"unauthorized"}' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistants = store.messages.filter(m => m.role === 'assistant'); + assert( + assistants.length === 1 && assistants[0].isError === true, + `http-error: 1 error assistant message` + ); + assert( + assistants[0].content.includes('agent.provider_http:401'), + `http-error: error string includes status 401` + ); + assert( + assistants[0].content.includes('unauthorized'), + `http-error: error string includes body excerpt` + ); +} + +// 5. Transport error (fetch throws every attempt) +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + // retries=2 -> 3 attempts total. + const { http, requests } = makeFakeHttp([ + { throws: new Error('connection refused') }, + { throws: new Error('connection refused') }, + { throws: new Error('connection refused') }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert( + requests.length === 3, + `transport-error: 3 attempts (initial + 2 retries)` + ); + const assistants = store.messages.filter(m => m.role === 'assistant'); + assert( + assistants.length === 1 && assistants[0].isError === true, + `transport-error: 1 error assistant message` + ); + assert( + assistants[0].content.includes( + 'agent.provider_transport:connection refused' + ), + `transport-error: error string identifies cause` + ); +} + +// 6. Parse error path +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + const { http } = makeFakeHttp([{ status: 200, body: '{not json' }]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistants = store.messages.filter(m => m.role === 'assistant'); + assert( + assistants.length === 1 && + assistants[0].isError === true && + assistants[0].content.includes('agent.provider_parse'), + `parse-error: 1 error assistant message with parse kind` + ); +} + +// 7. Max turns exceeded +{ + const store = makeFakeStore(); + store.setTool('loop_tool', () => ({ result: 'still going', isError: false })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'go', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const cfgSmall: LoopConfig = { ...baseCfg, maxTurns: 3 }; + const { http, requests } = makeFakeHttp([ + llmReply({ + content: '', + toolCalls: [{ id: 'a', name: 'loop_tool', args: {} }], + }), + llmReply({ + content: '', + toolCalls: [{ id: 'b', name: 'loop_tool', args: {} }], + }), + llmReply({ + content: '', + toolCalls: [{ id: 'c', name: 'loop_tool', args: {} }], + }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: cfgSmall, + threadId: 1n, + }); + + assert( + requests.length === 3, + `max-turns: exactly maxTurns LLM calls (got ${requests.length})` + ); + const errMsg = store.messages.find(m => m.isError === true); + assert( + errMsg !== undefined && errMsg.content === 'agent.max_turns_exceeded:3', + `max-turns: final error message inserted` + ); + assert( + store.toolInvocations.length === 3, + `max-turns: 3 tool invocations (one per turn)` + ); +} + +// 8. Failing tool (isError=true). Loop continues if LLM asks again, ends if not. +{ + const store = makeFakeStore(); + store.setTool('flaky', () => ({ result: 'tool exploded', isError: true })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'try', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http } = makeFakeHttp([ + llmReply({ + content: '', + toolCalls: [{ id: 'x', name: 'flaky', args: {} }], + }), + llmReply({ content: 'sorry, that tool broke', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const toolMsg = store.messages.find(m => m.role === 'tool'); + assert( + toolMsg !== undefined && + toolMsg.isError === true && + toolMsg.content === 'tool exploded', + `failing-tool: tool message recorded with isError=true` + ); + const finalAssistant = [...store.messages] + .reverse() + .find(m => m.role === 'assistant' && !m.isError); + assert( + finalAssistant !== undefined && + finalAssistant.content === 'sorry, that tool broke', + `failing-tool: LLM's recovery reply recorded` + ); +} + +// 9. System prompt + message history forwarded correctly +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'first', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 1n, + role: 'assistant', + content: 'reply', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'second', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + // Different thread; must not leak. + store.tx.appendMessage({ + threadId: 99n, + role: 'user', + content: 'OTHER THREAD', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http, requests } = makeFakeHttp([ + llmReply({ content: 'ack', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const req = requests[0]; + assert( + req.url === 'https://openrouter.ai/api/v1/chat/completions', + `forward: posts to OpenRouter URL` + ); + assert( + req.headers.Authorization === 'Bearer sk-test', + `forward: auth header set` + ); + assert( + req.body.model === 'anthropic/claude-3.5-sonnet', + `forward: model included` + ); + assert( + req.body.messages[0].role === 'system' && + req.body.messages[0].content === 'you are a test assistant', + `forward: system prompt prepended` + ); + assert( + eq(req.body.messages.slice(1), [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'reply' }, + { role: 'user', content: 'second' }, + ]), + `forward: thread-1 history in correct order, no thread-99 leak` + ); +} + +// 10. buildLlmMessages: assistant-with-tool-calls round-trips through history +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 7n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 7n, + role: 'assistant', + content: '', + toolCallsJson: JSON.stringify([ + { + id: 'c1', + type: 'function', + function: { name: 'echo', arguments: '{"x":1}' }, + }, + ]), + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 7n, + role: 'tool', + content: 'result', + toolCallsJson: undefined, + toolCallId: 'c1', + isError: false, + }); + + const out = buildLlmMessages(store.tx, 7n, 50); + assert(out.length === 3, `roundtrip: 3 messages built`); + const first = out[0]; + const second = out[1]; + const third = out[2]; + assert(first?.role === 'user' && first.content === 'hi', `roundtrip: user`); + assert( + second?.role === 'assistant' && + Array.isArray(second.tool_calls) && + second.tool_calls[0]?.id === 'c1', + `roundtrip: assistant carries tool_calls array reconstructed from JSON` + ); + assert( + third?.role === 'tool' && third.tool_call_id === 'c1', + `roundtrip: tool message links via tool_call_id` + ); +} + +// 11. Malformed toolCallsJson is dropped, not thrown +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 5n, + role: 'assistant', + content: 'hello', + toolCallsJson: '{not json', + toolCallId: undefined, + isError: false, + }); + const out = buildLlmMessages(store.tx, 5n, 50); + const first = out[0]; + assert( + out.length === 1 && + first?.role === 'assistant' && + first.tool_calls === undefined, + `malformed-json: tool_calls dropped, message preserved` + ); +} + +// 12. History window slides over last N messages +{ + const store = makeFakeStore(); + for (let i = 0; i < 20; i++) { + store.tx.appendMessage({ + threadId: 1n, + role: i % 2 === 0 ? 'user' : 'assistant', + content: `msg-${i}`, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + } + const out = buildLlmMessages(store.tx, 1n, 5); + assert( + out.length === 5, + `history-window: only last 5 messages emitted (got ${out.length})` + ); + assert( + out[0]?.content === 'msg-15' && out[4]?.content === 'msg-19', + `history-window: emits the most recent slice` + ); +} + +// 13. History window drops orphan tool messages (assistant turn evicted) +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 2n, + role: 'assistant', + content: '', + toolCallsJson: JSON.stringify([ + { + id: 'old', + type: 'function', + function: { name: 'echo', arguments: '{}' }, + }, + ]), + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 2n, + role: 'tool', + content: 'orphan', + toolCallsJson: undefined, + toolCallId: 'old', + isError: false, + }); + // Filler so the window cuts off the assistant + tool above. + for (let i = 0; i < 10; i++) { + store.tx.appendMessage({ + threadId: 2n, + role: 'user', + content: `keep-${i}`, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + } + const out = buildLlmMessages(store.tx, 2n, 5); + const hasOrphan = out.some( + m => m.role === 'tool' && m.tool_call_id === 'old' + ); + assert(!hasOrphan, `history-window: orphan tool message dropped`); + assert( + out.every(m => m.role !== 'tool'), + `history-window: only user/assistant survive` + ); +} + +// 14. Tool result truncated at TOOL_RESULT_MAX +{ + const store = makeFakeStore(); + const big = 'A'.repeat(200_000); + store.setTool('big_tool', () => ({ result: big, isError: false })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'go', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http } = makeFakeHttp([ + llmReply({ + content: '', + toolCalls: [{ id: 'a', name: 'big_tool', args: {} }], + }), + llmReply({ content: 'done', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const toolMsg = store.messages.find(m => m.role === 'tool'); + assert(toolMsg !== undefined, `tool-truncation: tool message present`); + assert( + toolMsg!.content.length < big.length, + `tool-truncation: clipped (${toolMsg!.content.length} < ${big.length})` + ); + assert( + toolMsg!.content.endsWith('…[truncated]'), + `tool-truncation: ends with truncation marker` + ); +} + +// 15. max_tokens forwarded to OpenRouter request body +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + llmReply({ content: 'ok', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: { ...baseCfg, maxTokens: 256 }, + threadId: 1n, + }); + + assert( + requests[0].body.max_tokens === 256, + `max-tokens: forwarded as max_tokens=256` + ); +} + +// 15a. responseFormat forwarded to the request body +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + const { http, requests } = makeFakeHttp([ + llmReply({ content: '{}', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: { ...baseCfg, responseFormat: { type: 'json_object' } }, + threadId: 1n, + }); + + assert( + JSON.stringify(requests[0].body.response_format) === + '{"type":"json_object"}', + `response-format: forwarded as response_format=json_object` + ); +} + +// 15b. responseFormat omitted when undefined +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + const { http, requests } = makeFakeHttp([ + llmReply({ content: 'ok', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert( + !('response_format' in requests[0].body), + `response-format-omit: field absent when undefined` + ); +} + +// 16. max_tokens omitted when undefined +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + llmReply({ content: 'ok', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, // maxTokens: undefined + threadId: 1n, + }); + + assert( + !('max_tokens' in requests[0].body), + `max-tokens-omit: field absent when undefined` + ); +} + +// 17. Retry succeeds after 503/503/200 +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + { status: 503, body: 'unavailable' }, + { status: 503, body: 'unavailable' }, + llmReply({ content: 'finally', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert(requests.length === 3, `retry-recovery: 3 attempts made`); + const assistant = store.messages.find(m => m.role === 'assistant'); + assert( + assistant !== undefined && + !assistant.isError && + assistant.content === 'finally', + `retry-recovery: success after retries` + ); +} + +// 18. Retry exhausted after 3x 429 +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + { status: 429, body: 'rate limited' }, + { status: 429, body: 'rate limited' }, + { status: 429, body: 'rate limited' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert(requests.length === 3, `retry-exhaust: 3 attempts then give up`); + const errAssistant = store.messages.find( + m => m.role === 'assistant' && m.isError + ); + assert( + errAssistant !== undefined && + errAssistant.content.includes('agent.provider_http:429'), + `retry-exhaust: final error includes 429` + ); +} + +// 19a. retries=0 disables retries even on 503 +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + { status: 503, body: 'unavailable' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: { ...baseCfg, retries: 0 }, + threadId: 1n, + }); + + assert( + requests.length === 1, + `retries=0: exactly 1 attempt on 503 (no retry)` + ); +} + +// 19. No retry on non-retryable status (401) +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + { status: 401, body: 'unauthorized' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert( + requests.length === 1, + `no-retry-401: exactly 1 attempt (no retries on 401)` + ); +} + +// 21. Usage round-trip: assistant message captures promptTokens + completionTokens +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + const { http } = makeFakeHttp([ + llmReply({ + content: 'hi back', + finish: 'stop', + usage: { prompt: 47, completion: 13 }, + }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistant = store.messages.find(m => m.role === 'assistant'); + assert(assistant !== undefined, `usage: assistant message present`); + assert( + assistant!.promptTokens === 47, + `usage: promptTokens = 47 (got ${assistant?.promptTokens})` + ); + assert( + assistant!.completionTokens === 13, + `usage: completionTokens = 13 (got ${assistant?.completionTokens})` + ); +} + +// 22. Usage of 0 (e.g. billing not yet reported) -> stored as undefined +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + const { http } = makeFakeHttp([ + llmReply({ + content: 'ok', + finish: 'stop', + usage: { prompt: 0, completion: 0 }, + }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistant = store.messages.find(m => m.role === 'assistant'); + assert( + assistant!.promptTokens === undefined, + `usage-zero: promptTokens=0 mapped to undefined` + ); + assert( + assistant!.completionTokens === undefined, + `usage-zero: completionTokens=0 mapped to undefined` + ); +} + +// 23. Tool messages and error assistants have no usage +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + store.setTool('echo', () => ({ result: 'r', isError: false })); + const { http } = makeFakeHttp([ + llmReply({ toolCalls: [{ id: 'a', name: 'echo', args: {} }] }), + { status: 401, body: 'unauthorized' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const tool = store.messages.find(m => m.role === 'tool'); + assert( + tool!.promptTokens === undefined && tool!.completionTokens === undefined, + `usage: tool message has no usage` + ); + const err = store.messages.find(m => m.role === 'assistant' && m.isError); + assert( + err!.promptTokens === undefined && err!.completionTokens === undefined, + `usage: error assistant has no usage` + ); +} + +// 24. Cancel before turn 1: loop bails immediately, no LLM call +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + store.cancel(1n); + + const { http, requests } = makeFakeHttp([]); + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + assert(requests.length === 0, `cancel-before: 0 LLM calls`); + const cancelMsg = store.messages.find( + m => m.role === 'assistant' && m.content === 'agent.cancelled' + ); + assert( + cancelMsg !== undefined && cancelMsg.isError === true, + `cancel-before: cancelled message inserted` + ); +} + +// 25. Cancel between turns: loop completes turn 1, sees cancel, bails before turn 2 +{ + const store = makeFakeStore(); + store.setTool('echo', () => ({ result: 'r', isError: false })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'go', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + // Trigger cancel inside the appendMessage hook of turn 1. + let triggeredCancel = false; + const originalAppend = store.tx.appendMessage.bind(store.tx); + store.tx.appendMessage = row => { + originalAppend(row); + if (!triggeredCancel && row.role === 'assistant' && row.toolCallsJson) { + triggeredCancel = true; + store.cancel(1n); + } + }; + + const { http, requests } = makeFakeHttp([ + llmReply({ content: '', toolCalls: [{ id: 'a', name: 'echo', args: {} }] }), + ]); + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert( + requests.length === 1, + `cancel-between: only 1 LLM call (turn 2 skipped)` + ); + const cancelMsg = store.messages.find( + m => m.role === 'assistant' && m.content === 'agent.cancelled' + ); + assert( + cancelMsg !== undefined && cancelMsg.isError === true, + `cancel-between: cancelled message inserted on turn 2 entry` + ); + assert( + store.toolInvocations.length === 1, + `cancel-between: turn 1's tool call completed before cancel` + ); +} + +// 20. Stale-lock predicate (threshold is operator-tunable, passed as arg) +{ + const ONE_MIN = 60n * 1_000_000n; + const FIFTEEN_MIN = 15n * ONE_MIN; + const now = 1_000_000_000_000_000n; + assert( + isStaleLock(now, now - 16n * ONE_MIN, FIFTEEN_MIN) === true, + `stale-lock: 16-min-old lock is stale at 15-min threshold` + ); + assert( + isStaleLock(now, now - 14n * ONE_MIN, FIFTEEN_MIN) === false, + `stale-lock: 14-min-old lock is fresh at 15-min threshold` + ); + // exactly threshold -> not stale (strict <) + assert( + isStaleLock(now, now - FIFTEEN_MIN, FIFTEEN_MIN) === false, + `stale-lock: lock at threshold boundary is fresh (strict <)` + ); + assert( + isStaleLock(now, now, FIFTEEN_MIN) === false, + `stale-lock: brand-new lock is fresh` + ); + // A future timestamp caused by clock skew remains fresh. + assert( + isStaleLock(now, now + ONE_MIN, FIFTEEN_MIN) === false, + `stale-lock: future-dated lock is fresh` + ); + assert( + isStaleLock(now, now - 16n * ONE_MIN, 30n * ONE_MIN) === false, + `stale-lock: 16-min-old lock is fresh at 30-min threshold (operator tuned)` + ); +} + +process.stdout.write('\nsummarization helper tests\n'); + +function mkMsg( + id: bigint, + role: string, + content: string, + extras: Partial = {} +): LoopMessage { + return { + id, + threadId: 1n, + role, + content, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + attachments: [], + ...extras, + }; +} + +// 26. pickSummarizationCandidates: history fits window -> null +{ + const messages = [mkMsg(1n, 'user', 'a'), mkMsg(2n, 'assistant', 'b')]; + assert( + pickSummarizationCandidates(messages, 5, null) === null, + `pickCandidates: history within window -> null` + ); +} + +// 27. pickSummarizationCandidates: 10 messages, window=4 -> 6 dropped +{ + const messages = Array.from({ length: 10 }, (_, i) => + mkMsg(BigInt(i + 1), 'user', `m${i}`) + ); + const result = pickSummarizationCandidates(messages, 4, null); + assert( + result !== null && result.newDropped.length === 6, + `pickCandidates: 10 msgs, window=4 -> 6 dropped` + ); + assert( + result!.lastNewId === 6n, + `pickCandidates: lastNewId is the last dropped id` + ); +} + +// 28. pickSummarizationCandidates: respects summarizedThroughId +{ + // 10 msgs, window=4 -> ids 1-6 dropped; summary covers through 4 -> only 5,6 new. + const messages = Array.from({ length: 10 }, (_, i) => + mkMsg(BigInt(i + 1), 'user', `m${i}`) + ); + const result = pickSummarizationCandidates(messages, 4, 4n); + assert( + result !== null && result.newDropped.length === 2, + `pickCandidates: respects summarizedThroughId -> only NEW dropped (got ${result?.newDropped.length})` + ); + assert( + result!.newDropped[0].id === 5n && result!.lastNewId === 6n, + `pickCandidates: newDropped starts at first uncovered id` + ); +} + +// 29. pickSummarizationCandidates: summary already covers all dropped -> null +{ + const messages = Array.from({ length: 10 }, (_, i) => + mkMsg(BigInt(i + 1), 'user', `m${i}`) + ); + // summary covers 1-7 (>= window-cutoff at 6) -> nothing new + assert( + pickSummarizationCandidates(messages, 4, 7n) === null, + `pickCandidates: summary covers all dropped -> null` + ); +} + +// 30. formatMessagesForSummarizer: roles render distinctly +{ + const m = [ + mkMsg(1n, 'user', 'hello'), + mkMsg(2n, 'assistant', 'hi back'), + mkMsg(3n, 'tool', 'tool_result_text', { toolCallId: 'c1' }), + ]; + const out = formatMessagesForSummarizer(m); + assert(out.includes('User: hello'), `format: user line`); + assert(out.includes('Assistant: hi back'), `format: assistant line`); + assert( + out.includes('[Tool result: tool_result_text]'), + `format: tool result line` + ); +} + +// 31. formatMessagesForSummarizer: assistant tool_calls render +{ + const m = [ + mkMsg(1n, 'assistant', '', { + toolCallsJson: JSON.stringify([ + { function: { name: 'echo', arguments: '{"x":1}' } }, + ]), + }), + ]; + const out = formatMessagesForSummarizer(m); + assert( + out.includes('[Assistant called tool echo({"x":1})]'), + `format: assistant tool call rendered` + ); +} + +// 32. buildSummarizerUserContent: with existing summary +{ + const m = [mkMsg(1n, 'user', 'hi')]; + const out = buildSummarizerUserContent('prior summary text', m); + assert( + out.includes('Existing summary:\nprior summary text'), + `buildContent: includes existing summary` + ); + assert( + out.includes('Additional messages'), + `buildContent: asks for extension when summary exists` + ); +} + +// 33. buildSummarizerUserContent: without existing summary +{ + const m = [mkMsg(1n, 'user', 'hi')]; + const out = buildSummarizerUserContent(null, m); + assert( + !out.includes('Existing summary'), + `buildContent: no existing summary header` + ); + assert( + out.includes('Messages to summarize'), + `buildContent: from-scratch header` + ); +} + +// 34. augmentSystemWithSummary: no summary -> unchanged +{ + assert( + augmentSystemWithSummary('be helpful', null) === 'be helpful', + `augment: null summary returns base unchanged` + ); + assert( + augmentSystemWithSummary('be helpful', '') === 'be helpful', + `augment: empty summary returns base unchanged` + ); + assert( + augmentSystemWithSummary(undefined, null) === undefined, + `augment: undefined base + null summary stays undefined` + ); +} + +// 35. augmentSystemWithSummary: summary appended under divider +{ + const out = augmentSystemWithSummary('be helpful', 'we discussed APIs'); + assert(out!.includes('be helpful'), `augment: includes base`); + assert( + out!.includes('## Summary of earlier conversation'), + `augment: divider header present` + ); + assert(out!.includes('we discussed APIs'), `augment: includes summary`); +} + +// 36. augmentSystemWithSummary: undefined base + summary +{ + const out = augmentSystemWithSummary(undefined, 'context'); + assert( + out !== undefined && out.includes('context'), + `augment: produces summary-only system prompt when base is undefined` + ); +} + +if (failures > 0) { + process.stderr.write(`\n${failures} test(s) failed.\n`); + process.exit(1); +} +process.stdout.write('\nall agent loop tests passed.\n'); diff --git a/spacetime-agents-ts/example/spacetimedb/scripts/tsconfig.json b/spacetime-agents-ts/example/spacetimedb/scripts/tsconfig.json new file mode 100644 index 00000000000..5197ce2769f --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/scripts/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts"] +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts b/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts new file mode 100644 index 00000000000..c15030fff72 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts @@ -0,0 +1,20 @@ +import { defineAgent } from '@spacetimedb/agents/kit'; +import getTime from '../tools/getTime'; +import echo from '../tools/echo'; + +export default defineAgent({ + defaultModel: 'anthropic/claude-haiku-4.5', + defaultSystemPrompt: + 'You are a helpful assistant. Use tools when they make the answer better.', + defaultMaxTurns: 10, + defaultMaxHistoryMessages: 50, + defaultRetries: 2, + summarizerAgentName: 'summarizer', + embeddingsProvider: 'openai', + embeddingsModel: 'text-embedding-3-small', + ragTopK: 4, + tools: { + get_time: getTime, + echo, + }, +}); diff --git a/spacetime-agents-ts/example/spacetimedb/src/agents/index.ts b/spacetime-agents-ts/example/spacetimedb/src/agents/index.ts new file mode 100644 index 00000000000..0ceb62ee649 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/agents/index.ts @@ -0,0 +1,7 @@ +import chat from './chat'; +import summarizer from './summarizer'; + +export const agents = { + chat, + summarizer, +}; diff --git a/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts b/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts new file mode 100644 index 00000000000..05952a4c212 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts @@ -0,0 +1,17 @@ +import { defineAgent } from '@spacetimedb/agents/kit'; + +export default defineAgent({ + defaultModel: 'anthropic/claude-haiku-4.5', + defaultSystemPrompt: + 'You produce concise running summaries of chat conversations. ' + + 'Capture facts, decisions, names, numbers, and ongoing tasks the ' + + 'main assistant must remember. Skip pleasantries. If the user ' + + 'provides an existing summary, EXTEND it with the new content. ' + + 'Do not restart from scratch and do not duplicate prior facts. ' + + 'Reply with the updated summary as plain prose, no preamble.', + defaultMaxTurns: 1, + defaultMaxHistoryMessages: 100, + defaultMaxTokens: 600, + defaultRetries: 2, + tools: {}, +}); diff --git a/spacetime-agents-ts/example/spacetimedb/src/attachments.ts b/spacetime-agents-ts/example/spacetimedb/src/attachments.ts new file mode 100644 index 00000000000..82121eba7ab --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/attachments.ts @@ -0,0 +1,39 @@ +import { FILE_BYTES_MAX } from '@spacetimedb/files/constants'; + +export const ATTACHMENT_ALLOWED_MIMES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +]); + +export const ATTACHMENT_COUNT_MAX = 4; +export const ATTACHMENT_TOTAL_BYTES_MAX = 12_000_000; + +export interface AttachmentInput { + mimeType: string; + bytes: ArrayLike; +} + +export function attachmentValidationError( + attachments: readonly AttachmentInput[] +): string | undefined { + if (attachments.length > ATTACHMENT_COUNT_MAX) { + return `agent.too_many_attachments:${attachments.length}/${ATTACHMENT_COUNT_MAX}`; + } + + let totalBytes = 0; + for (const attachment of attachments) { + if (!ATTACHMENT_ALLOWED_MIMES.has(attachment.mimeType)) { + return `agent.unsupported_attachment_mime:${attachment.mimeType}`; + } + if (attachment.bytes.length > FILE_BYTES_MAX) { + return `agent.attachment_too_large:${attachment.bytes.length}/${FILE_BYTES_MAX}`; + } + totalBytes += attachment.bytes.length; + if (totalBytes > ATTACHMENT_TOTAL_BYTES_MAX) { + return `agent.attachments_too_large:${totalBytes}/${ATTACHMENT_TOTAL_BYTES_MAX}`; + } + } + return undefined; +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/index.ts b/spacetime-agents-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..f32cf7bc9a7 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,1058 @@ +// Multi-agent submodule. Effective config = thread > agent_override > code default. +import { + schema, + table, + t, + Range, + Router, + SenderError, + type TransactionCtx, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, +} from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { + deleteStaleThreadLocks, + staleLockCutoffMicros, +} from '@spacetimedb/agents/stale-locks'; +import * as auth from '@spacetimedb/auth/submodule'; +import { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + linkConnectionImpl, + unlinkConnectionParams, + updateProfileParams, + revokeSessionParams, + listMySessionsParams, + revokeMySessionParams, + passwordSignupHandler, + parseCookies, + passwordLoginHandler, + meHandler, + logoutHandler, + refreshHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + makeEmailVerifyRequestHandler, + makeEmailVerifyHandler, + getCallerUserId, + publicKeyFromPem, + verifyJwt, + type SendMailFn, + type MailParams, +} from '@spacetimedb/auth/submodule'; +import { consumeRateLimit } from '@spacetimedb/rate-limit/submodule'; +import * as agentRateLimit from '@spacetimedb/rate-limit/submodule'; +import { callChat, type Provider } from '@spacetimedb/agents/openrouter'; +import { BUILT_IN_PROVIDERS } from '@spacetimedb/agents/providers'; +import { + FILE_VISIBILITY_OWNER, + fileSha256Hex, +} from '@spacetimedb/files/submodule'; +import * as files from '@spacetimedb/files/submodule'; +import { USER_CONTENT_MAX, type LoopConfig } from './loop'; +import { SWEEPER_INTERVAL_MICROS } from './sweeper'; +import { attachmentValidationError } from './attachments'; +import { registerAgentViews } from './views'; +import { maybeEmbedMessage, registry, runLockedLoop } from './runtime'; + +const ONE_SECOND_MICROS = 1_000_000n; +const DEFAULT_STALE_LOCK_THRESHOLD_SECS = 15 * 60; +const U32_MAX = 0xffff_ffff; +const AGENT_TOKEN_RATE_LIMIT_SCOPE = 'agents.tokens'; + +function throwSenderError(msg: string): never { + throw new SenderError(msg); +} + +// Development mailer that logs messages. Configure a delivery provider in production. +const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { + console.log( + `[mail] to=${params.to} subject=${params.subject}\n${params.text}` + ); +}; + +// Ownership is keyed by userId so the same user works across devices. +import { + apiKey, + agentSecret, + agentAdminIdentity, + agentOverride, + thread, + message, + threadLock, + messageAttachment, + messageEmbedding, +} from './model'; + +const threadLockSweeperTick = table( + { name: 'thread_lock_sweeper_tick', scheduled: (): any => thread_lock_sweep }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + auth, + files, + agentRateLimit, + agentSecret, + agentAdminIdentity, + agentOverride, + apiKey, + thread, + message, + messageAttachment, + threadLock, + threadLockSweeperTick, + messageEmbedding, +}); +export default spacetimedb; + +type Schema = InferSchema; +type WriteCtx = TransactionCtx; + +export const { + myThreads, + myMessages, + myThreadLocks, + myMessageEmbeddings, + myFiles, + myAuthUser, +} = registerAgentViews(spacetimedb); + +function requireAdmin(tx: WriteCtx): void { + if (tx.db.agentAdminIdentity.identity.find(tx.sender) == null) { + throwSenderError('agent.not_authorized'); + } +} + +// Procedures and reducers both expose sender and db. +type CallerCtx = ProcedureCtx | ReducerCtx; + +function requireUserId(ctx: CallerCtx): string { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throwSenderError('agent.not_authenticated'); + return userId; +} + +function requireOwnedThread(tx: WriteCtx, threadId: bigint, userId: string) { + const row = tx.db.thread.id.find(threadId); + if (!row) throwSenderError(`agent.thread_not_found:${threadId}`); + if (row.userId !== userId) + throwSenderError(`agent.not_thread_owner:${threadId}`); + return row; +} + +function toU32OrThrow(name: string, value: bigint): number { + if (value <= 0n || value > BigInt(U32_MAX)) { + throwSenderError(`agent.invalid_${name}`); + } + return Number(value); +} + +function rateLimitKey(userId: string): string { + return `${AGENT_TOKEN_RATE_LIMIT_SCOPE}:${userId}`; +} + +function isExpired(nowMicros: bigint, expiresAtMicros: bigint): boolean { + return expiresAtMicros <= nowMicros; +} + +function checkRateLimit(tx: WriteCtx, userId: string): void { + const secret = tx.db.agentSecret.singleton.find(true); + if ( + secret == null || + secret.rateLimitTokensPerWindow == null || + secret.rateLimitWindowSecs == null + ) + return; + + const cap = Number(secret.rateLimitTokensPerWindow); + const key = rateLimitKey(userId); + const existing = tx.db.agentRateLimit.rateLimitBucket.key.find(key); + if (existing == null) return; + + const nowMicros = tx.timestamp.microsSinceUnixEpoch as bigint; + const expiresAtMicros = existing.expiresAt.microsSinceUnixEpoch as bigint; + if (isExpired(nowMicros, expiresAtMicros)) return; + + if (existing.count >= cap) { + throwSenderError(`agent.rate_limited:${existing.count}/${cap}`); + } +} + +function bumpRateLimit(tx: WriteCtx, userId: string, tokens: bigint): void { + if (tokens <= 0n) return; + + const secret = tx.db.agentSecret.singleton.find(true); + if ( + secret == null || + secret.rateLimitTokensPerWindow == null || + secret.rateLimitWindowSecs == null + ) + return; + + const cost = toU32OrThrow('rate_limit_tokens', tokens); + const windowSeconds = Number(secret.rateLimitWindowSecs); + + const result = consumeRateLimit(tx.as.agentRateLimit, { + key: rateLimitKey(userId), + scope: AGENT_TOKEN_RATE_LIMIT_SCOPE, + // Cap enforced in checkRateLimit; this only increments usage. + limit: U32_MAX, + windowSeconds, + cost, + }); + + if (!result.allowed) { + throwSenderError('agent.rate_limit_counter_overflow'); + } +} + +export const init = spacetimedb.init(ctx => { + auth.installAuth(ctx.as.auth); + files.installFiles(ctx.as.files); + agentRateLimit.installRateLimit(ctx.as.agentRateLimit); + ctx.db.agentAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + ctx.db.threadLockSweeperTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(SWEEPER_INTERVAL_MICROS), + }); +}); + +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.set_auth_config(ctx.as.auth, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.get_auth_public_key(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +// Procedure (not reducer) so the client can await commit before subscribing. +export const link_connection = spacetimedb.procedure( + linkConnectionParams, + t.object('LinkConnectionResult', { userId: t.string() }), + (ctx, args) => linkConnectionImpl(ctx.as.auth, args) +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlink_connection(ctx.as.auth, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.update_profile(ctx.as.auth, args); + } +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revoke_session(ctx.as.auth, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + (ctx, args) => + auth.list_my_sessions(ctx.as.auth, args) as { + sessions: Array<{ + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; + }>; + } +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revoke_my_session(ctx.as.auth, args); + } +); + +const forgotHandler = makeForgotPasswordHandler({ + sendMail: consoleSendMail, + appName: 'Agents', +}); +const verifyRequestHandler = makeEmailVerifyRequestHandler({ + sendMail: consoleSendMail, + appName: 'Agents', +}); +const verifyHandler = makeEmailVerifyHandler({ + successRedirect: '/?verified=1', +}); +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + passwordSignupHandler(ctx.as.auth, req) +); +export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => + passwordLoginHandler(ctx.as.auth, req) +); +export const authMe = spacetimedb.httpHandler((ctx, req) => + meHandler(ctx.as.auth, req) +); +export const authLogout = spacetimedb.httpHandler((ctx, req) => + logoutHandler(ctx.as.auth, req) +); +export const authRefresh = spacetimedb.httpHandler((ctx, req) => + refreshHandler(ctx.as.auth, req) +); +export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => + googleStartHandler(ctx.as.auth, req) +); +export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => + googleCallbackHandler(ctx.as.auth, req) +); +export const authGithubStart = spacetimedb.httpHandler((ctx, req) => + githubStartHandler(ctx.as.auth, req) +); +export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => + githubCallbackHandler(ctx.as.auth, req) +); +export const authPasswordForgot = spacetimedb.httpHandler((ctx, req) => + forgotHandler(ctx.as.auth, req) +); +export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => + resetPasswordHandler(ctx.as.auth, req) +); +export const authEmailVerifyRequest = spacetimedb.httpHandler((ctx, req) => + verifyRequestHandler(ctx.as.auth, req) +); +export const authEmailVerify = spacetimedb.httpHandler((ctx, req) => + verifyHandler(ctx.as.auth, req) +); + +const fileServeHandler = files.makeFileServeImpl({ + getOwner: (ctx, req) => + ctx.withTx((tx: TransactionCtx) => { + const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( + tx.sender + ); + if (binding) return binding.userId; + + const cfg = tx.db.auth.authConfig.singleton.find(true); + if (!cfg) return undefined; + const bearer = req.headers.get('authorization'); + const cookies = parseCookies(req.headers.get('cookie')); + const tokens = [ + bearer && bearer.toLowerCase().startsWith('bearer ') + ? bearer.slice(7).trim() + : undefined, + cookies[cfg.cookieName], + ].filter((token): token is string => Boolean(token)); + for (const token of tokens) { + const verified = verifyJwt( + publicKeyFromPem(cfg.es256PublicKeyPem), + token, + { + issuer: cfg.issuerUrl, + nowSeconds: Number( + (tx.timestamp.microsSinceUnixEpoch as bigint) / 1_000_000n + ), + } + ); + if (!verified.ok || !verified.claims.jti) continue; + + const session = tx.db.auth.authSession.sessionId.find( + verified.claims.jti + ); + if (!session) continue; + if ( + (session.expiresAt.microsSinceUnixEpoch as bigint) <= + (tx.timestamp.microsSinceUnixEpoch as bigint) + ) { + continue; + } + if (session.userId === verified.claims.sub) return session.userId; + } + return undefined; + }), + canAccess: (ctx, _req, file, userId) => + ctx.withTx((tx: TransactionCtx) => { + if (!userId) return false; + if (file.ownerUserId === userId) return true; + for (const a of tx.db.messageAttachment.fileId.filter(file.id)) { + if (a.ownerUserId === userId) return true; + } + return false; + }), +}); +export const fileServe = spacetimedb.httpHandler(fileServeHandler); + +export const router = spacetimedb.httpRouter( + new Router() + .post('/auth/password/signup', authPasswordSignup) + .post('/auth/password/login', authPasswordLogin) + .post('/auth/session/refresh', authRefresh) + .get('/auth/me', authMe) + .post('/auth/logout', authLogout) + .get('/auth/google/start', authGoogleStart) + .get('/auth/google/callback', authGoogleCallback) + .get('/auth/github/start', authGithubStart) + .get('/auth/github/callback', authGithubCallback) + .post('/auth/password/forgot', authPasswordForgot) + .post('/auth/password/reset', authPasswordReset) + .post('/auth/email/verify-request', authEmailVerifyRequest) + .get('/auth/email/verify', authEmailVerify) + .get('/files', fileServe) + .get('/files/', fileServe) + .head('/files/', fileServe) + .head('/files', fileServe) +); + +// Admin-gated tuning; API keys go through set_api_key. +export const set_agent_secret = spacetimedb.reducer( + { + staleLockThresholdSecs: t.option(t.u32()), + rateLimitTokensPerWindow: t.option(t.u32()), + rateLimitWindowSecs: t.option(t.u32()), + }, + (ctx, args) => { + const staleLockThresholdSecs = + args.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS; + if (staleLockThresholdSecs === 0) { + throwSenderError('agent.invalid_stale_lock_threshold:must be > 0'); + } + if ( + args.rateLimitTokensPerWindow !== undefined && + args.rateLimitTokensPerWindow === 0 + ) { + throwSenderError('agent.invalid_rate_limit_tokens:must be > 0'); + } + if ( + args.rateLimitWindowSecs !== undefined && + args.rateLimitWindowSecs === 0 + ) { + throwSenderError('agent.invalid_rate_limit_window:must be > 0'); + } + + const tx = ctx; + requireAdmin(tx); + + const existing = tx.db.agentSecret.singleton.find(true); + const row = { + singleton: true, + staleLockThresholdSecs, + rateLimitTokensPerWindow: args.rateLimitTokensPerWindow, + rateLimitWindowSecs: args.rateLimitWindowSecs, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.agentSecret.singleton.update(row); + } else { + tx.db.agentSecret.insert(row); + } + } +); + +export const set_api_key = spacetimedb.reducer( + { provider: t.string(), key: t.string() }, + (ctx, args) => { + if (args.provider.length === 0) + throwSenderError('agent.invalid_provider:empty'); + if (args.key.length === 0) throwSenderError('agent.invalid_api_key:empty'); + if (!Object.hasOwn(BUILT_IN_PROVIDERS, args.provider)) { + throwSenderError(`agent.unknown_provider:${args.provider}`); + } + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.apiKey.provider.find(args.provider); + const row = { + provider: args.provider, + key: args.key, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.apiKey.provider.update(row); + } else { + tx.db.apiKey.insert(row); + } + } +); + +export const clear_api_key = spacetimedb.reducer( + { provider: t.string() }, + (ctx, { provider }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.apiKey.provider.find(provider); + if (existing) tx.db.apiKey.delete(existing); + } +); + +export const set_agent_override = spacetimedb.reducer( + { + agentName: t.string(), + provider: t.option(t.string()), + model: t.option(t.string()), + systemPrompt: t.option(t.string()), + maxTurns: t.option(t.u32()), + maxHistoryMessages: t.option(t.u32()), + maxTokens: t.option(t.u32()), + retries: t.option(t.u32()), + }, + (ctx, args) => { + if (!registry.has(args.agentName)) { + throwSenderError(`agent.unknown:${args.agentName}`); + } + if ( + args.provider !== undefined && + !Object.hasOwn(BUILT_IN_PROVIDERS, args.provider) + ) { + throwSenderError(`agent.unknown_provider:${args.provider}`); + } + if (args.maxTurns !== undefined && args.maxTurns === 0) { + throwSenderError('agent.invalid_max_turns:must be > 0'); + } + if ( + args.maxHistoryMessages !== undefined && + args.maxHistoryMessages === 0 + ) { + throwSenderError('agent.invalid_max_history:must be > 0'); + } + + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentOverride.agentName.find(args.agentName); + const row = { + agentName: args.agentName, + provider: args.provider, + model: args.model, + systemPrompt: args.systemPrompt, + maxTurns: args.maxTurns, + maxHistoryMessages: args.maxHistoryMessages, + maxTokens: args.maxTokens, + retries: args.retries, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.agentOverride.agentName.update(row); + } else { + tx.db.agentOverride.insert(row); + } + } +); + +export const clear_agent_override = spacetimedb.reducer( + { agentName: t.string() }, + (ctx, { agentName }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentOverride.agentName.find(agentName); + if (existing) tx.db.agentOverride.delete(existing); + } +); + +export const get_agent_config_status = spacetimedb.procedure( + {}, + t.object('AgentConfigStatus', { + isConfigured: t.bool(), + staleLockThresholdSecs: t.u32(), + rateLimitTokensPerWindow: t.option(t.u32()), + rateLimitWindowSecs: t.option(t.u32()), + agents: t.array( + t.object('AgentInfo', { + name: t.string(), + defaultProvider: t.string(), + defaultModel: t.string(), + }) + ), + configuredProviders: t.array(t.string()), + }), + ctx => + ctx.withTx(tx => { + const secret = tx.db.agentSecret.singleton.find(true); + const configuredProviders = [...tx.db.apiKey.iter()] + .map(r => r.provider) + .sort(); + const agents = registry.names().map(name => { + const def = registry.agentDef(name)!; + return { + name, + defaultProvider: def.defaultProvider, + defaultModel: def.defaultModel, + }; + }); + return { + isConfigured: secret != null, + staleLockThresholdSecs: + secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS, + rateLimitTokensPerWindow: secret?.rateLimitTokensPerWindow, + rateLimitWindowSecs: secret?.rateLimitWindowSecs, + agents, + configuredProviders, + }; + }) +); + +export const add_agent_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, { identity }) => { + const tx = ctx; + requireAdmin(tx); + if (tx.db.agentAdminIdentity.identity.find(identity) == null) { + tx.db.agentAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + } +); + +export const remove_agent_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, { identity }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentAdminIdentity.identity.find(identity); + if (existing) tx.db.agentAdminIdentity.delete(existing); + } +); + +export const start_thread = spacetimedb.procedure( + { + agentName: t.string(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + metadata: t.option(t.string()), + }, + t.u64(), + (ctx, args) => { + const userId = requireUserId(ctx); + if (!registry.has(args.agentName)) { + throwSenderError(`agent.unknown:${args.agentName}`); + } + return ctx.withTx(tx => { + const inserted = tx.db.thread.insert({ + id: 0n, + userId, + agentName: args.agentName, + title: args.title, + systemPromptOverride: args.systemPromptOverride, + modelOverride: undefined, + metadata: args.metadata, + summary: undefined, + summarizedThroughId: undefined, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + return inserted.id; + }); + } +); + +export const update_thread = spacetimedb.reducer( + { + threadId: t.u64(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + modelOverride: t.option(t.string()), + metadata: t.option(t.string()), + clearTitle: t.bool(), + clearSystemPromptOverride: t.bool(), + clearModelOverride: t.bool(), + clearMetadata: t.bool(), + }, + (ctx, args) => { + const userId = requireUserId(ctx); + const tx = ctx; + const row = requireOwnedThread(tx, args.threadId, userId); + tx.db.thread.id.update({ + ...row, + title: args.clearTitle ? undefined : (args.title ?? row.title), + systemPromptOverride: args.clearSystemPromptOverride + ? undefined + : (args.systemPromptOverride ?? row.systemPromptOverride), + modelOverride: args.clearModelOverride + ? undefined + : (args.modelOverride ?? row.modelOverride), + metadata: args.clearMetadata + ? undefined + : (args.metadata ?? row.metadata), + updatedAt: tx.timestamp, + }); + } +); + +export const delete_thread = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const userId = requireUserId(ctx); + const tx = ctx; + const row = requireOwnedThread(tx, threadId, userId); + if (tx.db.threadLock.threadId.find(threadId) != null) { + throwSenderError(`agent.thread_busy:${threadId}`); + } + for (const e of [...tx.db.messageEmbedding.threadId.filter(threadId)]) { + tx.db.messageEmbedding.delete(e); + } + for (const a of [...tx.db.messageAttachment.threadId.filter(threadId)]) { + const blob = tx.db.files.fileBlob.fileId.find(a.fileId); + if (blob) tx.db.files.fileBlob.delete(blob); + const file = tx.db.files.file.id.find(a.fileId); + if (file) tx.db.files.file.delete(file); + tx.db.messageAttachment.delete(a); + } + for (const m of [...tx.db.message.threadId.filter(threadId)]) { + tx.db.message.delete(m); + } + tx.db.thread.delete(row); + } +); + +// Admin-gated; bypasses ownership. +export const clear_thread_lock = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const tx = ctx; + requireAdmin(tx); + const lock = tx.db.threadLock.threadId.find(threadId); + if (lock) tx.db.threadLock.delete(lock); + } +); + +// No-op if the thread already has a title. +export const generate_thread_title = spacetimedb.procedure( + { threadId: t.u64() }, + t.unit(), + (ctx, { threadId }) => { + const userId = requireUserId(ctx); + const job = ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return null; + if (thread.userId !== userId) { + throwSenderError(`agent.not_thread_owner:${threadId}`); + } + if (thread.title != null && thread.title.length > 0) return null; + + const def = registry.agentDef(thread.agentName); + if (!def) return null; + const sumName = def.summarizerAgentName ?? thread.agentName; + const sumDef = registry.agentDef(sumName); + if (!sumDef) return null; + + const override = tx.db.agentOverride.agentName.find(sumName); + const providerName = override?.provider ?? sumDef.defaultProvider; + const provider = BUILT_IN_PROVIDERS[providerName]; + if (!provider) return null; + const keyRow = tx.db.apiKey.provider.find(providerName); + if (!keyRow) return null; + + const msgs = [...tx.db.message.threadId.filter(threadId)]; + msgs.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const firstUser = msgs.find(m => m.role === 'user'); + if (!firstUser) return null; + + return { + provider, + apiKey: keyRow.key, + model: override?.model ?? sumDef.defaultModel, + retries: override?.retries ?? sumDef.defaultRetries, + firstMessage: firstUser.content, + }; + }); + if (!job) return {}; + + const result = callChat(ctx.http, job.provider, { + apiKey: job.apiKey, + model: job.model, + system: + 'You title chat conversations. The user will paste the opening message of ' + + 'a chat. You output a 3-5 word title describing the topic. ' + + 'CRITICAL: do not answer or respond to the message. Do not greet. ' + + 'Output the title and only the title. No quotes, no punctuation at the end.', + messages: [ + { + role: 'user', + content: `Title for a chat that starts with this message:\n\n\n${job.firstMessage}\n`, + }, + ], + maxTokens: 30, + retries: job.retries, + }); + if (!result.ok || !result.response.text) { + console.warn( + `title gen failed: ${result.ok ? 'no text' : result.error.kind}` + ); + return {}; + } + + const cleaned = result.response.text + .trim() + .replace(/^["']|["']$/g, '') + .replace(/[.!?]+$/g, '') + .slice(0, 80); + + ctx.withTx(tx => { + const t2 = tx.db.thread.id.find(threadId); + if (!t2 || (t2.title != null && t2.title.length > 0)) return; + tx.db.thread.id.update({ + ...t2, + title: cleaned, + updatedAt: tx.timestamp, + }); + }); + return {}; + } +); + +export const request_cancel = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const userId = requireUserId(ctx); + const tx = ctx; + requireOwnedThread(tx, threadId, userId); + const lock = tx.db.threadLock.threadId.find(threadId); + if (!lock) throwSenderError(`agent.thread_not_running:${threadId}`); + if (lock.cancelRequested) return; + tx.db.threadLock.threadId.update({ ...lock, cancelRequested: true }); + } +); + +function resolveProvider(name: string): Provider { + const p = BUILT_IN_PROVIDERS[name]; + if (!p) throwSenderError(`agent.unknown_provider:${name}`); + return p; +} + +function loadLoopConfigOrThrow( + tx: WriteCtx, + threadId: bigint, + userId: string +): { cfg: LoopConfig; agentName: string; userId: string } { + const threadRow = requireOwnedThread(tx, threadId, userId); + + const def = registry.agentDef(threadRow.agentName); + if (!def) { + throwSenderError(`agent.unknown:${threadRow.agentName}`); + } + + if (tx.db.threadLock.threadId.find(threadId) != null) { + throwSenderError(`agent.thread_busy:${threadId}`); + } + if (tx.db.agentSecret.singleton.find(true) == null) { + throwSenderError('agent.not_configured'); + } + + checkRateLimit(tx, userId); + + const override = tx.db.agentOverride.agentName.find(threadRow.agentName); + const providerName = override?.provider ?? def.defaultProvider; + const provider = resolveProvider(providerName); + + const keyRow = tx.db.apiKey.provider.find(providerName); + if (!keyRow) throwSenderError(`agent.no_api_key:${providerName}`); + + return { + cfg: { + provider, + apiKey: keyRow.key, + model: threadRow.modelOverride ?? override?.model ?? def.defaultModel, + systemPrompt: + threadRow.systemPromptOverride ?? + override?.systemPrompt ?? + def.defaultSystemPrompt, + maxTurns: override?.maxTurns ?? def.defaultMaxTurns, + maxHistoryMessages: + override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages, + maxTokens: override?.maxTokens ?? def.defaultMaxTokens, + retries: override?.retries ?? def.defaultRetries, + responseFormat: def.defaultResponseFormat, + } satisfies LoopConfig, + agentName: threadRow.agentName, + userId: threadRow.userId, + }; +} + +export const send_message = spacetimedb.procedure( + { + threadId: t.u64(), + content: t.string(), + attachments: t.array( + t.object('SendAttachment', { + mimeType: t.string(), + filename: t.option(t.string()), + bytes: t.array(t.u8()), + }) + ), + }, + t.unit(), + (ctx, args) => { + if (args.content.length === 0 && args.attachments.length === 0) { + throwSenderError('agent.empty_message'); + } + const attachmentError = attachmentValidationError(args.attachments); + if (attachmentError) throwSenderError(attachmentError); + const content = + args.content.length > USER_CONTENT_MAX + ? args.content.slice(0, USER_CONTENT_MAX) + '...[truncated]' + : args.content; + + const callerUserId = requireUserId(ctx); + const { cfg, agentName, userId, userMessageId } = ctx.withTx(tx => { + const loaded = loadLoopConfigOrThrow(tx, args.threadId, callerUserId); + tx.db.threadLock.insert({ + threadId: args.threadId, + userId: loaded.userId, + lockedAt: tx.timestamp, + cancelRequested: false, + }); + const inserted = tx.db.message.insert({ + id: 0n, + threadId: args.threadId, + userId: loaded.userId, + role: 'user', + content, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + createdAt: tx.timestamp, + }); + for (let i = 0; i < args.attachments.length; i++) { + const a = args.attachments[i]; + const path = `/msg/${inserted.id}/${i}`; + const file = tx.db.files.file.insert({ + id: 0n, + ownerPathKey: files.ownerPathKey(loaded.userId, path), + path, + ownerUserId: loaded.userId, + mimeType: a.mimeType, + size: BigInt(a.bytes.length), + sha256Hex: fileSha256Hex(a.bytes), + visibility: FILE_VISIBILITY_OWNER, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + tx.db.files.fileBlob.insert({ fileId: file.id, bytes: a.bytes }); + tx.db.messageAttachment.insert({ + id: 0n, + fileId: file.id, + messageId: inserted.id, + threadId: args.threadId, + ownerUserId: loaded.userId, + ordinal: i, + filename: a.filename, + createdAt: tx.timestamp, + }); + } + const threadRow = tx.db.thread.id.find(args.threadId); + if (threadRow) + tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp }); + return { ...loaded, userMessageId: inserted.id }; + }); + + maybeEmbedMessage(ctx, args.threadId, userMessageId); + runLockedLoop(ctx, cfg, agentName, userId, args.threadId, bumpRateLimit); + return {}; + } +); + +export const regenerate_response = spacetimedb.procedure( + { threadId: t.u64() }, + t.unit(), + (ctx, { threadId }) => { + const callerUserId = requireUserId(ctx); + const { cfg, agentName, userId } = ctx.withTx(tx => { + const loaded = loadLoopConfigOrThrow(tx, threadId, callerUserId); + + const rows = [...tx.db.message.threadId.filter(threadId)]; + rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + let lastUserMsgId: bigint | undefined; + for (const r of rows) { + if (r.role === 'user') lastUserMsgId = r.id; + } + if (lastUserMsgId === undefined) { + throwSenderError(`agent.regenerate_no_user_message:${threadId}`); + } + + for (const r of rows) { + if (r.id > lastUserMsgId!) tx.db.message.delete(r); + } + + tx.db.threadLock.insert({ + threadId, + userId: loaded.userId, + lockedAt: tx.timestamp, + cancelRequested: false, + }); + const threadRow = tx.db.thread.id.find(threadId); + if (threadRow) + tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp }); + return loaded; + }); + + runLockedLoop(ctx, cfg, agentName, userId, threadId, bumpRateLimit); + return {}; + } +); + +export const thread_lock_sweep = spacetimedb.reducer( + { arg: threadLockSweeperTick.rowType }, + (ctx, _arg) => { + const secret = ctx.db.agentSecret.singleton.find(true); + const thresholdSecs = + secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS; + const thresholdMicros = BigInt(thresholdSecs) * ONE_SECOND_MICROS; + + const cutoffMicros = staleLockCutoffMicros( + ctx.timestamp.microsSinceUnixEpoch as bigint, + thresholdMicros + ); + deleteStaleThreadLocks( + ctx.db.threadLock.lockedAt.filter( + new Range(undefined, { + tag: 'excluded', + value: new Timestamp(cutoffMicros), + }) + ), + cutoffMicros, + lock => ctx.db.threadLock.delete(lock) + ); + } +); diff --git a/spacetime-agents-ts/example/spacetimedb/src/loop.ts b/spacetime-agents-ts/example/spacetimedb/src/loop.ts new file mode 100644 index 00000000000..478f41fa598 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/loop.ts @@ -0,0 +1,254 @@ +// Pure agent loop. No STDB imports; tests inject fakes. + +import { + callChat, + type ChatMessage, + type ContentBlock, + type ToolCall, + type HttpLike, + type ToolDefinition, + type ResponseFormat, + type Provider, +} from '@spacetimedb/agents/openrouter'; +import type { InvokeResult } from '@spacetimedb/agents/kit'; + +export const USER_CONTENT_MAX = 32_000; +export const TOOL_RESULT_MAX = 64_000; + +export interface LoopConfig { + provider: Provider; + apiKey: string; + model: string; + systemPrompt: string | undefined; + maxTurns: number; + maxHistoryMessages: number; + maxTokens: number | undefined; + retries: number; + responseFormat: ResponseFormat | undefined; +} + +export interface LoopAttachment { + mimeType: string; + data: string; // base64 (provider HTTP APIs want strings) +} + +export interface LoopMessage { + id: bigint; + threadId: bigint; + role: string; + content: string; + toolCallsJson: string | undefined; + toolCallId: string | undefined; + isError: boolean; + promptTokens: number | undefined; + completionTokens: number | undefined; + attachments: LoopAttachment[]; +} + +// Only user messages carry attachments; the loop itself never appends them. +export type AppendMessageRow = Omit; + +export interface LoopTx { + listMessages(threadId: bigint): LoopMessage[]; + appendMessage(row: AppendMessageRow): void; + bumpThread(threadId: bigint): void; + invokeTool(name: string, inputJson: string): InvokeResult; + isCancelRequested(threadId: bigint): boolean; +} + +export type WithTx = (fn: (tx: LoopTx) => R) => R; + +export interface RunAgentLoopOpts { + http: HttpLike; + withTx: WithTx; + llmToolDefs: ToolDefinition[]; + cfg: LoopConfig; + threadId: bigint; +} + +function runOneTurn(opts: RunAgentLoopOpts): boolean { + const { http, withTx, llmToolDefs, cfg, threadId } = opts; + + const cancelled = withTx(tx => { + if (tx.isCancelRequested(threadId)) { + tx.appendMessage({ + threadId, + role: 'assistant', + content: 'agent.cancelled', + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }); + tx.bumpThread(threadId); + return true; + } + return false; + }); + if (cancelled) return false; + + const llmMessages = withTx(tx => + buildLlmMessages(tx, threadId, cfg.maxHistoryMessages) + ); + + const result = callChat(http, cfg.provider, { + apiKey: cfg.apiKey, + model: cfg.model, + system: cfg.systemPrompt, + messages: llmMessages, + tools: llmToolDefs, + maxTokens: cfg.maxTokens, + responseFormat: cfg.responseFormat, + retries: cfg.retries, + }); + + if (!result.ok) { + withTx(tx => + tx.appendMessage({ + threadId, + role: 'assistant', + content: formatChatError(result.error), + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }) + ); + return false; + } + + const { text, toolCalls, finishReason, usage } = result.response; + const hasToolCalls = toolCalls.length > 0; + + withTx(tx => { + tx.appendMessage({ + threadId, + role: 'assistant', + content: text ?? '', + toolCallsJson: hasToolCalls ? JSON.stringify(toolCalls) : undefined, + toolCallId: undefined, + isError: false, + promptTokens: usage.promptTokens > 0 ? usage.promptTokens : undefined, + completionTokens: + usage.completionTokens > 0 ? usage.completionTokens : undefined, + }); + + if (hasToolCalls) { + for (const call of toolCalls) { + const inv = tx.invokeTool(call.function.name, call.function.arguments); + tx.appendMessage({ + threadId, + role: 'tool', + content: clip(inv.result, TOOL_RESULT_MAX), + toolCallsJson: undefined, + toolCallId: call.id, + isError: inv.isError, + promptTokens: undefined, + completionTokens: undefined, + }); + } + } + + tx.bumpThread(threadId); + }); + + return hasToolCalls && finishReason === 'tool_calls'; +} + +export function runAgentLoop(opts: RunAgentLoopOpts): void { + for (let turn = 0; turn < opts.cfg.maxTurns; turn++) { + if (!runOneTurn(opts)) return; + } + opts.withTx(tx => + tx.appendMessage({ + threadId: opts.threadId, + role: 'assistant', + content: `agent.max_turns_exceeded:${opts.cfg.maxTurns}`, + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }) + ); +} + +// Drops orphan tool rows whose assistant tool_call fell outside the window. +export function buildLlmMessages( + tx: LoopTx, + threadId: bigint, + maxHistoryMessages: number +): ChatMessage[] { + const all = tx.listMessages(threadId); + const window = + maxHistoryMessages > 0 && all.length > maxHistoryMessages + ? all.slice(all.length - maxHistoryMessages) + : all; + + const out: ChatMessage[] = []; + const knownToolCallIds = new Set(); + + for (const row of window) { + if (row.role === 'user') { + out.push({ role: 'user', content: userContent(row) }); + } else if (row.role === 'assistant') { + let toolCalls: ToolCall[] | undefined; + if (row.toolCallsJson != null) { + try { + toolCalls = JSON.parse(row.toolCallsJson) as ToolCall[]; + } catch { + toolCalls = undefined; + } + } + const msg: ChatMessage = { role: 'assistant', content: row.content }; + if (toolCalls && toolCalls.length > 0) { + msg.tool_calls = toolCalls; + for (const c of toolCalls) knownToolCallIds.add(c.id); + } + out.push(msg); + } else if (row.role === 'tool') { + const tcid = row.toolCallId ?? ''; + if (!knownToolCallIds.has(tcid)) continue; + out.push({ role: 'tool', tool_call_id: tcid, content: row.content }); + } + } + return out; +} + +function userContent(row: LoopMessage): string | ContentBlock[] { + if (row.attachments.length === 0) return row.content; + const blocks: ContentBlock[] = []; + if (row.content) blocks.push({ type: 'text', text: row.content }); + for (const a of row.attachments) { + blocks.push({ type: 'image', mimeType: a.mimeType, data: a.data }); + } + return blocks; +} + +export function formatChatError(err: { + kind: string; + status?: number; + message?: string; + body?: string; +}): string { + switch (err.kind) { + case 'http': + return `agent.provider_http:${err.status}:${truncate(err.body ?? '', 500)}`; + case 'transport': + return `agent.provider_transport:${err.message ?? 'unknown'}`; + case 'parse': + return `agent.provider_parse:${err.message ?? 'unknown'}`; + default: + return `agent.provider_error:${err.kind}`; + } +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : s.slice(0, max) + '…'; +} + +function clip(s: string, max: number): string { + return s.length <= max ? s : s.slice(0, max) + '…[truncated]'; +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/model.ts b/spacetime-agents-ts/example/spacetimedb/src/model.ts new file mode 100644 index 00000000000..9fb469cff37 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/model.ts @@ -0,0 +1,132 @@ +import { table, t } from 'spacetimedb/server'; + +export const apiKey = table( + { name: 'api_key', public: false }, + { + provider: t.string().primaryKey(), + key: t.string(), + updatedAt: t.timestamp(), + } +); + +// rateLimit fields are paired: both set or both null. +export const agentSecret = table( + { name: 'agent_secret', public: false }, + { + singleton: t.bool().primaryKey(), + staleLockThresholdSecs: t.u32(), + rateLimitTokensPerWindow: t.option(t.u32()), + rateLimitWindowSecs: t.option(t.u32()), + updatedAt: t.timestamp(), + } +); + +export const agentAdminIdentity = table( + { name: 'agent_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +export const agentOverride = table( + { name: 'agent_override', public: true }, + { + agentName: t.string().primaryKey(), + provider: t.option(t.string()), + model: t.option(t.string()), + systemPrompt: t.option(t.string()), + maxTurns: t.option(t.u32()), + maxHistoryMessages: t.option(t.u32()), + maxTokens: t.option(t.u32()), + retries: t.option(t.u32()), + updatedAt: t.timestamp(), + } +); + +export const thread = table( + { name: 'thread', public: false }, + { + id: t.u64().primaryKey().autoInc(), + userId: t.string().index(), + agentName: t.string().index(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + modelOverride: t.option(t.string()), + metadata: t.option(t.string()), + summary: t.option(t.string()), + summarizedThroughId: t.option(t.u64()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +// userId denormalized from thread for the visibility filter. +export const message = table( + { name: 'message', public: false }, + { + id: t.u64().primaryKey().autoInc(), + threadId: t.u64().index(), + userId: t.string().index(), + role: t.string(), + content: t.string(), + toolCallsJson: t.option(t.string()), + toolCallId: t.option(t.string()), + isError: t.bool(), + promptTokens: t.option(t.u32()), + completionTokens: t.option(t.u32()), + createdAt: t.timestamp(), + } +); +export const threadLock = table( + { name: 'thread_lock', public: false }, + { + threadId: t.u64().primaryKey(), + userId: t.string().index(), + lockedAt: t.timestamp().index('btree'), + cancelRequested: t.bool(), + } +); + +export const messageAttachment = table( + { name: 'message_attachment', public: false }, + { + id: t.u64().primaryKey().autoInc(), + fileId: t.u64().index(), + messageId: t.u64().index(), + threadId: t.u64().index(), + ownerUserId: t.string().index(), + ordinal: t.u32(), + filename: t.option(t.string()), + createdAt: t.timestamp(), + } +); + +export const fileViewRow = t.object('File', { + id: t.u64(), + fileId: t.u64(), + path: t.string(), + ownerUserId: t.string(), + mimeType: t.string(), + size: t.u64(), + sha256Hex: t.string(), + visibility: t.string(), + filename: t.option(t.string()), + messageId: t.option(t.u64()), + threadId: t.option(t.u64()), + ordinal: t.u32(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +export const messageEmbedding = table( + { name: 'message_embedding', public: false }, + { + messageId: t.u64().primaryKey(), + threadId: t.u64().index(), + userId: t.string().index(), + model: t.string(), + vector: t.array(t.f32()), + createdAt: t.timestamp(), + } +); diff --git a/spacetime-agents-ts/example/spacetimedb/src/runtime.ts b/spacetime-agents-ts/example/spacetimedb/src/runtime.ts new file mode 100644 index 00000000000..61e0ffcc377 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/runtime.ts @@ -0,0 +1,384 @@ +import { makeAgentRegistry } from '@spacetimedb/agents/kit'; +import { + callChat, + type ChatMessage, + type HttpLike, +} from '@spacetimedb/agents/openrouter'; +import { BUILT_IN_PROVIDERS } from '@spacetimedb/agents/providers'; +import { + BUILT_IN_EMBEDDING_PROVIDERS, + cosineSimilarity, + topKByScore, +} from '@spacetimedb/agents/embeddings'; +import { agents } from './agents'; +import { + runAgentLoop, + type LoopConfig, + type LoopMessage, + type LoopTx, +} from './loop'; +import { + augmentSystemWithSummary, + buildSummarizerUserContent, + pickSummarizationCandidates, +} from './summarize'; +import type { Tx } from './types'; + +type WriteCtx = Tx; + +export const registry = makeAgentRegistry(agents); + +export interface ProcedureRuntimeContext { + http: HttpLike; + withTx: (fn: (tx: WriteCtx) => R) => R; +} + +function threadMessagesAscending(tx: WriteCtx, threadId: bigint) { + const rows = [...tx.db.message.threadId.filter(threadId)]; + rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return rows; +} + +export function maybeEmbedMessage( + ctx: ProcedureRuntimeContext, + threadId: bigint, + messageId: bigint +): void { + const job = ctx.withTx(tx => { + if (tx.db.messageEmbedding.messageId.find(messageId) != null) return null; + const message = tx.db.message.id.find(messageId); + if (!message) return null; + const thread = tx.db.thread.id.find(threadId); + if (!thread) return null; + const definition = registry.agentDef(thread.agentName); + if (!definition?.embeddingsProvider || !definition.embeddingsModel) + return null; + const provider = + BUILT_IN_EMBEDDING_PROVIDERS[definition.embeddingsProvider]; + if (!provider) return null; + const key = tx.db.apiKey.provider.find(definition.embeddingsProvider); + if (!key) return null; + return { + provider, + apiKey: key.key, + model: definition.embeddingsModel, + content: message.content, + userId: message.userId, + }; + }); + if (!job) return; + + const result = job.provider.embed(ctx.http, job.apiKey, job.model, [ + job.content, + ]); + if (!result.ok || result.vectors.length === 0) { + console.warn( + `embedding failed: ${result.ok ? 'no vectors' : result.error.kind}` + ); + return; + } + ctx.withTx(tx => { + if (tx.db.messageEmbedding.messageId.find(messageId) != null) return; + tx.db.messageEmbedding.insert({ + messageId, + threadId, + userId: job.userId, + model: job.model, + vector: result.vectors[0]!, + createdAt: tx.timestamp, + }); + }); +} + +function retrieveRag(ctx: ProcedureRuntimeContext, threadId: bigint): string[] { + return ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return []; + const definition = registry.agentDef(thread.agentName); + if (!definition || definition.ragTopK <= 0) return []; + + const messages = threadMessagesAscending(tx, threadId); + let queryMessage: (typeof messages)[number] | undefined; + for (let index = messages.length - 1; index >= 0; index--) { + if (messages[index]!.role === 'user') { + queryMessage = messages[index]; + break; + } + } + if (!queryMessage) return []; + const queryEmbedding = tx.db.messageEmbedding.messageId.find( + queryMessage.id + ); + if (!queryEmbedding) return []; + + const override = tx.db.agentOverride.agentName.find(thread.agentName); + const maxHistory = + override?.maxHistoryMessages ?? definition.defaultMaxHistoryMessages; + const windowStart = Math.max(0, messages.length - maxHistory); + const inWindowIds = new Set( + messages.slice(windowStart).map(message => message.id) + ); + const candidates = [ + ...tx.db.messageEmbedding.threadId.filter(threadId), + ].filter( + embedding => + !inWindowIds.has(embedding.messageId) && + embedding.messageId !== queryMessage!.id + ); + const top = topKByScore( + candidates, + embedding => cosineSimilarity(queryEmbedding.vector, embedding.vector), + definition.ragTopK + ).filter(result => result.score > 0); + + const snippets: string[] = []; + for (const { item } of top) { + const message = tx.db.message.id.find(item.messageId); + if (message) snippets.push(`[${message.role}] ${message.content}`); + } + return snippets; + }); +} + +function augmentSystemWithRag( + base: string | undefined, + snippets: string[] +): string | undefined { + if (snippets.length === 0) return base; + return `${base ?? ''}\n\n## Relevant earlier messages\n${snippets.join('\n---\n')}`.trim(); +} + +function runSummarization( + ctx: ProcedureRuntimeContext, + threadId: bigint +): void { + const decision = ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return null; + const definition = registry.agentDef(thread.agentName); + if (!definition?.summarizerAgentName) return null; + const summarizer = registry.agentDef(definition.summarizerAgentName); + if (!summarizer) return null; + + const override = tx.db.agentOverride.agentName.find(thread.agentName); + const maxHistory = + override?.maxHistoryMessages ?? definition.defaultMaxHistoryMessages; + const loopMessages: LoopMessage[] = threadMessagesAscending( + tx, + threadId + ).map(message => ({ + id: message.id, + threadId: message.threadId, + role: message.role, + content: message.content, + toolCallsJson: message.toolCallsJson, + toolCallId: message.toolCallId, + isError: message.isError, + promptTokens: message.promptTokens, + completionTokens: message.completionTokens, + attachments: [], + })); + const candidates = pickSummarizationCandidates( + loopMessages, + maxHistory, + thread.summarizedThroughId ?? null + ); + if (!candidates) return null; + + const summarizerOverride = tx.db.agentOverride.agentName.find( + definition.summarizerAgentName + ); + const providerName = + summarizerOverride?.provider ?? summarizer.defaultProvider; + const provider = BUILT_IN_PROVIDERS[providerName]; + const key = tx.db.apiKey.provider.find(providerName); + if (!provider || !key) return null; + return { + provider, + apiKey: key.key, + model: summarizerOverride?.model ?? summarizer.defaultModel, + systemPrompt: + summarizerOverride?.systemPrompt ?? summarizer.defaultSystemPrompt, + maxTokens: summarizerOverride?.maxTokens ?? summarizer.defaultMaxTokens, + retries: summarizerOverride?.retries ?? summarizer.defaultRetries, + existingSummary: thread.summary ?? null, + newDropped: candidates.newDropped, + lastNewId: candidates.lastNewId, + }; + }); + if (!decision) return; + + const messages: ChatMessage[] = [ + { + role: 'user', + content: buildSummarizerUserContent( + decision.existingSummary, + decision.newDropped + ), + }, + ]; + const result = callChat(ctx.http, decision.provider, { + apiKey: decision.apiKey, + model: decision.model, + system: decision.systemPrompt, + messages, + maxTokens: decision.maxTokens, + retries: decision.retries, + }); + if (!result.ok || !result.response.text) { + console.warn( + `summarization failed: ${result.ok ? 'no text in response' : result.error.kind}` + ); + return; + } + + ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return; + tx.db.thread.id.update({ + ...thread, + summary: result.response.text!, + summarizedThroughId: decision.lastNewId, + updatedAt: tx.timestamp, + }); + }); +} + +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +function bytesToBase64(bytes: ArrayLike): string { + let output = ''; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index]!; + const second = index + 1 < bytes.length ? bytes[index + 1]! : 0; + const third = index + 2 < bytes.length ? bytes[index + 2]! : 0; + output += BASE64_ALPHABET[first >> 2]; + output += BASE64_ALPHABET[((first & 0x03) << 4) | (second >> 4)]; + output += + index + 1 < bytes.length + ? BASE64_ALPHABET[((second & 0x0f) << 2) | (third >> 6)] + : '='; + output += index + 2 < bytes.length ? BASE64_ALPHABET[third & 0x3f] : '='; + } + return output; +} + +function loadAttachments( + tx: WriteCtx, + messageId: bigint +): Array<{ mimeType: string; data: string }> { + const rows = [...tx.db.messageAttachment.messageId.filter(messageId)]; + rows.sort((a, b) => a.ordinal - b.ordinal); + const attachments: Array<{ mimeType: string; data: string }> = []; + for (const row of rows) { + const file = tx.db.files.file.id.find(row.fileId); + const blob = tx.db.files.fileBlob.fileId.find(row.fileId); + if (file && blob) { + attachments.push({ + mimeType: file.mimeType, + data: bytesToBase64(blob.bytes), + }); + } + } + return attachments; +} + +function adaptTx( + tx: WriteCtx, + agentName: string, + userId: string, + recordTokens: (tx: WriteCtx, userId: string, tokens: bigint) => void +): LoopTx { + return { + listMessages(threadId): LoopMessage[] { + return threadMessagesAscending(tx, threadId).map(message => ({ + id: message.id, + threadId: message.threadId, + role: message.role, + content: message.content, + toolCallsJson: message.toolCallsJson, + toolCallId: message.toolCallId, + isError: message.isError, + promptTokens: message.promptTokens, + completionTokens: message.completionTokens, + attachments: + message.role === 'user' ? loadAttachments(tx, message.id) : [], + })); + }, + appendMessage(row): void { + tx.db.message.insert({ + id: 0n, + threadId: row.threadId, + userId, + role: row.role, + content: row.content, + toolCallsJson: row.toolCallsJson, + toolCallId: row.toolCallId, + isError: row.isError, + promptTokens: row.promptTokens, + completionTokens: row.completionTokens, + createdAt: tx.timestamp, + }); + if ( + row.role === 'assistant' && + (row.promptTokens != null || row.completionTokens != null) + ) { + const tokens = BigInt( + (row.promptTokens ?? 0) + (row.completionTokens ?? 0) + ); + if (tokens > 0n) recordTokens(tx, userId, tokens); + } + }, + bumpThread(threadId): void { + const thread = tx.db.thread.id.find(threadId); + if (thread) + tx.db.thread.id.update({ ...thread, updatedAt: tx.timestamp }); + }, + invokeTool(name, inputJson) { + return registry.invoke(agentName, tx, name, inputJson); + }, + isCancelRequested(threadId): boolean { + return tx.db.threadLock.threadId.find(threadId)?.cancelRequested ?? false; + }, + }; +} + +export function runLockedLoop( + ctx: ProcedureRuntimeContext, + cfg: LoopConfig, + agentName: string, + userId: string, + threadId: bigint, + recordTokens: (tx: WriteCtx, userId: string, tokens: bigint) => void +): void { + try { + runSummarization(ctx, threadId); + const ragSnippets = retrieveRag(ctx, threadId); + const finalConfig = ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return cfg; + const withSummary = augmentSystemWithSummary( + cfg.systemPrompt, + thread.summary ?? null + ); + return { + ...cfg, + systemPrompt: augmentSystemWithRag(withSummary, ragSnippets), + }; + }); + runAgentLoop({ + http: ctx.http, + withTx: (fn: (loopTx: LoopTx) => R): R => + ctx.withTx(tx => fn(adaptTx(tx, agentName, userId, recordTokens))), + llmToolDefs: registry.llmToolDefsFor(agentName), + cfg: finalConfig, + threadId, + }); + } finally { + ctx.withTx(tx => { + const lock = tx.db.threadLock.threadId.find(threadId); + if (lock) tx.db.threadLock.delete(lock); + }); + } +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/summarize.ts b/spacetime-agents-ts/example/spacetimedb/src/summarize.ts new file mode 100644 index 00000000000..338d005d2c4 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/summarize.ts @@ -0,0 +1,76 @@ +// Auto-summarization helpers. HTTP call lives in index.ts. + +import type { LoopMessage } from './loop'; + +// Returns null if nothing new to summarize. +export function pickSummarizationCandidates( + messages: LoopMessage[], // ascending by id + maxHistoryMessages: number, + summarizedThroughId: bigint | null +): { newDropped: LoopMessage[]; lastNewId: bigint } | null { + if (messages.length <= maxHistoryMessages) return null; + + const dropCount = messages.length - maxHistoryMessages; + const dropped = messages.slice(0, dropCount); + + const newDropped = + summarizedThroughId == null + ? dropped + : dropped.filter(m => m.id > summarizedThroughId); + + if (newDropped.length === 0) return null; + return { newDropped, lastNewId: newDropped[newDropped.length - 1].id }; +} + +export function formatMessagesForSummarizer(messages: LoopMessage[]): string { + const lines: string[] = []; + for (const m of messages) { + if (m.role === 'user') { + lines.push(`User: ${m.content}`); + } else if (m.role === 'assistant') { + if (m.toolCallsJson != null) { + try { + const calls = JSON.parse(m.toolCallsJson) as Array<{ + function?: { name?: string; arguments?: string }; + }>; + for (const c of calls) { + const name = c.function?.name ?? '?'; + const args = c.function?.arguments ?? ''; + lines.push(`[Assistant called tool ${name}(${args})]`); + } + } catch { + /* malformed */ + } + if (m.content) lines.push(`Assistant: ${m.content}`); + } else { + lines.push(`Assistant: ${m.content}`); + } + } else if (m.role === 'tool') { + lines.push(`[Tool result: ${m.content}]`); + } + } + return lines.join('\n'); +} + +export function buildSummarizerUserContent( + existingSummary: string | null, + newDropped: LoopMessage[] +): string { + const formatted = formatMessagesForSummarizer(newDropped); + if (existingSummary) { + return ( + `Existing summary:\n${existingSummary}\n\n` + + `Additional messages to fold into the summary:\n${formatted}` + ); + } + return `Messages to summarize:\n${formatted}`; +} + +export function augmentSystemWithSummary( + baseSystem: string | undefined, + summary: string | null +): string | undefined { + if (summary == null || summary.length === 0) return baseSystem; + const base = baseSystem ?? ''; + return `${base}\n\n## Summary of earlier conversation\n${summary}`.trim(); +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts b/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts new file mode 100644 index 00000000000..694168ce918 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts @@ -0,0 +1,14 @@ +// Pure; isolated from spacetimedb/server so tsx tests can import it. + +const ONE_SECOND_MICROS = 1_000_000n; +const ONE_MINUTE_MICROS = 60n * ONE_SECOND_MICROS; + +export const SWEEPER_INTERVAL_MICROS = ONE_MINUTE_MICROS; + +export function isStaleLock( + nowMicros: bigint, + lockedAtMicros: bigint, + thresholdMicros: bigint +): boolean { + return lockedAtMicros < nowMicros - thresholdMicros; +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts new file mode 100644 index 00000000000..79f54e573ad --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts @@ -0,0 +1,10 @@ +// Demo tool: echoes the given message back. + +import { t } from 'spacetimedb/server'; +import { agentTool } from '@spacetimedb/agents/kit'; + +export default agentTool( + 'echoes the given message back to the caller', + t.object('EchoArgs', { message: t.string() }), + (_ctx, args) => `echo: ${args.message}` +); diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts new file mode 100644 index 00000000000..d52b8a98880 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts @@ -0,0 +1,16 @@ +// Demo tool: returns the current server time. ctx cast to Tx avoids a +// circular type reference between the kit and the schema-derived Tx. + +import { t } from 'spacetimedb/server'; +import { agentTool } from '@spacetimedb/agents/kit'; +import type { Tx } from '../types'; + +export default agentTool( + 'returns the current server time as an ISO-8601 string', + t.unit(), + ctx => { + const tx = ctx as Tx; + const micros = tx.timestamp.microsSinceUnixEpoch as bigint; + return new Date(Number(micros / 1000n)).toISOString(); + } +); diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts new file mode 100644 index 00000000000..19ea082273f --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts @@ -0,0 +1,2 @@ +// Tool dir barrel. Agents import specific tools from ../agents/*. +export {}; diff --git a/spacetime-agents-ts/example/spacetimedb/src/types.ts b/spacetime-agents-ts/example/spacetimedb/src/types.ts new file mode 100644 index 00000000000..3a9586279b3 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/types.ts @@ -0,0 +1,6 @@ +import type { ReducerCtx, InferSchema } from 'spacetimedb/server'; +import type spacetimedb from './index'; + +export type Schema = InferSchema; +export type Tx = ReducerCtx; +export type Db = Tx['db']; diff --git a/spacetime-agents-ts/example/spacetimedb/src/views.ts b/spacetime-agents-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..4ade0f00d49 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,115 @@ +import { t, type InferSchema, type ViewCtx } from 'spacetimedb/server'; +import { + fileViewRow, + message, + messageEmbedding, + thread, + threadLock, +} from './model'; + +const authUserViewRow = t.object('AgentAuthUser', { + userId: t.string(), + email: t.string(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +export function registerAgentViews( + spacetimedb: typeof import('./index').default +) { + type Schema = InferSchema; + const callerUserId = (ctx: ViewCtx) => + ctx.db.auth.authConnectionBinding.stdbIdentity.find(ctx.sender)?.userId; + + const myThreads = spacetimedb.view( + { name: 'my_threads', public: true }, + t.array(thread.rowType), + ctx => { + const userId = callerUserId(ctx); + return userId ? [...ctx.db.thread.userId.filter(userId)] : []; + } + ); + + const myMessages = spacetimedb.view( + { name: 'my_messages', public: true }, + t.array(message.rowType), + ctx => { + const userId = callerUserId(ctx); + return userId ? [...ctx.db.message.userId.filter(userId)] : []; + } + ); + + const myThreadLocks = spacetimedb.view( + { name: 'my_thread_locks', public: true }, + t.array(threadLock.rowType), + ctx => { + const userId = callerUserId(ctx); + return userId ? [...ctx.db.threadLock.userId.filter(userId)] : []; + } + ); + + const myMessageEmbeddings = spacetimedb.view( + { name: 'my_message_embeddings', public: true }, + t.array(messageEmbedding.rowType), + ctx => { + const userId = callerUserId(ctx); + return userId ? [...ctx.db.messageEmbedding.userId.filter(userId)] : []; + } + ); + + const myFiles = spacetimedb.view( + { name: 'my_files', public: true }, + t.array(fileViewRow), + ctx => { + const userId = callerUserId(ctx); + if (!userId) return []; + const rows = []; + for (const attachment of ctx.db.messageAttachment.ownerUserId.filter( + userId + )) { + const file = ctx.db.files.file.id.find(attachment.fileId); + if (!file) continue; + rows.push({ + id: attachment.id, + fileId: attachment.fileId, + path: file.path, + ownerUserId: attachment.ownerUserId, + mimeType: file.mimeType, + size: file.size, + sha256Hex: file.sha256Hex, + visibility: file.visibility, + filename: attachment.filename, + messageId: attachment.messageId, + threadId: attachment.threadId, + ordinal: attachment.ordinal, + createdAt: attachment.createdAt, + updatedAt: file.updatedAt, + }); + } + return rows; + } + ); + + const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUserViewRow), + ctx => { + const userId = callerUserId(ctx); + if (!userId) return []; + const row = ctx.db.auth.authUser.userId.find(userId); + return row ? [row] : []; + } + ); + + return { + myThreads, + myMessages, + myThreadLocks, + myMessageEmbeddings, + myFiles, + myAuthUser, + }; +} diff --git a/spacetime-agents-ts/example/spacetimedb/tsconfig.json b/spacetime-agents-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..c18065b7cb8 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-agents-ts/example/src/app.ts b/spacetime-agents-ts/example/src/app.ts new file mode 100644 index 00000000000..c76bd4efb5c --- /dev/null +++ b/spacetime-agents-ts/example/src/app.ts @@ -0,0 +1,591 @@ +// STDB connection + chat ops + auth. Exposes window.auth and window.stdb. +import { + DbConnection, + type ErrorContext, + type EventContext, + type SubscriptionHandle, +} from './codegen/app'; +import type { File as FileRow, AgentConfigStatus } from './codegen/app/types'; + +interface AuthUser { + userId: string; + email: string; + emailVerified: boolean; + name?: string; + image?: string; +} +interface AuthMe { + user: AuthUser; + sessionExpiresAt: number; +} +interface AuthUserRow extends AuthUser { + createdAt: unknown; + updatedAt: unknown; +} + +declare global { + interface Window { + auth?: { + signup: (args: { + email: string; + password: string; + name?: string; + }) => Promise; + login: (args: { email: string; password: string }) => Promise; + logout: () => Promise; + oauthStart: (provider: 'google' | 'github') => void; + forgotPassword: (email: string) => Promise; + resetPassword: (token: string, newPassword: string) => Promise; + requestEmailVerify: () => Promise; + listMySessions: () => Promise<{ sessions: unknown[] }>; + revokeMySession: (sessionId: string) => Promise; + setProfile: (args: { name?: string; image?: string }) => void; + }; + stdb?: { + setAgentSecret: (args: { + staleLockThresholdSecs: number | undefined; + rateLimitTokensPerWindow: number | undefined; + rateLimitWindowSecs: number | undefined; + }) => Promise; + setApiKey: (provider: string, key: string) => Promise; + clearApiKey: (provider: string) => Promise; + setAgentOverride: (args: { + agentName: string; + provider: string | undefined; + model: string | undefined; + systemPrompt: string | undefined; + maxTurns: number | undefined; + maxHistoryMessages: number | undefined; + maxTokens: number | undefined; + retries: number | undefined; + }) => Promise; + clearAgentOverride: (agentName: string) => Promise; + getAgentConfigStatus: () => Promise; + setActiveThread: (threadId: bigint | null) => void; + startThread: (args: { + agentName: string; + title: string | undefined; + systemPromptOverride: string | undefined; + metadata: string | undefined; + }) => Promise; + updateThread: (args: { + threadId: bigint; + title: string | undefined; + systemPromptOverride: string | undefined; + modelOverride: string | undefined; + metadata: string | undefined; + clearTitle: boolean; + clearSystemPromptOverride: boolean; + clearModelOverride: boolean; + clearMetadata: boolean; + }) => Promise; + deleteThread: (threadId: bigint) => Promise; + sendMessage: ( + threadId: bigint, + content: string, + attachments?: Array<{ + mimeType: string; + filename: string | undefined; + bytes: Uint8Array; + }> + ) => Promise; + regenerateResponse: (threadId: bigint) => Promise; + requestCancel: (threadId: bigint) => Promise; + generateThreadTitle: (threadId: bigint) => Promise; + clearThreadLock: (threadId: bigint) => Promise; + }; + } +} + +type ConfigState = + | { kind: 'unknown' } + | { kind: 'unconfigured' } + | { kind: 'configured'; status: AgentConfigStatus }; +type ConnState = 'idle' | 'connecting' | 'connected' | 'error'; + +let configState: ConfigState = { kind: 'unknown' }; + +let currentConn: DbConnection | null = null; +let globalSub: SubscriptionHandle | null = null; +let messageSub: SubscriptionHandle | null = null; +let activeThreadId: bigint | null = null; +let serverCfg: { stdbUri: string; appDatabase: string } | null = null; + +let currentUser: AuthUser | null = null; +let currentExp: number | undefined; + +let reconnectAttempt = 0; +let reconnectTimer: ReturnType | null = null; +const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; + +function dispatch(name: string, detail: unknown): void { + window.dispatchEvent(new CustomEvent(name, { detail })); +} +function broadcastThreads(): void { + if (!currentConn) { + dispatch('stdb:threads', { threads: [] }); + return; + } + const sorted = [...currentConn.db.myThreads.iter()].sort((a, b) => { + const av = a.updatedAt.microsSinceUnixEpoch as bigint; + const bv = b.updatedAt.microsSinceUnixEpoch as bigint; + return av < bv ? 1 : av > bv ? -1 : 0; + }); + dispatch('stdb:threads', { threads: sorted }); +} +function broadcastMessages(): void { + if (!currentConn) { + dispatch('stdb:messages', { messages: [], attachments: {} }); + return; + } + const sorted = [...currentConn.db.myMessages.iter()].sort((a, b) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + ); + const atts: Record = {}; + for (const f of currentConn.db.myFiles.iter()) { + if (f.messageId === undefined) continue; + const key = f.messageId.toString(); + (atts[key] ??= []).push(f); + } + dispatch('stdb:messages', { messages: sorted, attachments: atts }); +} +function broadcastLocks(): void { + if (!currentConn) { + dispatch('stdb:locks', { locks: [] }); + return; + } + const entries: Array<[bigint, boolean]> = []; + for (const l of currentConn.db.myThreadLocks.iter()) + entries.push([l.threadId, l.cancelRequested]); + dispatch('stdb:locks', { locks: entries }); +} +function broadcastOverrides(): void { + if (!currentConn) { + dispatch('stdb:overrides', { overrides: [] }); + return; + } + dispatch('stdb:overrides', { + overrides: [...currentConn.db.agentOverride.iter()], + }); +} +function broadcastConfig(): void { + dispatch('stdb:config', { state: configState }); +} +function broadcastConn(state: ConnState, detail?: string): void { + dispatch('stdb:connState', { state, detail }); +} +function broadcastAuth(): void { + dispatch('auth:state', { user: currentUser, sessionExpiresAt: currentExp }); +} + +function syncUserFromRow(row: AuthUserRow): void { + if (!currentUser || row.userId !== currentUser.userId) return; + currentUser = { + userId: row.userId, + email: row.email, + emailVerified: row.emailVerified, + name: row.name ?? undefined, + image: row.image ?? undefined, + }; + broadcastAuth(); +} + +function requireConn(): DbConnection { + if (!currentConn) throw new Error('STDB not connected'); + return currentConn; +} + +// Authentication requests proxied to SpacetimeDB by the Express server +async function callJson(path: string, body?: unknown): Promise { + const r = await fetch(path, { + method: body !== undefined ? 'POST' : 'GET', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'same-origin', + }); + let data: unknown = null; + try { + data = await r.json(); + } catch { + /* empty body */ + } + if (!r.ok) { + const err = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${r.status}`; + throw new Error(err); + } + return data as T; +} + +async function loadServerConfig(): Promise<{ + stdbUri: string; + appDatabase: string; +}> { + const res = await fetch('/api/config', { credentials: 'same-origin' }); + if (!res.ok) throw new Error(`/api/config returned ${res.status}`); + return res.json(); +} + +// Persist the STDB identity token so refresh reuses the same identity. +const STDB_TOKEN_KEY = 'agents:stdb_token'; +function loadStdbToken(): string | undefined { + try { + return localStorage.getItem(STDB_TOKEN_KEY) ?? undefined; + } catch { + return undefined; + } +} +function saveStdbToken(token: string): void { + try { + localStorage.setItem(STDB_TOKEN_KEY, token); + } catch { + /* Storage can be unavailable. */ + } +} + +function buildConnection(uri: string, db: string): Promise { + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(uri) + .withDatabaseName(db) + .withToken(loadStdbToken()) + .onConnect((c, _identity, token) => { + if (token) saveStdbToken(token); + resolve(c); + }) + .onDisconnect((_ctx, err) => { + broadcastConn('error', err?.message ?? 'disconnected'); + currentConn = null; + globalSub = null; + messageSub = null; + if (currentUser) scheduleReconnect(); + }) + .onConnectError((_ctx, err) => { + broadcastConn('error', err?.message ?? 'connect failed'); + reject(err); + }) + .build(); + }); +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + const delay = + RECONNECT_DELAYS_MS[ + Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1) + ]; + console.warn( + `STDB reconnect in ${delay}ms (attempt ${reconnectAttempt + 1})` + ); + reconnectTimer = setTimeout(async () => { + reconnectTimer = null; + reconnectAttempt++; + if (!currentUser) return; + try { + const r = await callJson<{ + user: AuthUser; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + reconnectAttempt = 0; + } catch (err) { + console.error('Reconnect failed:', err); + scheduleReconnect(); + } + }, delay); +} + +function setActiveThread(threadId: bigint | null): void { + if (activeThreadId === threadId) return; + activeThreadId = threadId; + + if (messageSub) { + messageSub.unsubscribe(); + messageSub = null; + } + broadcastMessages(); + + if (threadId === null || !currentConn) return; + + messageSub = currentConn + .subscriptionBuilder() + .onApplied(() => broadcastMessages()) + .onError((ctx: ErrorContext) => + console.error('message sub error', ctx.event) + ) + .subscribe([`SELECT * FROM my_messages WHERE thread_id = ${threadId}`]); +} + +function wireRowHandlers(conn: DbConnection): void { + conn.db.myThreads.onInsert(() => broadcastThreads()); + conn.db.myThreads.onUpdate(() => broadcastThreads()); + conn.db.myThreads.onDelete(() => broadcastThreads()); + + conn.db.myMessages.onInsert(() => broadcastMessages()); + conn.db.myMessages.onUpdate(() => broadcastMessages()); + conn.db.myMessages.onDelete(() => broadcastMessages()); + + conn.db.myFiles.onInsert(() => broadcastMessages()); + conn.db.myFiles.onUpdate(() => broadcastMessages()); + conn.db.myFiles.onDelete(() => broadcastMessages()); + + conn.db.myThreadLocks.onInsert(() => broadcastLocks()); + conn.db.myThreadLocks.onUpdate(() => broadcastLocks()); + conn.db.myThreadLocks.onDelete(() => broadcastLocks()); + + conn.db.agentOverride.onInsert(() => broadcastOverrides()); + conn.db.agentOverride.onUpdate(() => broadcastOverrides()); + conn.db.agentOverride.onDelete(() => broadcastOverrides()); + + conn.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) => + syncUserFromRow(row) + ); + conn.db.myAuthUser.onUpdate( + (_ctx: EventContext, _o: AuthUserRow, n: AuthUserRow) => syncUserFromRow(n) + ); + conn.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => { + if (!currentUser || row.userId !== currentUser.userId) return; + currentUser = null; + currentExp = undefined; + broadcastAuth(); + }); +} + +async function refreshConfigStatus(): Promise { + const status = await requireConn().procedures.getAgentConfigStatus({}); + configState = status.isConfigured + ? { kind: 'configured', status } + : { kind: 'unconfigured' }; + broadcastConfig(); + return status; +} + +async function bindSession( + token: string, + user: AuthUser, + exp: number +): Promise { + currentUser = user; + currentExp = exp; + + if (!serverCfg) serverCfg = await loadServerConfig(); + + if (!currentConn) { + broadcastConn('connecting'); + try { + const conn = await buildConnection( + serverCfg.stdbUri, + serverCfg.appDatabase + ); + currentConn = conn; + reconnectAttempt = 0; + broadcastConn('connected'); + + broadcastThreads(); + broadcastMessages(); + broadcastLocks(); + broadcastOverrides(); + + wireRowHandlers(conn); + } catch (err) { + broadcastConn('error', err instanceof Error ? err.message : String(err)); + return; + } + } + + // Link the connection before subscribing because views read the binding. + try { + await currentConn.procedures.linkConnection({ sessionToken: token }); + } catch (err) { + console.warn('link_connection failed', err); + } + + if (!globalSub) { + globalSub = currentConn + .subscriptionBuilder() + .onApplied(() => { + broadcastThreads(); + broadcastLocks(); + broadcastOverrides(); + broadcastMessages(); + }) + .onError((ctx: ErrorContext) => + console.error('global sub error', ctx.event) + ) + .subscribe([ + 'SELECT * FROM my_threads', + 'SELECT * FROM my_thread_locks', + 'SELECT * FROM agent_override', + 'SELECT * FROM my_files', + 'SELECT * FROM my_auth_user', + ]); + + const previousActive = activeThreadId; + activeThreadId = null; + messageSub = null; + if (previousActive !== null) setActiveThread(previousActive); + } + + await refreshConfigStatus(); + broadcastAuth(); +} + +async function restoreSession(): Promise { + try { + const r = await callJson<{ + user: AuthUser; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + return true; + } catch { + return false; + } +} + +async function signup(args: { + email: string; + password: string; + name?: string; +}): Promise { + const r = await callJson<{ token: string }>('/auth/password/signup', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} + +async function login(args: { email: string; password: string }): Promise { + const r = await callJson<{ token: string }>('/auth/password/login', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} + +async function logout(): Promise { + if (currentConn) { + try { + currentConn.reducers.unlinkConnection({}); + } catch { + /* ignore */ + } + } + try { + await callJson('/auth/logout', {}); + } catch { + /* ignore */ + } + currentUser = null; + currentExp = undefined; + broadcastThreads(); + broadcastMessages(); + broadcastLocks(); + broadcastOverrides(); + broadcastAuth(); +} + +function oauthStart(provider: 'google' | 'github'): void { + window.location.href = `/auth/${provider}/start?redirectTo=/`; +} + +async function forgotPassword(email: string): Promise { + await callJson('/auth/password/forgot', { email }); +} +async function resetPassword( + token: string, + newPassword: string +): Promise { + await callJson('/auth/password/reset', { token, newPassword }); +} +async function requestEmailVerify(): Promise { + await callJson('/auth/email/verify-request', {}); +} + +async function listMySessions(): Promise<{ sessions: unknown[] }> { + return await requireConn().procedures.listMySessions({}); +} +async function revokeMySession(sessionId: string): Promise { + requireConn().reducers.revokeMySession({ sessionId }); +} + +async function main(): Promise { + window.auth = { + signup, + login, + logout, + oauthStart, + forgotPassword, + resetPassword, + requestEmailVerify, + listMySessions, + revokeMySession, + setProfile: args => { + requireConn().reducers.updateProfile({ + name: args.name, + image: args.image, + }); + }, + }; + + window.stdb = { + setAgentSecret: async args => { + requireConn().reducers.setAgentSecret(args); + await refreshConfigStatus(); + }, + setApiKey: async (provider, key) => { + requireConn().reducers.setApiKey({ provider, key }); + await refreshConfigStatus(); + }, + clearApiKey: async provider => { + requireConn().reducers.clearApiKey({ provider }); + await refreshConfigStatus(); + }, + setAgentOverride: async args => { + requireConn().reducers.setAgentOverride(args); + }, + clearAgentOverride: async agentName => { + requireConn().reducers.clearAgentOverride({ agentName }); + }, + getAgentConfigStatus: () => refreshConfigStatus(), + setActiveThread, + startThread: async args => { + return await requireConn().procedures.startThread(args); + }, + updateThread: async args => { + requireConn().reducers.updateThread(args); + }, + deleteThread: async threadId => { + requireConn().reducers.deleteThread({ threadId }); + }, + sendMessage: async (threadId, content, atts) => { + await requireConn().procedures.sendMessage({ + threadId, + content, + attachments: atts ?? [], + }); + }, + regenerateResponse: async threadId => { + await requireConn().procedures.regenerateResponse({ threadId }); + }, + requestCancel: async threadId => { + requireConn().reducers.requestCancel({ threadId }); + }, + generateThreadTitle: async threadId => { + await requireConn().procedures.generateThreadTitle({ threadId }); + }, + clearThreadLock: async threadId => { + requireConn().reducers.clearThreadLock({ threadId }); + }, + }; + + broadcastConn('idle'); + dispatch('stdb:ready', {}); + await restoreSession(); + dispatch('auth:ready', {}); +} + +main().catch(err => { + console.error(err); + broadcastConn('error', err instanceof Error ? err.message : String(err)); +}); diff --git a/spacetime-agents-ts/example/tsconfig.json b/spacetime-agents-ts/example/tsconfig.json new file mode 100644 index 00000000000..419028c7af7 --- /dev/null +++ b/spacetime-agents-ts/example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "server.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-agents-ts/package.json b/spacetime-agents-ts/package.json new file mode 100644 index 00000000000..c57f3a49f27 --- /dev/null +++ b/spacetime-agents-ts/package.json @@ -0,0 +1,78 @@ +{ + "name": "@spacetimedb/agents", + "description": "Reusable agent orchestration, tool calling, and provider adapters for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./openrouter": { + "types": "./src/openrouter.ts", + "default": "./src/openrouter.ts" + }, + "./kit": { + "types": "./src/kit.ts", + "default": "./src/kit.ts" + }, + "./providers": { + "types": "./src/providers.ts", + "default": "./src/providers.ts" + }, + "./embeddings": { + "types": "./src/embeddings.ts", + "default": "./src/embeddings.ts" + }, + "./stale-locks": { + "types": "./src/stale-locks.ts", + "default": "./src/stale-locks.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-agents-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-agents-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "agents", + "llm", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "pnpm run test:unit", + "test:unit": "tsx scripts/test-kit.ts" + }, + "dependencies": {}, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-agents-ts/scripts/test-kit.ts b/spacetime-agents-ts/scripts/test-kit.ts new file mode 100644 index 00000000000..c9c026902d4 --- /dev/null +++ b/spacetime-agents-ts/scripts/test-kit.ts @@ -0,0 +1,1189 @@ +// Pure-Node tests for spacetime-agents-ts/kit. +// Avoids importing 'spacetimedb/server' (Node 22 ESM can't parse its `using` decls); +// builds minimal AlgebraicType fixtures matching what t.object(...) would produce. + +import { + agentTool, + makeAgentDispatch, + defineAgent, + makeAgentRegistry, + typeBuilderToJsonSchema, +} from '../src/kit.ts'; +import { + openRouterProvider, + openAiProvider, + anthropicProvider, +} from '../src/providers.ts'; +import { + callChat, + type HttpLike, + type ChatRequest, + type ToolDefinition, +} from '../src/openrouter.ts'; +import { + cosineSimilarity, + topKByScore, + openAiEmbeddingsProvider, + openRouterEmbeddingsProvider, +} from '../src/embeddings.ts'; +import { + deleteStaleThreadLocks, + staleLockCutoffMicros, +} from '../src/stale-locks.ts'; + +type AT = { tag: string; value?: unknown }; + +import type { AlgebraicType } from 'spacetimedb'; +import type { TypeBuilder } from 'spacetimedb/server'; + +const fake = (at: AT): TypeBuilder => + ({ algebraicType: at }) as unknown as TypeBuilder; + +const _bool = (): AT => ({ tag: 'Bool' }); +const _string = (): AT => ({ tag: 'String' }); +const _i8 = (): AT => ({ tag: 'I8' }); +const _u8 = (): AT => ({ tag: 'U8' }); +const _i16 = (): AT => ({ tag: 'I16' }); +const _u16 = (): AT => ({ tag: 'U16' }); +const _i32 = (): AT => ({ tag: 'I32' }); +const _u32 = (): AT => ({ tag: 'U32' }); +const _i64 = (): AT => ({ tag: 'I64' }); +const _u64 = (): AT => ({ tag: 'U64' }); +const _u128 = (): AT => ({ tag: 'U128' }); +const _f64 = (): AT => ({ tag: 'F64' }); +const _array = (e: AT): AT => ({ tag: 'Array', value: e }); +const _object = (props: Record): AT => ({ + tag: 'Product', + value: { + elements: Object.entries(props).map(([name, at]) => ({ + name, + algebraicType: at, + })), + }, +}); +const _unit = (): AT => ({ tag: 'Product', value: { elements: [] } }); +const _option = (inner: AT): AT => ({ + tag: 'Sum', + value: { + variants: [ + { name: 'some', algebraicType: inner }, + { name: 'none', algebraicType: _unit() }, + ], + }, +}); + +let failures = 0; +function assert(cond: boolean, msg: string): void { + if (!cond) { + process.stderr.write(` FAIL: ${msg}\n`); + failures++; + } else { + process.stdout.write(` ${msg} OK\n`); + } +} + +process.stdout.write('\nstale lock sweep tests\n'); + +{ + const nowMicros = 20_000n; + const cutoffMicros = staleLockCutoffMicros(nowMicros, 1_000n); + const locks = Array.from({ length: 600 }, (_, id) => ({ + id, + lockedAt: { microsSinceUnixEpoch: nowMicros }, + })); + locks.push({ id: 600, lockedAt: { microsSinceUnixEpoch: 1_000n } }); + + const expiredIndexRows = locks + .filter(lock => lock.lockedAt.microsSinceUnixEpoch < cutoffMicros) + .sort((a, b) => + a.lockedAt.microsSinceUnixEpoch < b.lockedAt.microsSinceUnixEpoch ? -1 : 1 + ); + const deleted = new Set(); + const count = deleteStaleThreadLocks(expiredIndexRows, cutoffMicros, lock => + deleted.add(lock.id) + ); + + assert(count === 1, 'sweep reaches an expired lock after 600 fresh inserts'); + assert(deleted.has(600), 'sweep deletes the expired lock'); + assert( + [...deleted].every(id => id === 600), + 'sweep preserves every fresh lock' + ); +} + +const eq = (a: unknown, b: unknown): boolean => + JSON.stringify(a) === JSON.stringify(b); + +process.stdout.write('typeBuilderToJsonSchema tests\n'); + +// 1. unit -> empty object schema +{ + const schema = typeBuilderToJsonSchema(fake(_unit())); + assert( + eq(schema, { type: 'object', properties: {} }), + `unit() -> empty object` + ); +} + +// 2. object with required primitives +{ + const tb = fake( + _object({ + name: _string(), + count: _i32(), + ratio: _f64(), + on: _bool(), + }) + ); + const schema = typeBuilderToJsonSchema(tb); + assert( + eq(schema, { + type: 'object', + properties: { + name: { type: 'string' }, + count: { type: 'integer' }, + ratio: { type: 'number' }, + on: { type: 'boolean' }, + }, + required: ['name', 'count', 'ratio', 'on'], + }), + `object with primitives -> all required` + ); +} + +// 3. option fields excluded from required, unwrapped to inner schema +{ + const tb = fake( + _object({ + must: _string(), + maybe: _option(_string()), + }) + ); + const schema = typeBuilderToJsonSchema(tb); + assert( + schema.required !== undefined && + schema.required.includes('must') && + !schema.required.includes('maybe'), + `option fields excluded from required` + ); + assert( + eq(schema.properties.maybe, { type: 'string' }), + `option unwraps to plain string schema` + ); +} + +// 4. nested object +{ + const tb = fake( + _object({ + inner: _object({ a: _i64() }), + }) + ); + const schema = typeBuilderToJsonSchema(tb); + assert( + eq(schema.properties.inner, { + type: 'object', + properties: { a: { type: 'integer' } }, + required: ['a'], + }), + `nested object inlined recursively` + ); +} + +// 5. array of strings +{ + const tb = fake(_object({ tags: _array(_string()) })); + const schema = typeBuilderToJsonSchema(tb); + assert( + eq(schema.properties.tags, { type: 'array', items: { type: 'string' } }), + `array -> {type:array, items:{type:string}}` + ); +} + +// 6. all i*/u* through 64 bits roll up to integer +{ + const tb = fake( + _object({ + a: _i8(), + b: _u8(), + c: _i16(), + d: _u16(), + e: _i32(), + f: _u32(), + g: _i64(), + h: _u64(), + }) + ); + const schema = typeBuilderToJsonSchema(tb); + const allInt = Object.values(schema.properties).every( + v => + typeof v === 'object' && + v !== null && + (v as { type?: unknown }).type === 'integer' + ); + assert(allInt, `all i*/u* up to 64 bits -> integer`); +} + +// 7. 128/256-bit integers throw (not JSON-representable) +{ + let threw = false; + try { + typeBuilderToJsonSchema(fake(_object({ x: _u128() }))); + } catch (err) { + threw = err instanceof Error && err.message.includes('U128'); + } + assert(threw, `u128 field rejected with helpful error`); +} + +// 8. non-product top-level rejected +{ + let threw = false; + try { + typeBuilderToJsonSchema(fake(_string())); + } catch (err) { + threw = err instanceof Error && err.message.includes('object'); + } + assert(threw, `top-level non-object rejected`); +} + +// 9. true sum (non-option) -> oneOf +{ + const sumAt: AT = { + tag: 'Sum', + value: { + variants: [ + { name: 'a', algebraicType: _string() }, + { name: 'b', algebraicType: _i32() }, + { name: 'c', algebraicType: _bool() }, + ], + }, + }; + const tb = fake(_object({ kind: sumAt })); + const schema = typeBuilderToJsonSchema(tb); + const kind = schema.properties.kind as { oneOf?: unknown[] }; + const oneOf = Array.isArray(kind.oneOf) ? kind.oneOf : []; + assert(oneOf.length === 3, `3-variant sum -> oneOf with 3 entries`); + assert( + eq(oneOf[0], { + type: 'object', + properties: { + tag: { type: 'string', enum: ['a'] }, + value: { type: 'string' }, + }, + required: ['tag'], + }), + `sum variant shape: {tag, value}` + ); +} + +process.stdout.write('\nagentTool + makeAgentDispatch tests\n'); + +// 10. agentTool retains TypeBuilder algebraicType +{ + const tool = agentTool( + 'echoes the message back', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `echo: ${args.msg}` + ); + const at = tool.algebraicType; + assert(at?.tag === 'Product', `agentTool retains algebraicType`); +} + +// 11. dispatch builds llmToolDefs in expected shape + invoke paths +{ + const echo = agentTool( + 'echoes the message back', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `echo: ${args.msg}` + ); + const noop = agentTool( + 'does nothing, takes no args', + fake(_unit()), + _ctx => 'ok' + ); + const { llmToolDefs, invoke } = makeAgentDispatch< + unknown, + { echo: typeof echo; noop: typeof noop } + >({ echo, noop }); + + assert(llmToolDefs.length === 2, `2 tool defs emitted`); + assert( + llmToolDefs[0].function.name === 'echo' && + llmToolDefs[0].function.description === 'echoes the message back', + `echo tool def name + description` + ); + assert( + eq(llmToolDefs[0].function.parameters, { + type: 'object', + properties: { msg: { type: 'string' } }, + required: ['msg'], + }), + `echo tool def parameters schema` + ); + assert( + eq(llmToolDefs[1].function.parameters, { type: 'object', properties: {} }), + `unit-arg tool def has empty properties` + ); + + const r1 = invoke({}, 'echo', JSON.stringify({ msg: 'hi' })); + assert(r1.isError === false && r1.result === 'echo: hi', `invoke echo OK`); + + const r2 = invoke({}, 'noop', ''); + assert( + r2.isError === false && r2.result === 'ok', + `invoke noop with empty input string` + ); + + const r3 = invoke({}, 'nope', '{}'); + assert( + r3.isError === true && r3.result.includes('unknown tool'), + `unknown tool returns isError` + ); + + const r4 = invoke({}, 'echo', '{not json'); + assert( + r4.isError === true && r4.result.includes('invalid JSON'), + `bad JSON returns isError` + ); + + const boom = agentTool('throws', fake(_unit()), _ctx => { + throw new Error('kaboom'); + }); + const d2 = makeAgentDispatch({ boom }); + const r5 = d2.invoke({}, 'boom', ''); + assert( + r5.isError === true && r5.result === 'kaboom', + `tool throw becomes isError result` + ); + + const missing = invoke({}, 'echo', '{}'); + assert( + missing.isError === true && missing.result.includes('msg is required'), + `missing required field rejected before handler` + ); + const wrongType = invoke({}, 'echo', '{"msg":42}'); + assert( + wrongType.isError === true && wrongType.result.includes('must be a string'), + `wrong field type rejected before handler` + ); + const unknown = invoke({}, 'echo', '{"msg":"hi","extra":true}'); + assert( + unknown.isError === true && unknown.result.includes('extra is not allowed'), + `unknown field rejected before handler` + ); + const oversized = invoke( + {}, + 'echo', + JSON.stringify({ msg: 'x'.repeat(70_000) }) + ); + assert( + oversized.isError === true && oversized.result.includes('exceeds'), + `oversized tool input rejected before parsing` + ); +} + +// 12. invalid tool name rejected at dispatch construction +{ + const bad = agentTool('x', fake(_unit()), () => 'ok'); + let threw = false; + try { + makeAgentDispatch>({ + 'has spaces': bad, + }); + } catch (err) { + threw = err instanceof Error && err.message.includes('must match'); + } + assert(threw, `invalid tool name 'has spaces' rejected`); +} + +// 13. valid tool names accepted (a-z, A-Z, 0-9, _, -) +{ + const ok = agentTool('x', fake(_unit()), () => 'ok'); + let threw = false; + try { + makeAgentDispatch>({ + send_message: ok, + 'get-time': ok, + tool42: ok, + }); + } catch { + threw = true; + } + assert(!threw, `valid tool names accepted`); +} + +// 14. Prototype-key lookup reported as 'unknown tool', not dispatched. +{ + const ok = agentTool('x', fake(_unit()), () => 'ok'); + const { invoke } = makeAgentDispatch>({ + real: ok, + }); + for (const name of [ + 'toString', + 'constructor', + '__proto__', + 'hasOwnProperty', + ]) { + const r = invoke({}, name, '{}'); + assert( + r.isError === true && r.result === `unknown tool: ${name}`, + `prototype-key '${name}' rejected as unknown tool` + ); + } +} + +process.stdout.write('\ndefineAgent + makeAgentRegistry tests\n'); + +// 15. defineAgent fills in defaults for omitted optional fields +{ + const echo = agentTool( + 'echo back', + fake<{ message: string }>(_object({ message: _string() })), + (_ctx, args) => `echo: ${args.message}` + ); + const a = defineAgent({ + defaultModel: 'm/x', + tools: { echo }, + }); + assert(a.defaultModel === 'm/x', `defineAgent: defaultModel set`); + assert( + a.defaultMaxTurns === 10, + `defineAgent: defaultMaxTurns falls back to 10` + ); + assert( + a.defaultMaxHistoryMessages === 50, + `defineAgent: defaultMaxHistoryMessages falls back to 50` + ); + assert(a.defaultRetries === 2, `defineAgent: defaultRetries falls back to 2`); + assert( + a.defaultSystemPrompt === undefined, + `defineAgent: systemPrompt remains undefined when omitted` + ); + assert( + a.defaultMaxTokens === undefined, + `defineAgent: maxTokens remains undefined when omitted` + ); + assert( + a.defaultResponseFormat === undefined, + `defineAgent: responseFormat remains undefined when omitted` + ); +} + +// 15b. Invalid agent limits fail during definition. +{ + let threw = false; + try { + defineAgent({ defaultModel: 'm/x', defaultMaxTurns: 0, tools: {} }); + } catch (err) { + threw = err instanceof Error && err.message.includes('defaultMaxTurns'); + } + assert(threw, `defineAgent rejects an invalid turn limit`); +} + +// 16. makeAgentRegistry exposes names() / has() / agentDef() +{ + const echo = agentTool( + 'echo back', + fake<{ message: string }>(_object({ message: _string() })), + (_ctx, args) => `echo: ${args.message}` + ); + const noop = agentTool('does nothing', fake(_unit()), _ctx => 'ok'); + const chat = defineAgent({ defaultModel: 'm/chat', tools: { echo, noop } }); + const summary = defineAgent({ + defaultModel: 'm/summary', + defaultMaxTurns: 1, + defaultResponseFormat: { type: 'json_object' }, + tools: {}, + }); + const reg = makeAgentRegistry< + unknown, + { chat: typeof chat; summary: typeof summary } + >({ chat, summary }); + + assert( + eq(reg.names().sort(), ['chat', 'summary']), + `registry: names() returns all agent names` + ); + assert(reg.has('chat') === true, `registry: has('chat') = true`); + assert( + reg.has('does-not-exist') === false, + `registry: has('does-not-exist') = false` + ); + assert( + reg.agentDef('chat')?.defaultModel === 'm/chat', + `registry: agentDef returns the right def` + ); + assert( + reg.agentDef('summary')?.defaultResponseFormat !== undefined, + `registry: per-agent responseFormat preserved` + ); +} + +// 17. Registry routes tool dispatch by agent name +{ + const echo = agentTool( + 'echo', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `chat-echo: ${args.msg}` + ); + const otherEcho = agentTool( + 'echo', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `summary-echo: ${args.msg}` + ); + const chat = defineAgent({ defaultModel: 'm/chat', tools: { echo } }); + const summary = defineAgent({ + defaultModel: 'm/sum', + tools: { echo: otherEcho }, + }); + const reg = makeAgentRegistry< + unknown, + { chat: typeof chat; summary: typeof summary } + >({ chat, summary }); + + const r1 = reg.invoke('chat', {}, 'echo', JSON.stringify({ msg: 'hi' })); + assert( + r1.isError === false && r1.result === 'chat-echo: hi', + `registry: invoke('chat', echo) hits chat's tool` + ); + const r2 = reg.invoke('summary', {}, 'echo', JSON.stringify({ msg: 'hi' })); + assert( + r2.isError === false && r2.result === 'summary-echo: hi', + `registry: invoke('summary', echo) hits summary's tool (same name, different agent)` + ); + const r3 = reg.invoke('does-not-exist', {}, 'echo', '{}'); + assert( + r3.isError === true && r3.result.includes('unknown agent'), + `registry: invoke on unknown agent returns isError` + ); + const r4 = reg.invoke('summary', {}, 'echo_typo', '{}'); + assert( + r4.isError === true && r4.result.includes('unknown tool'), + `registry: invoke with unknown tool name in valid agent returns isError` + ); +} + +// 18. Per-agent llmToolDefs differs by agent +{ + const echo = agentTool( + 'echo', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `e: ${args.msg}` + ); + const chat = defineAgent({ defaultModel: 'm/chat', tools: { echo } }); + const summary = defineAgent({ defaultModel: 'm/sum', tools: {} }); + const reg = makeAgentRegistry< + unknown, + { chat: typeof chat; summary: typeof summary } + >({ chat, summary }); + + assert(reg.llmToolDefsFor('chat').length === 1, `tool-defs: chat has 1 tool`); + assert( + reg.llmToolDefsFor('summary').length === 0, + `tool-defs: summary has 0 tools` + ); + assert( + reg.llmToolDefsFor('does-not-exist').length === 0, + `tool-defs: unknown agent returns empty list` + ); +} + +// 19. Invalid agent name rejected at registry construction +{ + const a = defineAgent({ defaultModel: 'm/x', tools: {} }); + let threw = false; + try { + makeAgentRegistry>({ 'has spaces': a }); + } catch (err) { + threw = err instanceof Error && err.message.includes('must match'); + } + assert(threw, `agent-name: invalid name rejected`); +} + +// Provider adapters + +process.stdout.write('\nprovider adapter tests\n'); + +const sampleTools: ToolDefinition[] = [ + { + type: 'function', + function: { + name: 'echo', + description: 'echo back', + parameters: { + type: 'object', + properties: { msg: { type: 'string' } }, + required: ['msg'], + }, + }, + }, +]; + +const baseReq: ChatRequest = { + apiKey: 'sk-test', + model: 'some/model', + system: 'you are helpful', + messages: [{ role: 'user', content: 'hi' }], + tools: sampleTools, + maxTokens: 256, +}; + +// 20. openRouterProvider builds OpenAI-shape body, posts to OpenRouter URL. +{ + const { url, headers, body } = openRouterProvider.buildRequest(baseReq); + assert( + url === 'https://openrouter.ai/api/v1/chat/completions', + `openrouter: OpenRouter URL` + ); + assert( + headers['Authorization'] === 'Bearer sk-test', + `openrouter: Bearer auth` + ); + const b = JSON.parse(body); + assert(b.model === 'some/model', `openrouter: model carried`); + assert( + b.messages[0].role === 'system' && + b.messages[0].content === 'you are helpful', + `openrouter: system prepended as first message` + ); + assert(b.messages[1].role === 'user', `openrouter: user follows system`); + assert( + Array.isArray(b.tools) && b.tools[0].function.name === 'echo', + `openrouter: tools in OpenAI shape` + ); + assert(b.tool_choice === 'auto', `openrouter: tool_choice='auto'`); + assert(b.max_tokens === 256, `openrouter: max_tokens carried`); +} + +// 21. openAiProvider differs only in URL (same wire format). +{ + const { url, headers } = openAiProvider.buildRequest(baseReq); + assert( + url === 'https://api.openai.com/v1/chat/completions', + `openai: native OpenAI URL` + ); + assert(headers['Authorization'] === 'Bearer sk-test', `openai: Bearer auth`); +} + +// 22. anthropicProvider translates: system separate, tools renamed, max_tokens required. +{ + const { url, headers, body } = anthropicProvider.buildRequest(baseReq); + assert( + url === 'https://api.anthropic.com/v1/messages', + `anthropic: messages URL` + ); + assert(headers['x-api-key'] === 'sk-test', `anthropic: x-api-key auth`); + assert( + headers['anthropic-version'] === '2023-06-01', + `anthropic: version header` + ); + const b = JSON.parse(body); + assert( + b.system === 'you are helpful', + `anthropic: system as top-level field` + ); + assert( + b.messages.length === 1 && b.messages[0].role === 'user', + `anthropic: system NOT in messages array` + ); + assert(b.max_tokens === 256, `anthropic: max_tokens carried`); + assert( + b.tools[0].name === 'echo' && b.tools[0].input_schema !== undefined, + `anthropic: tools renamed (function.name -> name, parameters -> input_schema)` + ); + assert( + eq(b.tool_choice, { type: 'auto' }), + `anthropic: tool_choice is object {type:'auto'}` + ); +} + +// 23. anthropicProvider: tool_calls in assistant messages become content blocks. +{ + const req: ChatRequest = { + apiKey: 'sk-test', + model: 'claude-3-5-sonnet', + messages: [ + { role: 'user', content: 'do it' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'tc_1', + type: 'function', + function: { name: 'echo', arguments: '{"msg":"hi"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'tc_1', content: 'echoed' }, + ], + }; + const { body } = anthropicProvider.buildRequest(req); + const b = JSON.parse(body); + assert(b.messages.length === 3, `anthropic: 3 messages`); + const asst = b.messages[1]; + assert( + asst.role === 'assistant' && Array.isArray(asst.content), + `anthropic: assistant content is content-block array` + ); + assert( + asst.content.some( + (block: unknown) => + typeof block === 'object' && + block !== null && + (block as { type?: unknown }).type === 'tool_use' && + (block as { id?: unknown }).id === 'tc_1' + ), + `anthropic: tool_call -> tool_use block` + ); + const tr = b.messages[2]; + assert( + tr.role === 'user' && + tr.content[0].type === 'tool_result' && + tr.content[0].tool_use_id === 'tc_1', + `anthropic: tool result wrapped in user/tool_result block` + ); +} + +// 24. anthropicProvider: default max_tokens when caller omits. +{ + const req: ChatRequest = { + apiKey: 'k', + model: 'm', + messages: [{ role: 'user', content: 'x' }], + }; + const { body } = anthropicProvider.buildRequest(req); + assert( + JSON.parse(body).max_tokens > 0, + `anthropic: max_tokens always present` + ); +} + +// 25. parseResponse: OpenAI shape. +{ + const r = openRouterProvider.parseResponse( + JSON.stringify({ + model: 'gpt-4', + choices: [ + { + finish_reason: 'stop', + message: { content: 'hello back' }, + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 5, total_tokens: 17 }, + }), + 'some/model' + ); + assert( + r.text === 'hello back' && r.finishReason === 'stop', + `parse-openai: text + finishReason` + ); + assert( + r.usage.promptTokens === 12 && r.usage.completionTokens === 5, + `parse-openai: usage` + ); + assert(r.toolCalls.length === 0, `parse-openai: no tool calls`); +} + +// 26. parseResponse: OpenAI tool calls. +{ + const r = openRouterProvider.parseResponse( + JSON.stringify({ + model: 'gpt-4', + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + tool_calls: [ + { + id: 'tc_a', + type: 'function', + function: { name: 'echo', arguments: '{"msg":"hi"}' }, + }, + ], + }, + }, + ], + usage: {}, + }), + 'some/model' + ); + assert( + r.text === null && + r.toolCalls.length === 1 && + r.toolCalls[0].function.name === 'echo', + `parse-openai: tool_calls extracted` + ); + assert(r.finishReason === 'tool_calls', `parse-openai: tool_calls finish`); +} + +// 27. parseResponse: Anthropic content blocks + stop_reason mapping. +{ + const r = anthropicProvider.parseResponse( + JSON.stringify({ + model: 'claude-3-5-sonnet', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'hello back' }], + usage: { input_tokens: 12, output_tokens: 5 }, + }), + 'claude-3-5-sonnet' + ); + assert(r.text === 'hello back', `parse-anthropic: text extracted`); + assert(r.finishReason === 'stop', `parse-anthropic: end_turn -> stop`); + assert( + r.usage.promptTokens === 12 && r.usage.completionTokens === 5, + `parse-anthropic: usage mapped (input_tokens -> promptTokens)` + ); + assert(r.usage.totalTokens === 17, `parse-anthropic: total computed`); +} + +// 27b. Malformed provider usage cannot produce NaN or negative totals. +{ + const openAi = openAiProvider.parseResponse( + JSON.stringify({ + choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], + usage: { + prompt_tokens: 'oops', + completion_tokens: -5, + total_tokens: null, + }, + }), + 'm' + ); + assert( + eq(openAi.usage, { promptTokens: 0, completionTokens: 0, totalTokens: 0 }), + `parse-openai: malformed usage normalizes to zero` + ); +} + +// 28. parseResponse: Anthropic tool_use blocks -> normalized tool calls. +{ + const r = anthropicProvider.parseResponse( + JSON.stringify({ + model: 'claude-3-5-sonnet', + stop_reason: 'tool_use', + content: [ + { type: 'text', text: 'using tool' }, + { type: 'tool_use', id: 'tu_1', name: 'echo', input: { msg: 'hi' } }, + ], + usage: {}, + }), + 'claude-3-5-sonnet' + ); + assert(r.text === 'using tool', `parse-anthropic: text from text block`); + assert( + r.finishReason === 'tool_calls', + `parse-anthropic: tool_use -> tool_calls` + ); + assert( + r.toolCalls.length === 1 && r.toolCalls[0].function.name === 'echo', + `parse-anthropic: tool_use -> toolCalls` + ); + assert( + r.toolCalls[0].function.arguments === '{"msg":"hi"}', + `parse-anthropic: input -> JSON-stringified arguments` + ); +} + +// 29. callChat dispatches through chosen provider. +{ + const http: HttpLike = { + fetch(url, init) { + // Verify Anthropic URL came through. + assert( + url === 'https://api.anthropic.com/v1/messages', + `callChat: routes to provider URL` + ); + assert( + (init.headers as Record)['x-api-key'] === 'sk-test', + `callChat: provider headers` + ); + return { + status: 200, + text: () => + JSON.stringify({ + model: 'claude-3-5-sonnet', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'ok' }], + usage: { input_tokens: 1, output_tokens: 1 }, + }), + }; + }, + }; + const result = callChat(http, anthropicProvider, { + apiKey: 'sk-test', + model: 'claude-3-5-sonnet', + messages: [{ role: 'user', content: 'hi' }], + }); + assert( + result.ok && result.response.text === 'ok', + `callChat: returns parsed response from chosen provider` + ); +} + +// 30. defineAgent: provider defaults to 'openrouter'. +{ + const a = defineAgent({ defaultModel: 'm', tools: {} }); + assert( + a.defaultProvider === 'openrouter', + `defineAgent: default provider is openrouter` + ); +} + +// 31. defineAgent: explicit provider preserved. +{ + const a = defineAgent({ + defaultProvider: 'anthropic', + defaultModel: 'm', + tools: {}, + }); + assert( + a.defaultProvider === 'anthropic', + `defineAgent: explicit provider preserved` + ); +} + +// Embeddings + RAG helpers + +process.stdout.write('\nembeddings + RAG tests\n'); + +// 32. Cosine: identical vectors -> 1.0 +{ + const v = [1, 2, 3]; + assert(Math.abs(cosineSimilarity(v, v) - 1) < 1e-9, `cosine: identical -> 1`); +} + +// 33. Cosine: orthogonal -> 0 +{ + assert( + Math.abs(cosineSimilarity([1, 0], [0, 1])) < 1e-9, + `cosine: orthogonal -> 0` + ); +} + +// 34. Cosine: anti-parallel -> -1 +{ + assert( + Math.abs(cosineSimilarity([1, 2, 3], [-1, -2, -3]) - -1) < 1e-9, + `cosine: anti-parallel -> -1` + ); +} + +// 35. Cosine: zero vector -> 0 (no NaN) +{ + assert( + cosineSimilarity([0, 0, 0], [1, 2, 3]) === 0, + `cosine: zero vector returns 0` + ); +} + +// 36. Cosine: length mismatch -> 0 +{ + assert( + cosineSimilarity([1, 2], [1, 2, 3]) === 0, + `cosine: length mismatch -> 0` + ); +} + +// 37. topKByScore picks the highest-scoring items in descending order +{ + const items = ['a', 'b', 'c', 'd']; + const scores: Record = { a: 0.1, b: 0.9, c: 0.5, d: 0.7 }; + const top = topKByScore(items, x => scores[x], 2); + assert(top.length === 2, `topK: count`); + assert(top[0].item === 'b' && top[1].item === 'd', `topK: descending`); + assert(top[0].score === 0.9, `topK: score carried`); +} + +// 38. topKByScore k=0 returns empty +{ + assert(topKByScore([1, 2, 3], x => x, 0).length === 0, `topK: k=0 -> empty`); +} + +// 39. topKByScore k > length returns all +{ + const out = topKByScore(['x', 'y'], () => 1, 10); + assert(out.length === 2, `topK: k > length returns all`); +} + +// 40. openAiEmbeddingsProvider builds correct request + parses response. +{ + let captured: { url?: string; body?: unknown } = {}; + const http: HttpLike = { + fetch(url, init) { + captured = { url, body: JSON.parse(init.body!) }; + return { + status: 200, + text: () => + JSON.stringify({ + model: 'text-embedding-3-small', + data: [ + { embedding: [0.1, 0.2, 0.3] }, + { embedding: [0.4, 0.5, 0.6] }, + ], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }), + }; + }, + }; + const r = openAiEmbeddingsProvider.embed( + http, + 'sk-test', + 'text-embedding-3-small', + ['hello', 'world'] + ); + const capturedBody = captured.body as Record; + assert( + captured.url === 'https://api.openai.com/v1/embeddings', + `embeddings: openai URL` + ); + assert( + capturedBody.model === 'text-embedding-3-small', + `embeddings: model carried` + ); + assert( + eq(capturedBody.input, ['hello', 'world']), + `embeddings: inputs array` + ); + assert( + r.ok && r.vectors.length === 2 && eq(r.vectors[0], [0.1, 0.2, 0.3]), + `embeddings: vectors parsed in order` + ); + assert(r.ok && r.usage.promptTokens === 8, `embeddings: usage parsed`); +} + +// 41. openRouterEmbeddingsProvider hits OpenRouter URL. +{ + let capturedUrl = ''; + const http: HttpLike = { + fetch(url) { + capturedUrl = url; + return { + status: 200, + text: () => + JSON.stringify({ + model: 'm', + data: [{ embedding: [1, 2] }], + usage: {}, + }), + }; + }, + }; + openRouterEmbeddingsProvider.embed(http, 'k', 'm', ['t']); + assert( + capturedUrl === 'https://openrouter.ai/api/v1/embeddings', + `embeddings: openrouter URL` + ); +} + +// Multimodal content blocks + +// 42a. openRouter/openAi serialize image attachments as image_url data URIs. +{ + const req: ChatRequest = { + apiKey: 'k', + model: 'gpt-4o', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'caption this' }, + { type: 'image', mimeType: 'image/png', data: 'BASE64DATA' }, + ], + }, + ], + }; + const { body } = openAiProvider.buildRequest(req); + const b = JSON.parse(body); + assert( + Array.isArray(b.messages[0].content), + `multimodal-openai: content is array` + ); + assert( + b.messages[0].content[0].type === 'text' && + b.messages[0].content[0].text === 'caption this', + `multimodal-openai: text block` + ); + assert( + b.messages[0].content[1].type === 'image_url', + `multimodal-openai: image becomes image_url block` + ); + assert( + b.messages[0].content[1].image_url.url === + 'data:image/png;base64,BASE64DATA', + `multimodal-openai: data URI built correctly` + ); +} + +// 42b. Anthropic serializes image attachments as { type:'image', source:{base64,media_type,data} }. +{ + const req: ChatRequest = { + apiKey: 'k', + model: 'claude-3-5-sonnet', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'caption this' }, + { type: 'image', mimeType: 'image/jpeg', data: 'BASE64DATA' }, + ], + }, + ], + }; + const { body } = anthropicProvider.buildRequest(req); + const b = JSON.parse(body); + assert( + Array.isArray(b.messages[0].content), + `multimodal-anthropic: content is array` + ); + assert( + b.messages[0].content[1].type === 'image', + `multimodal-anthropic: image block type='image'` + ); + assert( + eq(b.messages[0].content[1].source, { + type: 'base64', + media_type: 'image/jpeg', + data: 'BASE64DATA', + }), + `multimodal-anthropic: source structured correctly` + ); +} + +// 42c. String content unchanged through providers (backward compat). +{ + const req: ChatRequest = { + apiKey: 'k', + model: 'm', + messages: [{ role: 'user', content: 'plain text' }], + }; + const oa = JSON.parse(openAiProvider.buildRequest(req).body); + const an = JSON.parse(anthropicProvider.buildRequest(req).body); + assert( + oa.messages[0].content === 'plain text', + `compat: openai keeps string content` + ); + assert( + an.messages[0].content === 'plain text', + `compat: anthropic keeps string content` + ); +} + +// 42. Embedding HTTP errors return parseable error shape. +{ + const http: HttpLike = { + fetch: () => ({ status: 401, text: () => '{"error":"unauthorized"}' }), + }; + const r = openAiEmbeddingsProvider.embed(http, 'bad', 'm', ['x']); + assert( + !r.ok && r.error.kind === 'http' && r.error.status === 401, + `embeddings: http error parsed` + ); +} + +if (failures > 0) { + process.stderr.write(`\n${failures} test(s) failed.\n`); + process.exit(1); +} +process.stdout.write('\nall kit tests passed.\n'); diff --git a/spacetime-agents-ts/spacetimedb/package.json b/spacetime-agents-ts/spacetimedb/package.json new file mode 100644 index 00000000000..cc7fcb43700 --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-agents-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-agents", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-agents" + }, + "dependencies": { + "@spacetimedb/agents": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-agents-ts/spacetimedb/src/index.ts b/spacetime-agents-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..d4e3855096b --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/src/index.ts @@ -0,0 +1,1033 @@ +import { + schema, + table, + t, + Range, + SenderError, + type TransactionCtx, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, +} from 'spacetimedb/server'; +import { Timestamp, type Identity } from 'spacetimedb'; +import { + deleteStaleThreadLocks, + staleLockCutoffMicros, +} from '@spacetimedb/agents/stale-locks'; +import { installAgents } from './install'; +import { + agentTool, + defineAgent, + makeAgentRegistry, +} from '@spacetimedb/agents/kit'; +import { + callChat, + type ChatMessage, + type Provider, + type HttpLike, +} from '@spacetimedb/agents/openrouter'; +import { BUILT_IN_PROVIDERS } from '@spacetimedb/agents/providers'; +import { + BUILT_IN_EMBEDDING_PROVIDERS, + cosineSimilarity, + topKByScore, +} from '@spacetimedb/agents/embeddings'; +import { + runAgentLoop, + USER_CONTENT_MAX, + type LoopConfig, + type LoopMessage, + type LoopTx, +} from './loop'; +import { + augmentSystemWithSummary, + buildSummarizerUserContent, + pickSummarizationCandidates, +} from './summarize'; + +const ONE_SECOND_MICROS = 1_000_000n; +const DEFAULT_STALE_LOCK_THRESHOLD_SECS = 15 * 60; + +function throwSenderError(msg: string): never { + throw new SenderError(msg); +} + +const echo = agentTool( + 'echoes the given message back to the caller', + t.object('EchoArgs', { message: t.string() }), + (_ctx, args) => `echo: ${args.message}` +); + +const getTime = agentTool( + 'returns the current server time as an ISO-8601 string', + t.unit(), + ctx => { + const tx = ctx as { timestamp: { microsSinceUnixEpoch: bigint } }; + const micros = tx.timestamp.microsSinceUnixEpoch; + return new Date(Number(micros / 1000n)).toISOString(); + } +); + +const chatAgent = defineAgent({ + defaultModel: 'anthropic/claude-haiku-4.5', + defaultSystemPrompt: + 'You are a helpful assistant. Use tools when they make the answer better.', + defaultMaxTurns: 10, + defaultMaxHistoryMessages: 50, + defaultRetries: 2, + summarizerAgentName: 'summarizer', + embeddingsProvider: 'openai', + embeddingsModel: 'text-embedding-3-small', + ragTopK: 4, + tools: { + get_time: getTime, + echo, + }, +}); + +const summarizerAgent = defineAgent({ + defaultModel: 'anthropic/claude-haiku-4.5', + defaultSystemPrompt: + 'You produce concise running summaries of chat conversations. ' + + 'Capture facts, decisions, names, numbers, and ongoing tasks the ' + + 'main assistant must remember. Skip pleasantries. If the user ' + + 'provides an existing summary, EXTEND it with the new content. ' + + 'Do not restart from scratch and do not duplicate prior facts. ' + + 'Reply with the updated summary as plain prose, no preamble.', + defaultMaxTurns: 1, + defaultMaxHistoryMessages: 100, + defaultMaxTokens: 600, + defaultRetries: 2, + tools: {}, +}); + +const agents = { + chat: chatAgent, + summarizer: summarizerAgent, +}; + +import { + apiKey, + agentSecret, + agentAdminIdentity, + agentOverride, + thread, + message, + threadLock, + messageEmbedding, +} from './model'; + +const threadLockSweeperTick = table( + { name: 'thread_lock_sweeper_tick', scheduled: (): any => thread_lock_sweep }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + apiKey, + agentSecret, + agentAdminIdentity, + agentOverride, + thread, + message, + threadLock, + threadLockSweeperTick, + messageEmbedding, +}); +export default spacetimedb; + +type Schema = InferSchema; +type WriteCtx = TransactionCtx; + +const registry = makeAgentRegistry(agents); + +export const myThreads = spacetimedb.view( + { name: 'my_threads', public: true }, + t.array(thread.rowType), + ctx => [...ctx.db.thread.owner.filter(ctx.sender)] +); + +export const myMessages = spacetimedb.view( + { name: 'my_messages', public: true }, + t.array(message.rowType), + ctx => [...ctx.db.message.owner.filter(ctx.sender)] +); + +export const myThreadLocks = spacetimedb.view( + { name: 'my_thread_locks', public: true }, + t.array(threadLock.rowType), + ctx => [...ctx.db.threadLock.owner.filter(ctx.sender)] +); + +export const myMessageEmbeddings = spacetimedb.view( + { name: 'my_message_embeddings', public: true }, + t.array(messageEmbedding.rowType), + ctx => [...ctx.db.messageEmbedding.owner.filter(ctx.sender)] +); + +function requireAdmin(tx: WriteCtx): void { + if (tx.db.agentAdminIdentity.identity.find(tx.sender) == null) { + throwSenderError('agent.not_authorized'); + } +} + +type CallerCtx = ProcedureCtx | ReducerCtx; + +function callerIdentity(ctx: CallerCtx): Identity { + return ctx.sender; +} + +function requireOwnedThread(tx: WriteCtx, threadId: bigint, owner: Identity) { + const row = tx.db.thread.id.find(threadId); + if (!row) throwSenderError(`agent.thread_not_found:${threadId}`); + if (!row.owner.isEqual(owner)) { + throwSenderError(`agent.not_thread_owner:${threadId}`); + } + return row; +} + +export const init = spacetimedb.init(ctx => { + installAgents(ctx); +}); + +export const set_agent_secret = spacetimedb.reducer( + { staleLockThresholdSecs: t.option(t.u32()) }, + (ctx, args) => { + const staleLockThresholdSecs = + args.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS; + if (staleLockThresholdSecs === 0) { + throwSenderError('agent.invalid_stale_lock_threshold:must be > 0'); + } + + const tx = ctx; + requireAdmin(tx); + + const existing = tx.db.agentSecret.singleton.find(true); + const row = { + singleton: true, + staleLockThresholdSecs, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.agentSecret.singleton.update(row); + } else { + tx.db.agentSecret.insert(row); + } + } +); + +export const set_api_key = spacetimedb.reducer( + { provider: t.string(), key: t.string() }, + (ctx, args) => { + if (args.provider.length === 0) + throwSenderError('agent.invalid_provider:empty'); + if (args.key.length === 0) throwSenderError('agent.invalid_api_key:empty'); + if (!Object.hasOwn(BUILT_IN_PROVIDERS, args.provider)) { + throwSenderError(`agent.unknown_provider:${args.provider}`); + } + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.apiKey.provider.find(args.provider); + const row = { + provider: args.provider, + key: args.key, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.apiKey.provider.update(row); + } else { + tx.db.apiKey.insert(row); + } + } +); + +export const clear_api_key = spacetimedb.reducer( + { provider: t.string() }, + (ctx, { provider }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.apiKey.provider.find(provider); + if (existing) tx.db.apiKey.delete(existing); + } +); + +export const set_agent_override = spacetimedb.reducer( + { + agentName: t.string(), + provider: t.option(t.string()), + model: t.option(t.string()), + systemPrompt: t.option(t.string()), + maxTurns: t.option(t.u32()), + maxHistoryMessages: t.option(t.u32()), + maxTokens: t.option(t.u32()), + retries: t.option(t.u32()), + }, + (ctx, args) => { + if (!registry.has(args.agentName)) { + throwSenderError(`agent.unknown:${args.agentName}`); + } + if ( + args.provider !== undefined && + !Object.hasOwn(BUILT_IN_PROVIDERS, args.provider) + ) { + throwSenderError(`agent.unknown_provider:${args.provider}`); + } + if (args.maxTurns !== undefined && args.maxTurns === 0) { + throwSenderError('agent.invalid_max_turns:must be > 0'); + } + if ( + args.maxHistoryMessages !== undefined && + args.maxHistoryMessages === 0 + ) { + throwSenderError('agent.invalid_max_history:must be > 0'); + } + + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentOverride.agentName.find(args.agentName); + const row = { + agentName: args.agentName, + provider: args.provider, + model: args.model, + systemPrompt: args.systemPrompt, + maxTurns: args.maxTurns, + maxHistoryMessages: args.maxHistoryMessages, + maxTokens: args.maxTokens, + retries: args.retries, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.agentOverride.agentName.update(row); + } else { + tx.db.agentOverride.insert(row); + } + } +); + +export const clear_agent_override = spacetimedb.reducer( + { agentName: t.string() }, + (ctx, { agentName }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentOverride.agentName.find(agentName); + if (existing) tx.db.agentOverride.delete(existing); + } +); + +export const add_agent_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, { identity }) => { + const tx = ctx; + requireAdmin(tx); + if (tx.db.agentAdminIdentity.identity.find(identity) == null) { + tx.db.agentAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + } +); + +export const remove_agent_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, { identity }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.agentAdminIdentity.count() <= 1n) { + throwSenderError('agent.cannot_remove_last_admin'); + } + tx.db.agentAdminIdentity.delete(existing); + } +); + +export const get_agent_config_status = spacetimedb.procedure( + {}, + t.object('AgentConfigStatus', { + isConfigured: t.bool(), + staleLockThresholdSecs: t.u32(), + agents: t.array( + t.object('AgentInfo', { + name: t.string(), + defaultProvider: t.string(), + defaultModel: t.string(), + }) + ), + configuredProviders: t.array(t.string()), + }), + ctx => + ctx.withTx(tx => { + const secret = tx.db.agentSecret.singleton.find(true); + const configuredProviders = [...tx.db.apiKey.iter()] + .map(r => r.provider) + .sort(); + const agentInfos = registry.names().map(name => { + const def = registry.agentDef(name)!; + return { + name, + defaultProvider: def.defaultProvider, + defaultModel: def.defaultModel, + }; + }); + return { + isConfigured: secret != null, + staleLockThresholdSecs: + secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS, + agents: agentInfos, + configuredProviders, + }; + }) +); + +export const start_thread = spacetimedb.procedure( + { + agentName: t.string(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + metadata: t.option(t.string()), + }, + t.u64(), + (ctx, args) => { + const owner = callerIdentity(ctx); + if (!registry.has(args.agentName)) { + throwSenderError(`agent.unknown:${args.agentName}`); + } + return ctx.withTx(tx => { + const inserted = tx.db.thread.insert({ + id: 0n, + owner, + agentName: args.agentName, + title: args.title, + systemPromptOverride: args.systemPromptOverride, + modelOverride: undefined, + metadata: args.metadata, + summary: undefined, + summarizedThroughId: undefined, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + return inserted.id; + }); + } +); + +export const update_thread = spacetimedb.reducer( + { + threadId: t.u64(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + modelOverride: t.option(t.string()), + metadata: t.option(t.string()), + clearTitle: t.bool(), + clearSystemPromptOverride: t.bool(), + clearModelOverride: t.bool(), + clearMetadata: t.bool(), + }, + (ctx, args) => { + const owner = callerIdentity(ctx); + const tx = ctx; + const row = requireOwnedThread(tx, args.threadId, owner); + tx.db.thread.id.update({ + ...row, + title: args.clearTitle ? undefined : (args.title ?? row.title), + systemPromptOverride: args.clearSystemPromptOverride + ? undefined + : (args.systemPromptOverride ?? row.systemPromptOverride), + modelOverride: args.clearModelOverride + ? undefined + : (args.modelOverride ?? row.modelOverride), + metadata: args.clearMetadata + ? undefined + : (args.metadata ?? row.metadata), + updatedAt: tx.timestamp, + }); + } +); + +export const delete_thread = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const owner = callerIdentity(ctx); + const tx = ctx; + const row = requireOwnedThread(tx, threadId, owner); + if (tx.db.threadLock.threadId.find(threadId) != null) { + throwSenderError(`agent.thread_busy:${threadId}`); + } + for (const e of [...tx.db.messageEmbedding.threadId.filter(threadId)]) { + tx.db.messageEmbedding.delete(e); + } + for (const m of [...tx.db.message.threadId.filter(threadId)]) { + tx.db.message.delete(m); + } + tx.db.thread.delete(row); + } +); + +// Admin-gated and bypasses ownership, to clear a wedged lock. +export const clear_thread_lock = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const tx = ctx; + requireAdmin(tx); + const lock = tx.db.threadLock.threadId.find(threadId); + if (lock) tx.db.threadLock.delete(lock); + } +); + +export const request_cancel = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const owner = callerIdentity(ctx); + const tx = ctx; + requireOwnedThread(tx, threadId, owner); + const lock = tx.db.threadLock.threadId.find(threadId); + if (!lock) throwSenderError(`agent.thread_not_running:${threadId}`); + if (lock.cancelRequested) return; + tx.db.threadLock.threadId.update({ ...lock, cancelRequested: true }); + } +); + +function resolveProvider(name: string): Provider { + const p = BUILT_IN_PROVIDERS[name]; + if (!p) throwSenderError(`agent.unknown_provider:${name}`); + return p; +} + +function loadLoopConfigOrThrow( + tx: WriteCtx, + threadId: bigint, + owner: Identity +): { cfg: LoopConfig; agentName: string; owner: Identity } { + const threadRow = requireOwnedThread(tx, threadId, owner); + + const def = registry.agentDef(threadRow.agentName); + if (!def) { + throwSenderError(`agent.unknown:${threadRow.agentName}`); + } + + if (tx.db.threadLock.threadId.find(threadId) != null) { + throwSenderError(`agent.thread_busy:${threadId}`); + } + if (tx.db.agentSecret.singleton.find(true) == null) { + throwSenderError('agent.not_configured'); + } + + const override = tx.db.agentOverride.agentName.find(threadRow.agentName); + const providerName = override?.provider ?? def.defaultProvider; + const provider = resolveProvider(providerName); + + const keyRow = tx.db.apiKey.provider.find(providerName); + if (!keyRow) throwSenderError(`agent.no_api_key:${providerName}`); + + return { + cfg: { + provider, + apiKey: keyRow.key, + model: threadRow.modelOverride ?? override?.model ?? def.defaultModel, + systemPrompt: + threadRow.systemPromptOverride ?? + override?.systemPrompt ?? + def.defaultSystemPrompt, + maxTurns: override?.maxTurns ?? def.defaultMaxTurns, + maxHistoryMessages: + override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages, + maxTokens: override?.maxTokens ?? def.defaultMaxTokens, + retries: override?.retries ?? def.defaultRetries, + responseFormat: def.defaultResponseFormat, + }, + agentName: threadRow.agentName, + owner: threadRow.owner, + }; +} + +function augmentSystemWithRag( + base: string | undefined, + snippets: string[] +): string | undefined { + if (snippets.length === 0) return base; + const b = base ?? ''; + return `${b}\n\n## Relevant earlier messages\n${snippets.join('\n---\n')}`.trim(); +} + +type ProcLikeCtx = { + http: HttpLike; + withTx: (fn: (tx: WriteCtx) => R) => R; +}; + +function threadMessagesAscending(tx: WriteCtx, threadId: bigint) { + const rows = [...tx.db.message.threadId.filter(threadId)]; + rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return rows; +} + +function toLoopMessage(r: { + id: bigint; + threadId: bigint; + role: string; + content: string; + toolCallsJson: string | undefined; + toolCallId: string | undefined; + isError: boolean; + promptTokens: number | undefined; + completionTokens: number | undefined; +}): LoopMessage { + return { + id: r.id, + threadId: r.threadId, + role: r.role, + content: r.content, + toolCallsJson: r.toolCallsJson, + toolCallId: r.toolCallId, + isError: r.isError, + promptTokens: r.promptTokens, + completionTokens: r.completionTokens, + }; +} + +function maybeEmbedMessage( + ctx: ProcLikeCtx, + threadId: bigint, + messageId: bigint +): void { + const job = ctx.withTx(tx => { + if (tx.db.messageEmbedding.messageId.find(messageId) != null) return null; + const msg = tx.db.message.id.find(messageId); + if (!msg) return null; + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return null; + const def = registry.agentDef(threadRow.agentName); + if (!def?.embeddingsProvider || !def.embeddingsModel) return null; + const provider = BUILT_IN_EMBEDDING_PROVIDERS[def.embeddingsProvider]; + if (!provider) return null; + const keyRow = tx.db.apiKey.provider.find(def.embeddingsProvider); + if (!keyRow) return null; + return { + provider, + apiKey: keyRow.key, + model: def.embeddingsModel, + content: msg.content, + owner: msg.owner, + }; + }); + if (!job) return; + + const result = job.provider.embed(ctx.http, job.apiKey, job.model, [ + job.content, + ]); + if (!result.ok || result.vectors.length === 0) { + console.warn( + `embedding failed: ${result.ok ? 'no vectors' : result.error.kind}` + ); + return; + } + ctx.withTx(tx => { + if (tx.db.messageEmbedding.messageId.find(messageId) != null) return; + tx.db.messageEmbedding.insert({ + messageId, + threadId, + owner: job.owner, + model: job.model, + vector: result.vectors[0], + createdAt: tx.timestamp, + }); + }); +} + +function maybeRetrieveRag(ctx: ProcLikeCtx, threadId: bigint): string[] { + return ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return []; + const def = registry.agentDef(threadRow.agentName); + if (!def || def.ragTopK <= 0) return []; + + const msgs = threadMessagesAscending(tx, threadId); + let queryMsg = undefined as (typeof msgs)[number] | undefined; + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role === 'user') { + queryMsg = msgs[i]; + break; + } + } + if (!queryMsg) return []; + const queryEmb = tx.db.messageEmbedding.messageId.find(queryMsg.id); + if (!queryEmb) return []; + + const override = tx.db.agentOverride.agentName.find(threadRow.agentName); + const maxHistory = + override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages; + const windowStartIdx = Math.max(0, msgs.length - maxHistory); + const inWindowIds = new Set(msgs.slice(windowStartIdx).map(m => m.id)); + + const candidates = [ + ...tx.db.messageEmbedding.threadId.filter(threadId), + ].filter( + e => !inWindowIds.has(e.messageId) && e.messageId !== queryMsg!.id + ); + const top = topKByScore( + candidates, + e => cosineSimilarity(queryEmb.vector, e.vector), + def.ragTopK + ).filter(x => x.score > 0); + + const out: string[] = []; + for (const { item } of top) { + const m = tx.db.message.id.find(item.messageId); + if (m) out.push(`[${m.role}] ${m.content}`); + } + return out; + }); +} + +function maybeRunSummarization(ctx: ProcLikeCtx, threadId: bigint): void { + const decision = ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return null; + const def = registry.agentDef(threadRow.agentName); + if (!def?.summarizerAgentName) return null; + const sumDef = registry.agentDef(def.summarizerAgentName); + if (!sumDef) return null; + + const override = tx.db.agentOverride.agentName.find(threadRow.agentName); + const maxHistory = + override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages; + + const rows = threadMessagesAscending(tx, threadId).map(toLoopMessage); + + const candidates = pickSummarizationCandidates( + rows, + maxHistory, + threadRow.summarizedThroughId ?? null + ); + if (!candidates) return null; + + const sumOverride = tx.db.agentOverride.agentName.find( + def.summarizerAgentName + ); + const sumProviderName = sumOverride?.provider ?? sumDef.defaultProvider; + const sumProvider = BUILT_IN_PROVIDERS[sumProviderName]; + if (!sumProvider) return null; + const keyRow = tx.db.apiKey.provider.find(sumProviderName); + if (!keyRow) return null; + + return { + provider: sumProvider, + apiKey: keyRow.key, + sumModel: sumOverride?.model ?? sumDef.defaultModel, + sumSystemPrompt: sumOverride?.systemPrompt ?? sumDef.defaultSystemPrompt, + sumMaxTokens: sumOverride?.maxTokens ?? sumDef.defaultMaxTokens, + sumRetries: sumOverride?.retries ?? sumDef.defaultRetries, + existingSummary: threadRow.summary ?? null, + newDropped: candidates.newDropped, + lastNewId: candidates.lastNewId, + }; + }); + if (!decision) return; + + const userContent = buildSummarizerUserContent( + decision.existingSummary, + decision.newDropped + ); + const messages: ChatMessage[] = [{ role: 'user', content: userContent }]; + const result = callChat(ctx.http, decision.provider, { + apiKey: decision.apiKey, + model: decision.sumModel, + system: decision.sumSystemPrompt, + messages, + maxTokens: decision.sumMaxTokens, + retries: decision.sumRetries, + }); + if (!result.ok || !result.response.text) { + console.warn( + `summarization failed: ${result.ok ? 'no text in response' : result.error.kind}` + ); + return; + } + + ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return; + tx.db.thread.id.update({ + ...threadRow, + summary: result.response.text!, + summarizedThroughId: decision.lastNewId, + updatedAt: tx.timestamp, + }); + }); +} + +function adaptTx(tx: WriteCtx, agentName: string, owner: Identity): LoopTx { + return { + listMessages(threadId: bigint): LoopMessage[] { + return threadMessagesAscending(tx, threadId).map(toLoopMessage); + }, + appendMessage(row) { + tx.db.message.insert({ + id: 0n, + threadId: row.threadId, + owner, + role: row.role, + content: row.content, + toolCallsJson: row.toolCallsJson, + toolCallId: row.toolCallId, + isError: row.isError, + promptTokens: row.promptTokens, + completionTokens: row.completionTokens, + createdAt: tx.timestamp, + }); + }, + bumpThread(threadId: bigint): void { + const r = tx.db.thread.id.find(threadId); + if (r) tx.db.thread.id.update({ ...r, updatedAt: tx.timestamp }); + }, + invokeTool(name: string, inputJson: string) { + return registry.invoke(agentName, tx, name, inputJson); + }, + isCancelRequested(threadId: bigint): boolean { + const lock = tx.db.threadLock.threadId.find(threadId); + return lock != null && lock.cancelRequested; + }, + }; +} + +function runLockedLoop( + ctx: ProcLikeCtx, + cfg: LoopConfig, + agentName: string, + owner: Identity, + threadId: bigint +): void { + try { + maybeRunSummarization(ctx, threadId); + const ragSnippets = maybeRetrieveRag(ctx, threadId); + + const finalCfg = ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return cfg; + let systemPrompt = cfg.systemPrompt; + systemPrompt = augmentSystemWithSummary( + systemPrompt, + threadRow.summary ?? null + ); + systemPrompt = augmentSystemWithRag(systemPrompt, ragSnippets); + return { ...cfg, systemPrompt }; + }); + + runAgentLoop({ + http: ctx.http, + withTx: (fn: (lt: LoopTx) => R): R => + ctx.withTx(tx => fn(adaptTx(tx, agentName, owner))), + llmToolDefs: registry.llmToolDefsFor(agentName), + cfg: finalCfg, + threadId, + }); + } finally { + ctx.withTx(tx => { + const lock = tx.db.threadLock.threadId.find(threadId); + if (lock) tx.db.threadLock.delete(lock); + }); + } +} + +export const send_message = spacetimedb.procedure( + { threadId: t.u64(), content: t.string() }, + t.unit(), + (ctx, args) => { + if (args.content.length === 0) { + throwSenderError('agent.empty_message'); + } + const content = + args.content.length > USER_CONTENT_MAX + ? args.content.slice(0, USER_CONTENT_MAX) + '...[truncated]' + : args.content; + + const owner = callerIdentity(ctx); + const { + cfg, + agentName, + owner: threadOwner, + userMessageId, + } = ctx.withTx(tx => { + const loaded = loadLoopConfigOrThrow(tx, args.threadId, owner); + tx.db.threadLock.insert({ + threadId: args.threadId, + owner: loaded.owner, + lockedAt: tx.timestamp, + cancelRequested: false, + }); + const inserted = tx.db.message.insert({ + id: 0n, + threadId: args.threadId, + owner: loaded.owner, + role: 'user', + content, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + createdAt: tx.timestamp, + }); + const threadRow = tx.db.thread.id.find(args.threadId); + if (threadRow) + tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp }); + return { ...loaded, userMessageId: inserted.id }; + }); + + maybeEmbedMessage(ctx, args.threadId, userMessageId); + runLockedLoop(ctx, cfg, agentName, threadOwner, args.threadId); + return {}; + } +); + +export const regenerate_response = spacetimedb.procedure( + { threadId: t.u64() }, + t.unit(), + (ctx, { threadId }) => { + const owner = callerIdentity(ctx); + const { + cfg, + agentName, + owner: threadOwner, + } = ctx.withTx(tx => { + const loaded = loadLoopConfigOrThrow(tx, threadId, owner); + + const rows = threadMessagesAscending(tx, threadId); + let lastUserMsgId: bigint | undefined; + for (const r of rows) { + if (r.role === 'user') lastUserMsgId = r.id; + } + if (lastUserMsgId === undefined) { + throwSenderError(`agent.regenerate_no_user_message:${threadId}`); + } + + for (const r of rows) { + if (r.id > lastUserMsgId!) tx.db.message.delete(r); + } + + tx.db.threadLock.insert({ + threadId, + owner: loaded.owner, + lockedAt: tx.timestamp, + cancelRequested: false, + }); + const threadRow = tx.db.thread.id.find(threadId); + if (threadRow) + tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp }); + return loaded; + }); + + runLockedLoop(ctx, cfg, agentName, threadOwner, threadId); + return {}; + } +); + +export const generate_thread_title = spacetimedb.procedure( + { threadId: t.u64() }, + t.unit(), + (ctx, { threadId }) => { + const owner = callerIdentity(ctx); + const job = ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return null; + if (!threadRow.owner.isEqual(owner)) { + throwSenderError(`agent.not_thread_owner:${threadId}`); + } + if (threadRow.title != null && threadRow.title.length > 0) return null; + + const def = registry.agentDef(threadRow.agentName); + if (!def) return null; + const sumName = def.summarizerAgentName ?? threadRow.agentName; + const sumDef = registry.agentDef(sumName); + if (!sumDef) return null; + + const override = tx.db.agentOverride.agentName.find(sumName); + const providerName = override?.provider ?? sumDef.defaultProvider; + const provider = BUILT_IN_PROVIDERS[providerName]; + if (!provider) return null; + const keyRow = tx.db.apiKey.provider.find(providerName); + if (!keyRow) return null; + + const msgs = threadMessagesAscending(tx, threadId); + const firstUser = msgs.find(m => m.role === 'user'); + if (!firstUser) return null; + + return { + provider, + apiKey: keyRow.key, + model: override?.model ?? sumDef.defaultModel, + retries: override?.retries ?? sumDef.defaultRetries, + firstMessage: firstUser.content, + }; + }); + if (!job) return {}; + + const result = callChat(ctx.http, job.provider, { + apiKey: job.apiKey, + model: job.model, + system: + 'You title chat conversations. The user will paste the opening message of ' + + 'a chat. You output a 3-5 word title describing the topic. ' + + 'CRITICAL: do not answer or respond to the message. Do not greet. ' + + 'Output the title and only the title. No quotes, no punctuation at the end.', + messages: [ + { + role: 'user', + content: `Title for a chat that starts with this message:\n\n\n${job.firstMessage}\n`, + }, + ], + maxTokens: 30, + retries: job.retries, + }); + if (!result.ok || !result.response.text) { + console.warn( + `title gen failed: ${result.ok ? 'no text' : result.error.kind}` + ); + return {}; + } + + const cleaned = result.response.text + .trim() + .replace(/^["']|["']$/g, '') + .replace(/[.!?]+$/g, '') + .slice(0, 80); + + ctx.withTx(tx => { + const t2 = tx.db.thread.id.find(threadId); + if (!t2 || (t2.title != null && t2.title.length > 0)) return; + tx.db.thread.id.update({ + ...t2, + title: cleaned, + updatedAt: tx.timestamp, + }); + }); + return {}; + } +); + +export const thread_lock_sweep = spacetimedb.reducer( + { arg: threadLockSweeperTick.rowType }, + (ctx, _arg) => { + const secret = ctx.db.agentSecret.singleton.find(true); + const thresholdSecs = + secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS; + const thresholdMicros = BigInt(thresholdSecs) * ONE_SECOND_MICROS; + + const cutoffMicros = staleLockCutoffMicros( + ctx.timestamp.microsSinceUnixEpoch, + thresholdMicros + ); + deleteStaleThreadLocks( + ctx.db.threadLock.lockedAt.filter( + new Range(undefined, { + tag: 'excluded', + value: new Timestamp(cutoffMicros), + }) + ), + cutoffMicros, + lock => ctx.db.threadLock.delete(lock) + ); + } +); diff --git a/spacetime-agents-ts/spacetimedb/src/install.ts b/spacetime-agents-ts/spacetimedb/src/install.ts new file mode 100644 index 00000000000..148053285bb --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/src/install.ts @@ -0,0 +1,22 @@ +import { ScheduleAt } from 'spacetimedb'; +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import type spacetimedb from './index'; + +const ONE_SECOND_MICROS = 1_000_000n; +const SWEEPER_INTERVAL_MICROS = 60n * ONE_SECOND_MICROS; + +type Schema = InferSchema; +type InstallCtx = ReducerCtx; + +export function installAgents(ctx: InstallCtx) { + if (ctx.db.agentAdminIdentity.identity.find(ctx.sender) == null) { + ctx.db.agentAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + ctx.db.threadLockSweeperTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(SWEEPER_INTERVAL_MICROS), + }); +} diff --git a/spacetime-agents-ts/spacetimedb/src/loop.ts b/spacetime-agents-ts/spacetimedb/src/loop.ts new file mode 100644 index 00000000000..57c776243c0 --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/src/loop.ts @@ -0,0 +1,231 @@ +import { + callChat, + type ChatMessage, + type HttpLike, + type Provider, + type ResponseFormat, + type ToolCall, + type ToolDefinition, +} from '@spacetimedb/agents/openrouter'; + +export const USER_CONTENT_MAX = 32_000; +export const TOOL_RESULT_MAX = 64_000; + +export interface LoopConfig { + provider: Provider; + apiKey: string; + model: string; + systemPrompt: string | undefined; + maxTurns: number; + maxHistoryMessages: number; + maxTokens: number | undefined; + retries: number; + responseFormat: ResponseFormat | undefined; +} + +export interface LoopMessage { + id: bigint; + threadId: bigint; + role: string; + content: string; + toolCallsJson: string | undefined; + toolCallId: string | undefined; + isError: boolean; + promptTokens: number | undefined; + completionTokens: number | undefined; +} + +export type AppendMessageRow = Omit; + +export interface LoopTx { + listMessages(threadId: bigint): LoopMessage[]; + appendMessage(row: AppendMessageRow): void; + bumpThread(threadId: bigint): void; + invokeTool( + name: string, + inputJson: string + ): { result: string; isError: boolean }; + isCancelRequested(threadId: bigint): boolean; +} + +export interface RunAgentLoopOptions { + http: HttpLike; + withTx: (fn: (tx: LoopTx) => R) => R; + llmToolDefs: ToolDefinition[]; + cfg: LoopConfig; + threadId: bigint; +} + +function clip(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}...[truncated]`; +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}...`; +} + +function formatChatError(error: { + kind: string; + status?: number; + message?: string; + body?: string; +}): string { + switch (error.kind) { + case 'http': + return `agent.provider_http:${error.status}:${truncate(error.body ?? '', 500)}`; + case 'transport': + return `agent.provider_transport:${error.message ?? 'unknown'}`; + case 'parse': + return `agent.provider_parse:${error.message ?? 'unknown'}`; + default: + return `agent.provider_error:${error.kind}`; + } +} + +function buildLlmMessages( + tx: LoopTx, + threadId: bigint, + maxHistoryMessages: number +): ChatMessage[] { + const all = tx.listMessages(threadId); + const window = + maxHistoryMessages > 0 && all.length > maxHistoryMessages + ? all.slice(all.length - maxHistoryMessages) + : all; + const messages: ChatMessage[] = []; + const knownToolCallIds = new Set(); + + for (const row of window) { + if (row.role === 'user') { + messages.push({ role: 'user', content: row.content }); + } else if (row.role === 'assistant') { + let toolCalls: ToolCall[] | undefined; + if (row.toolCallsJson != null) { + try { + toolCalls = JSON.parse(row.toolCallsJson) as ToolCall[]; + } catch { + toolCalls = undefined; + } + } + const message: ChatMessage = { role: 'assistant', content: row.content }; + if (toolCalls && toolCalls.length > 0) { + message.tool_calls = toolCalls; + for (const call of toolCalls) knownToolCallIds.add(call.id); + } + messages.push(message); + } else if (row.role === 'tool') { + const toolCallId = row.toolCallId ?? ''; + if (!knownToolCallIds.has(toolCallId)) continue; + messages.push({ + role: 'tool', + tool_call_id: toolCallId, + content: row.content, + }); + } + } + return messages; +} + +function runOneTurn(options: RunAgentLoopOptions): boolean { + const { http, withTx, llmToolDefs, cfg, threadId } = options; + const cancelled = withTx(tx => { + if (!tx.isCancelRequested(threadId)) return false; + tx.appendMessage({ + threadId, + role: 'assistant', + content: 'agent.cancelled', + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }); + tx.bumpThread(threadId); + return true; + }); + if (cancelled) return false; + + const llmMessages = withTx(tx => + buildLlmMessages(tx, threadId, cfg.maxHistoryMessages) + ); + const result = callChat(http, cfg.provider, { + apiKey: cfg.apiKey, + model: cfg.model, + system: cfg.systemPrompt, + messages: llmMessages, + tools: llmToolDefs, + maxTokens: cfg.maxTokens, + responseFormat: cfg.responseFormat, + retries: cfg.retries, + }); + + if (!result.ok) { + withTx(tx => + tx.appendMessage({ + threadId, + role: 'assistant', + content: formatChatError(result.error), + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }) + ); + return false; + } + + const { text, toolCalls, finishReason, usage } = result.response; + const hasToolCalls = toolCalls.length > 0; + withTx(tx => { + tx.appendMessage({ + threadId, + role: 'assistant', + content: text ?? '', + toolCallsJson: hasToolCalls ? JSON.stringify(toolCalls) : undefined, + toolCallId: undefined, + isError: false, + promptTokens: usage.promptTokens > 0 ? usage.promptTokens : undefined, + completionTokens: + usage.completionTokens > 0 ? usage.completionTokens : undefined, + }); + if (hasToolCalls) { + for (const call of toolCalls) { + const invocation = tx.invokeTool( + call.function.name, + call.function.arguments + ); + tx.appendMessage({ + threadId, + role: 'tool', + content: clip(invocation.result, TOOL_RESULT_MAX), + toolCallsJson: undefined, + toolCallId: call.id, + isError: invocation.isError, + promptTokens: undefined, + completionTokens: undefined, + }); + } + } + tx.bumpThread(threadId); + }); + return hasToolCalls && finishReason === 'tool_calls'; +} + +export function runAgentLoop(options: RunAgentLoopOptions): void { + for (let turn = 0; turn < options.cfg.maxTurns; turn++) { + if (!runOneTurn(options)) return; + } + options.withTx(tx => + tx.appendMessage({ + threadId: options.threadId, + role: 'assistant', + content: `agent.max_turns_exceeded:${options.cfg.maxTurns}`, + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }) + ); +} diff --git a/spacetime-agents-ts/spacetimedb/src/model.ts b/spacetime-agents-ts/spacetimedb/src/model.ts new file mode 100644 index 00000000000..8d16d5d2200 --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/src/model.ts @@ -0,0 +1,100 @@ +import { table, t } from 'spacetimedb/server'; + +export const apiKey = table( + { name: 'api_key', public: false }, + { + provider: t.string().primaryKey(), + key: t.string(), + updatedAt: t.timestamp(), + } +); + +export const agentSecret = table( + { name: 'agent_secret', public: false }, + { + singleton: t.bool().primaryKey(), + staleLockThresholdSecs: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const agentAdminIdentity = table( + { name: 'agent_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +// Effective config precedence: thread > override > code default. +export const agentOverride = table( + { name: 'agent_override', public: true }, + { + agentName: t.string().primaryKey(), + provider: t.option(t.string()), + model: t.option(t.string()), + systemPrompt: t.option(t.string()), + maxTurns: t.option(t.u32()), + maxHistoryMessages: t.option(t.u32()), + maxTokens: t.option(t.u32()), + retries: t.option(t.u32()), + updatedAt: t.timestamp(), + } +); + +export const thread = table( + { name: 'thread', public: false }, + { + id: t.u64().primaryKey().autoInc(), + owner: t.identity().index(), + agentName: t.string().index(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + modelOverride: t.option(t.string()), + metadata: t.option(t.string()), + summary: t.option(t.string()), + summarizedThroughId: t.option(t.u64()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +// owner denormalized from thread so the visibility view can filter on it. +export const message = table( + { name: 'message', public: false }, + { + id: t.u64().primaryKey().autoInc(), + threadId: t.u64().index(), + owner: t.identity().index(), + role: t.string(), + content: t.string(), + toolCallsJson: t.option(t.string()), + toolCallId: t.option(t.string()), + isError: t.bool(), + promptTokens: t.option(t.u32()), + completionTokens: t.option(t.u32()), + createdAt: t.timestamp(), + } +); +// Presence of a row is the per-thread mutex: a loop is running for it. +export const threadLock = table( + { name: 'thread_lock', public: false }, + { + threadId: t.u64().primaryKey(), + owner: t.identity().index(), + lockedAt: t.timestamp().index('btree'), + cancelRequested: t.bool(), + } +); + +export const messageEmbedding = table( + { name: 'message_embedding', public: false }, + { + messageId: t.u64().primaryKey(), + threadId: t.u64().index(), + owner: t.identity().index(), + model: t.string(), + vector: t.array(t.f32()), + createdAt: t.timestamp(), + } +); diff --git a/spacetime-agents-ts/spacetimedb/src/submodule.ts b/spacetime-agents-ts/spacetimedb/src/submodule.ts new file mode 100644 index 00000000000..9e36d7593ed --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/src/submodule.ts @@ -0,0 +1,25 @@ +export { default } from './index'; +export { installAgents } from './install'; +export { + add_agent_admin_identity, + clear_agent_override, + clear_api_key, + clear_thread_lock, + delete_thread, + generate_thread_title, + get_agent_config_status, + myMessageEmbeddings, + myMessages, + myThreadLocks, + myThreads, + regenerate_response, + remove_agent_admin_identity, + request_cancel, + send_message, + set_agent_override, + set_agent_secret, + set_api_key, + start_thread, + thread_lock_sweep, + update_thread, +} from './index'; diff --git a/spacetime-agents-ts/spacetimedb/src/summarize.ts b/spacetime-agents-ts/spacetimedb/src/summarize.ts new file mode 100644 index 00000000000..c4814b3dd14 --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/src/summarize.ts @@ -0,0 +1,66 @@ +import type { LoopMessage } from './loop'; + +export function pickSummarizationCandidates( + messages: LoopMessage[], + maxHistoryMessages: number, + summarizedThroughId: bigint | null +): { newDropped: LoopMessage[]; lastNewId: bigint } | null { + if (messages.length <= maxHistoryMessages) return null; + const dropped = messages.slice(0, messages.length - maxHistoryMessages); + const newDropped = + summarizedThroughId == null + ? dropped + : dropped.filter(message => message.id > summarizedThroughId); + if (newDropped.length === 0) return null; + return { newDropped, lastNewId: newDropped[newDropped.length - 1]!.id }; +} + +export function formatMessagesForSummarizer(messages: LoopMessage[]): string { + const lines: string[] = []; + for (const message of messages) { + if (message.role === 'user') { + lines.push(`User: ${message.content}`); + } else if (message.role === 'assistant') { + if (message.toolCallsJson != null) { + try { + const calls = JSON.parse(message.toolCallsJson) as Array<{ + function?: { name?: string; arguments?: string }; + }>; + for (const call of calls) { + lines.push( + `[Assistant called tool ${call.function?.name ?? '?'}(${call.function?.arguments ?? ''})]` + ); + } + } catch { + // Preserve the assistant text when stored tool metadata is malformed. + } + } + if (message.content) lines.push(`Assistant: ${message.content}`); + } else if (message.role === 'tool') { + lines.push(`[Tool result: ${message.content}]`); + } + } + return lines.join('\n'); +} + +export function buildSummarizerUserContent( + existingSummary: string | null, + newDropped: LoopMessage[] +): string { + const formatted = formatMessagesForSummarizer(newDropped); + if (existingSummary) { + return ( + `Existing summary:\n${existingSummary}\n\n` + + `Additional messages to fold into the summary:\n${formatted}` + ); + } + return `Messages to summarize:\n${formatted}`; +} + +export function augmentSystemWithSummary( + baseSystem: string | undefined, + summary: string | null +): string | undefined { + if (!summary) return baseSystem; + return `${baseSystem ?? ''}\n\n## Summary of earlier conversation\n${summary}`.trim(); +} diff --git a/spacetime-agents-ts/spacetimedb/tsconfig.json b/spacetime-agents-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..8d8f9b03455 --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/spacetime-agents-ts/src/embeddings.ts b/spacetime-agents-ts/src/embeddings.ts new file mode 100644 index 00000000000..249c3b78d8f --- /dev/null +++ b/spacetime-agents-ts/src/embeddings.ts @@ -0,0 +1,196 @@ +import type { HttpLike } from './openrouter.ts'; + +export interface EmbeddingProvider { + name: string; + embed( + http: HttpLike, + apiKey: string, + model: string, + texts: string[] + ): EmbeddingResult; +} + +export type EmbeddingResult = + | { + ok: true; + vectors: number[][]; + model: string; + usage: { promptTokens: number; totalTokens: number }; + } + | { + ok: false; + error: + | { kind: 'http'; status: number; body: string } + | { kind: 'transport'; message: string } + | { kind: 'parse'; message: string; body: string }; + }; + +function asObject(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function tokenCount(value: unknown): number { + const count = typeof value === 'number' ? value : Number(value); + return Number.isFinite(count) && count >= 0 + ? Math.min(Math.trunc(count), Number.MAX_SAFE_INTEGER) + : 0; +} + +function postOpenAiEmbeddings( + http: HttpLike, + url: string, + apiKey: string, + model: string, + texts: string[] +): EmbeddingResult { + if ( + texts.length === 0 || + texts.length > 100 || + texts.some(text => text.length > 32_768) + ) { + return { + ok: false, + error: { kind: 'parse', message: 'invalid embedding input', body: '' }, + }; + } + if (texts.reduce((total, value) => total + value.length, 0) > 262_144) { + return { + ok: false, + error: { + kind: 'parse', + message: 'embedding input is too large', + body: '', + }, + }; + } + let res: { status: number; text(): string }; + try { + res = http.fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model, input: texts }), + }); + } catch (err) { + return { + ok: false, + error: { + kind: 'transport', + message: err instanceof Error ? err.message : String(err), + }, + }; + } + const text = res.text(); + if (res.status < 200 || res.status >= 300) { + return { + ok: false, + error: { kind: 'http', status: res.status, body: text.slice(0, 65_536) }, + }; + } + try { + if (text.length > 4 * 1024 * 1024) + throw new Error('embedding response is too large'); + const parsed: unknown = JSON.parse(text); + const root = asObject(parsed); + if (!root) throw new Error('embedding response must be an object'); + const data = root?.data; + if (!Array.isArray(data)) throw new Error('no data array'); + const vectors: number[][] = data.map(value => { + const row = asObject(value); + if (!Array.isArray(row?.embedding)) throw new Error('missing embedding'); + if (row.embedding.length === 0 || row.embedding.length > 16_384) { + throw new Error('invalid embedding dimensions'); + } + if ( + !row.embedding.every( + component => + typeof component === 'number' && Number.isFinite(component) + ) + ) { + throw new Error('embedding contains a non-finite value'); + } + return row.embedding as number[]; + }); + const usage = asObject(root.usage); + return { + ok: true, + vectors, + model: String(root.model ?? model), + usage: { + promptTokens: tokenCount(usage?.prompt_tokens), + totalTokens: tokenCount(usage?.total_tokens), + }, + }; + } catch (err) { + return { + ok: false, + error: { + kind: 'parse', + message: err instanceof Error ? err.message : String(err), + body: text.slice(0, 65_536), + }, + }; + } +} + +export const openAiEmbeddingsProvider: EmbeddingProvider = { + name: 'openai', + embed: (http, apiKey, model, texts) => + postOpenAiEmbeddings( + http, + 'https://api.openai.com/v1/embeddings', + apiKey, + model, + texts + ), +}; + +export const openRouterEmbeddingsProvider: EmbeddingProvider = { + name: 'openrouter', + embed: (http, apiKey, model, texts) => + postOpenAiEmbeddings( + http, + 'https://openrouter.ai/api/v1/embeddings', + apiKey, + model, + texts + ), +}; + +export const BUILT_IN_EMBEDDING_PROVIDERS: Record = { + openai: openAiEmbeddingsProvider, + openrouter: openRouterEmbeddingsProvider, +}; + +export function cosineSimilarity( + a: ArrayLike, + b: ArrayLike +): number { + if (a.length !== b.length || a.length === 0) return 0; + let dot = 0, + na = 0, + nb = 0; + for (let i = 0; i < a.length; i++) { + const x = a[i], + y = b[i]; + dot += x * y; + na += x * x; + nb += y * y; + } + const denom = Math.sqrt(na) * Math.sqrt(nb); + return denom === 0 ? 0 : dot / denom; +} + +export function topKByScore( + items: T[], + scoreFn: (t: T) => number, + k: number +): { item: T; score: number }[] { + const scored = items.map(item => ({ item, score: scoreFn(item) })); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, Math.max(0, k)); +} diff --git a/spacetime-agents-ts/src/index.ts b/spacetime-agents-ts/src/index.ts new file mode 100644 index 00000000000..afcf0f80b5a --- /dev/null +++ b/spacetime-agents-ts/src/index.ts @@ -0,0 +1,46 @@ +export { + agentTool, + makeAgentDispatch, + defineAgent, + makeAgentRegistry, + typeBuilderToJsonSchema, +} from './kit.ts'; +export type { + AgentTool, + AgentDefinition, + AgentRegistry, + InvokeResult, + ToolMap, +} from './kit.ts'; + +export { callChat, isRetryableError } from './openrouter.ts'; +export type { + HttpLike, + ChatMessage, + ContentBlock, + ToolCall, + ToolDefinition, + ChatRequest, + ChatResponse, + ChatError, + ChatResult, + ResponseFormat, + Provider, + ParsedResponse, +} from './openrouter.ts'; + +export { + openRouterProvider, + openAiProvider, + anthropicProvider, + BUILT_IN_PROVIDERS, +} from './providers.ts'; + +export { + cosineSimilarity, + topKByScore, + openAiEmbeddingsProvider, + openRouterEmbeddingsProvider, + BUILT_IN_EMBEDDING_PROVIDERS, +} from './embeddings.ts'; +export type { EmbeddingProvider, EmbeddingResult } from './embeddings.ts'; diff --git a/spacetime-agents-ts/src/kit.ts b/spacetime-agents-ts/src/kit.ts new file mode 100644 index 00000000000..5406472e5a5 --- /dev/null +++ b/spacetime-agents-ts/src/kit.ts @@ -0,0 +1,617 @@ +import type { ElementsObj, Infer as InferBuilder } from 'spacetimedb/server'; +import type { ToolDefinition, ResponseFormat } from './openrouter'; + +type TypeBuilderLike = ElementsObj[string]; +type IsUnit = [keyof T] extends [never] ? true : false; + +type RunFn = + IsUnit> extends true + ? (ctx: unknown) => string + : (ctx: unknown, args: InferBuilder) => string; + +const DESC_KEY = Symbol.for('agents-ts/description'); +const RUN_KEY = Symbol.for('agents-ts/run'); +const MAX_TOOLS = 64; +const MAX_TOOL_DESCRIPTION_LENGTH = 1024; +const MAX_TOOL_INPUT_JSON_LENGTH = 64 * 1024; +const MAX_TOOL_RESULT_LENGTH = 64 * 1024; +const MAX_TOOL_ARRAY_LENGTH = 1000; + +export type AgentTool = TB & { + [DESC_KEY]: string; + [RUN_KEY]: RunFn; +}; + +export function agentTool( + description: string, + args: TB, + run: RunFn +): AgentTool { + const normalizedDescription = description.trim(); + if ( + normalizedDescription.length === 0 || + normalizedDescription.length > MAX_TOOL_DESCRIPTION_LENGTH + ) { + throw new Error('agentTool description must contain 1 to 1024 characters'); + } + Object.defineProperty(args, DESC_KEY, { + value: normalizedDescription, + enumerable: false, + configurable: false, + writable: false, + }); + Object.defineProperty(args, RUN_KEY, { + value: run, + enumerable: false, + configurable: false, + writable: false, + }); + return args as AgentTool; +} + +export type InvokeResult = { result: string; isError: boolean }; + +export function makeAgentDispatch< + Tx, + T extends Record>, +>(tools: T) { + if (Object.keys(tools).length > MAX_TOOLS) { + throw new Error(`an agent may define at most ${MAX_TOOLS} tools`); + } + const llmToolDefs: ToolDefinition[] = []; + for (const [name, tool] of Object.entries(tools)) { + if (!isValidToolName(name)) { + throw new Error( + `agentTool name '${name}' must match /^[a-zA-Z0-9_-]{1,64}$/` + ); + } + llmToolDefs.push({ + type: 'function', + function: { + name, + description: tool[DESC_KEY], + parameters: typeBuilderToJsonSchema(tool), + }, + }); + } + + function invoke(ctx: Tx, name: string, inputJson: string): InvokeResult { + if (!Object.hasOwn(tools, name)) { + return { result: `unknown tool: ${name}`, isError: true }; + } + const tool = (tools as Record>)[name]; + if (!tool) return { result: `unknown tool: ${name}`, isError: true }; + + if (inputJson.length > MAX_TOOL_INPUT_JSON_LENGTH) { + return { result: 'tool input exceeds 65536 characters', isError: true }; + } + let parsed: unknown; + try { + const decoded: unknown = inputJson === '' ? {} : JSON.parse(inputJson); + parsed = validateToolValue(tool.algebraicType, decoded, '$'); + } catch (err) { + return { + result: `invalid JSON in tool input: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + try { + const run = tool[RUN_KEY] as (ctx: Tx, args: unknown) => string; + const result = run(ctx, parsed); + if (typeof result !== 'string') { + return { result: 'tool returned a non-string result', isError: true }; + } + if (result.length > MAX_TOOL_RESULT_LENGTH) { + return { + result: 'tool result exceeds 65536 characters', + isError: true, + }; + } + return { result, isError: false }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + result: message.slice(0, MAX_TOOL_RESULT_LENGTH), + isError: true, + }; + } + } + + return { llmToolDefs, invoke }; +} + +function isValidToolName(name: string): boolean { + return /^[a-zA-Z0-9_-]{1,64}$/.test(name); +} + +type AlgebraicTypeLike = { tag: string; value?: unknown }; +type AlgebraicElement = { name: string; algebraicType: AlgebraicTypeLike }; +type AlgebraicVariant = { name: string; algebraicType: AlgebraicTypeLike }; + +function objectValue(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function productElements(at: AlgebraicTypeLike): AlgebraicElement[] { + const value = objectValue(at.value); + return Array.isArray(value?.elements) + ? (value.elements as AlgebraicElement[]) + : []; +} + +function sumVariants(at: AlgebraicTypeLike): AlgebraicVariant[] { + const value = objectValue(at.value); + return Array.isArray(value?.variants) + ? (value.variants as AlgebraicVariant[]) + : []; +} + +function isUnitType(at: AlgebraicTypeLike): boolean { + return at.tag === 'Product' && productElements(at).length === 0; +} + +function optionPayload(at: AlgebraicTypeLike): AlgebraicTypeLike | undefined { + if (at.tag !== 'Sum') return undefined; + const variants = sumVariants(at); + if (variants.length !== 2) return undefined; + const unitIndex = variants.findIndex(variant => + isUnitType(variant.algebraicType) + ); + return unitIndex < 0 + ? undefined + : variants[unitIndex === 0 ? 1 : 0]?.algebraicType; +} + +function invalidToolValue(path: string, expected: string): never { + throw new Error(`invalid tool input: ${path} must be ${expected}`); +} + +function validateInteger( + at: AlgebraicTypeLike, + value: unknown, + path: string +): number | bigint { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + return invalidToolValue(path, 'a safe integer'); + } + const bounds: Record = { + I8: [-128, 127], + U8: [0, 255], + I16: [-32768, 32767], + U16: [0, 65535], + I32: [-2147483648, 2147483647], + U32: [0, 4294967295], + }; + const bound = bounds[at.tag]; + if (bound && (value < bound[0] || value > bound[1])) { + return invalidToolValue(path, `within the ${at.tag} range`); + } + if (at.tag === 'I64' || at.tag === 'U64') { + if (at.tag === 'U64' && value < 0) + return invalidToolValue(path, 'a non-negative safe integer'); + return BigInt(value); + } + return value; +} + +function validateToolValue( + at: AlgebraicTypeLike, + value: unknown, + path: string +): unknown { + switch (at.tag) { + case 'Bool': + return typeof value === 'boolean' + ? value + : invalidToolValue(path, 'a boolean'); + case 'String': + return typeof value === 'string' + ? value + : invalidToolValue(path, 'a string'); + case 'F32': + case 'F64': + return typeof value === 'number' && Number.isFinite(value) + ? value + : invalidToolValue(path, 'a finite number'); + case 'I8': + case 'I16': + case 'I32': + case 'I64': + case 'U8': + case 'U16': + case 'U32': + case 'U64': + return validateInteger(at, value, path); + case 'Product': { + const input = objectValue(value); + if (!input) return invalidToolValue(path, 'an object'); + const elements = productElements(at); + const names = new Set(elements.map(element => element.name)); + for (const key of Object.keys(input)) { + if (!names.has(key)) + throw new Error(`invalid tool input: ${path}.${key} is not allowed`); + } + const output: Record = {}; + for (const element of elements) { + if (!Object.hasOwn(input, element.name)) { + if (optionPayload(element.algebraicType) !== undefined) continue; + throw new Error( + `invalid tool input: ${path}.${element.name} is required` + ); + } + const payload = optionPayload(element.algebraicType); + output[element.name] = validateToolValue( + payload ?? element.algebraicType, + input[element.name], + `${path}.${element.name}` + ); + } + return output; + } + case 'Array': { + if (!Array.isArray(value)) return invalidToolValue(path, 'an array'); + if (value.length > MAX_TOOL_ARRAY_LENGTH) { + throw new Error( + `invalid tool input: ${path} exceeds ${MAX_TOOL_ARRAY_LENGTH} items` + ); + } + const inner = at.value as AlgebraicTypeLike; + return value.map((item, index) => + validateToolValue(inner, item, `${path}[${index}]`) + ); + } + case 'Sum': { + const payload = optionPayload(at); + if (payload !== undefined) return validateToolValue(payload, value, path); + const input = objectValue(value); + if (!input || typeof input.tag !== 'string') { + return invalidToolValue(path, 'a tagged object'); + } + const variant = sumVariants(at).find( + candidate => candidate.name === input.tag + ); + if (!variant) + throw new Error(`invalid tool input: ${path}.tag is unknown`); + const allowed = isUnitType(variant.algebraicType) + ? new Set(['tag']) + : new Set(['tag', 'value']); + for (const key of Object.keys(input)) { + if (!allowed.has(key)) + throw new Error(`invalid tool input: ${path}.${key} is not allowed`); + } + if (isUnitType(variant.algebraicType)) return { tag: input.tag }; + if (!Object.hasOwn(input, 'value')) + throw new Error(`invalid tool input: ${path}.value is required`); + return { + tag: input.tag, + value: validateToolValue( + variant.algebraicType, + input.value, + `${path}.value` + ), + }; + } + case 'Ref': + throw new Error( + 'invalid tool input: referenced argument types are unsupported' + ); + default: + throw new Error(`invalid tool input: unsupported type ${at.tag}`); + } +} + +export type ToolMap = Record>; + +// Built-ins: 'openrouter' | 'openai' | 'anthropic'. +export type ProviderName = string; + +export interface AgentDefinition { + defaultProvider: ProviderName; + defaultModel: string; + defaultSystemPrompt: string | undefined; + defaultMaxTurns: number; + defaultMaxHistoryMessages: number; + defaultMaxTokens: number | undefined; + defaultRetries: number; + defaultResponseFormat: ResponseFormat | undefined; + summarizerAgentName: string | undefined; + embeddingsProvider: string | undefined; + embeddingsModel: string | undefined; + ragTopK: number; + tools: TM; +} + +export function defineAgent(config: { + defaultProvider?: ProviderName; + defaultModel: string; + defaultSystemPrompt?: string; + defaultMaxTurns?: number; + defaultMaxHistoryMessages?: number; + defaultMaxTokens?: number; + defaultRetries?: number; + defaultResponseFormat?: ResponseFormat; + summarizerAgentName?: string; + embeddingsProvider?: string; + embeddingsModel?: string; + ragTopK?: number; + tools: TM; +}): AgentDefinition { + const provider = validateConfigString( + config.defaultProvider ?? 'openrouter', + 'defaultProvider', + 64 + ); + const model = validateConfigString(config.defaultModel, 'defaultModel', 256); + if ( + config.defaultSystemPrompt !== undefined && + config.defaultSystemPrompt.length > 32 * 1024 + ) { + throw new Error('defaultSystemPrompt exceeds 32768 characters'); + } + const maxTurns = validateConfigInteger( + config.defaultMaxTurns ?? 10, + 'defaultMaxTurns', + 1, + 100 + ); + const maxHistory = validateConfigInteger( + config.defaultMaxHistoryMessages ?? 50, + 'defaultMaxHistoryMessages', + 0, + 1000 + ); + const maxTokens = + config.defaultMaxTokens === undefined + ? undefined + : validateConfigInteger( + config.defaultMaxTokens, + 'defaultMaxTokens', + 1, + 1_000_000 + ); + const retries = validateConfigInteger( + config.defaultRetries ?? 2, + 'defaultRetries', + 0, + 10 + ); + const ragTopK = validateConfigInteger(config.ragTopK ?? 0, 'ragTopK', 0, 100); + return { + defaultProvider: provider, + defaultModel: model, + defaultSystemPrompt: config.defaultSystemPrompt, + defaultMaxTurns: maxTurns, + defaultMaxHistoryMessages: maxHistory, + defaultMaxTokens: maxTokens, + defaultRetries: retries, + defaultResponseFormat: config.defaultResponseFormat, + summarizerAgentName: + config.summarizerAgentName === undefined + ? undefined + : validateConfigString( + config.summarizerAgentName, + 'summarizerAgentName', + 64 + ), + embeddingsProvider: + config.embeddingsProvider === undefined + ? undefined + : validateConfigString( + config.embeddingsProvider, + 'embeddingsProvider', + 64 + ), + embeddingsModel: + config.embeddingsModel === undefined + ? undefined + : validateConfigString(config.embeddingsModel, 'embeddingsModel', 256), + ragTopK, + tools: config.tools, + }; +} + +function validateConfigString( + value: string, + field: string, + maxLength: number +): string { + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maxLength) { + throw new Error(`${field} must contain 1 to ${maxLength} characters`); + } + return normalized; +} + +function validateConfigInteger( + value: number, + field: string, + minimum: number, + maximum: number +): number { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error( + `${field} must be an integer from ${minimum} to ${maximum}` + ); + } + return value; +} + +export interface AgentRegistry { + has(agentName: string): boolean; + names(): string[]; + agentDef(agentName: string): AgentDefinition | undefined; + llmToolDefsFor(agentName: string): ToolDefinition[]; + invoke( + agentName: string, + ctx: Tx, + toolName: string, + inputJson: string + ): InvokeResult; +} + +export function makeAgentRegistry< + Tx, + Agents extends Record>, +>(agents: Agents): AgentRegistry { + const dispatches = new Map< + string, + ReturnType> + >(); + for (const [name, def] of Object.entries(agents)) { + if (!isValidAgentName(name)) { + throw new Error( + `agent name '${name}' must match /^[a-zA-Z0-9_-]{1,64}$/` + ); + } + dispatches.set(name, makeAgentDispatch(def.tools)); + } + + return { + has(agentName: string): boolean { + return Object.hasOwn(agents, agentName); + }, + names(): string[] { + return Object.keys(agents); + }, + agentDef(agentName: string): AgentDefinition | undefined { + return Object.hasOwn(agents, agentName) ? agents[agentName] : undefined; + }, + llmToolDefsFor(agentName: string): ToolDefinition[] { + const d = dispatches.get(agentName); + if (!d) return []; + return d.llmToolDefs; + }, + invoke( + agentName: string, + ctx: Tx, + toolName: string, + inputJson: string + ): InvokeResult { + const d = dispatches.get(agentName); + if (!d) return { result: `unknown agent: ${agentName}`, isError: true }; + return d.invoke(ctx, toolName, inputJson); + }, + }; +} + +function isValidAgentName(name: string): boolean { + return /^[a-zA-Z0-9_-]{1,64}$/.test(name); +} + +export function typeBuilderToJsonSchema(tb: TypeBuilderLike): { + type: 'object'; + properties: Record; + required?: string[]; +} { + const at = tb.algebraicType; + if (!at || at.tag !== 'Product') { + throw new Error( + `agentTool args must be a t.object(...) or t.unit(); got ${at?.tag ?? 'unknown'}` + ); + } + return algebraicProductToObjectSchema(at); +} + +function algebraicProductToObjectSchema(at: AlgebraicTypeLike): { + type: 'object'; + properties: Record; + required?: string[]; +} { + const properties: Record = {}; + const required: string[] = []; + const elements = productElements(at); + for (const el of elements) { + const inner = algebraicTypeToJsonSchema(el.algebraicType); + properties[el.name] = inner.schema; + if (inner.required) required.push(el.name); + } + const out: { + type: 'object'; + properties: Record; + required?: string[]; + } = { + type: 'object', + properties, + }; + if (required.length > 0) out.required = required; + return out; +} + +function algebraicTypeToJsonSchema(at: AlgebraicTypeLike): { + schema: unknown; + required: boolean; +} { + switch (at.tag) { + case 'Bool': + return { schema: { type: 'boolean' }, required: true }; + case 'String': + return { schema: { type: 'string' }, required: true }; + case 'F32': + case 'F64': + return { schema: { type: 'number' }, required: true }; + case 'I8': + case 'I16': + case 'I32': + case 'I64': + case 'U8': + case 'U16': + case 'U32': + case 'U64': + return { schema: { type: 'integer' }, required: true }; + case 'I128': + case 'U128': + case 'I256': + case 'U256': + throw new Error( + `tool arg type ${at.tag} is not representable in JSON Schema; use a smaller integer type or t.string()` + ); + case 'Product': + return { schema: algebraicProductToObjectSchema(at), required: true }; + case 'Array': { + const inner = algebraicTypeToJsonSchema(at.value as AlgebraicTypeLike); + return { schema: { type: 'array', items: inner.schema }, required: true }; + } + case 'Sum': { + const variants = sumVariants(at); + // Unwrap option = Sum { some: T, none: () }. + if (variants.length === 2) { + const unitVariantIdx = variants.findIndex(v => + isUnitType(v.algebraicType) + ); + const payloadIdx = + unitVariantIdx === 0 ? 1 : unitVariantIdx === 1 ? 0 : -1; + if (unitVariantIdx >= 0 && payloadIdx >= 0) { + const inner = algebraicTypeToJsonSchema( + variants[payloadIdx].algebraicType + ); + return { schema: inner.schema, required: false }; + } + } + return { + schema: { + oneOf: variants.map(v => { + const inner = algebraicTypeToJsonSchema(v.algebraicType); + return { + type: 'object', + properties: { + tag: { type: 'string', enum: [v.name] }, + value: inner.schema, + }, + required: ['tag'], + }; + }), + }, + required: true, + }; + } + case 'Ref': + throw new Error( + 'typespace Ref types are not supported in tool args; declare the type inline with t.object(...)' + ); + default: + throw new Error(`unsupported algebraic type tag: ${at.tag}`); + } +} diff --git a/spacetime-agents-ts/src/openrouter.ts b/spacetime-agents-ts/src/openrouter.ts new file mode 100644 index 00000000000..d99a7a14bd3 --- /dev/null +++ b/spacetime-agents-ts/src/openrouter.ts @@ -0,0 +1,162 @@ +export interface HttpLike { + fetch( + url: string, + init: { method: string; headers: Record; body?: string } + ): { + status: number; + text(): string; + }; +} + +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; mimeType: string; data: string }; + +export type ChatMessage = + | { + role: 'system' | 'user' | 'assistant'; + content: string | ContentBlock[]; + tool_calls?: ToolCall[]; + } + | { role: 'tool'; tool_call_id: string; content: string }; + +export type ToolCall = { + id: string; + type: 'function'; + function: { name: string; arguments: string }; +}; + +export type ToolDefinition = { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required?: string[]; + }; + }; +}; + +export type ResponseFormat = { type: string; [k: string]: unknown }; + +export type ChatRequest = { + apiKey: string; + model: string; + system?: string; + messages: ChatMessage[]; + tools?: ToolDefinition[]; + maxTokens?: number; + responseFormat?: ResponseFormat; + /** Back-to-back retries on 429/5xx/transport (STDB has no sleep). */ + retries?: number; +}; + +export type ChatResponse = { + text: string | null; + toolCalls: ToolCall[]; + finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | string; + usage: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; + model: string; + raw: unknown; +}; + +export type ChatError = + | { kind: 'http'; status: number; body: string } + | { kind: 'transport'; message: string } + | { kind: 'parse'; message: string; body: string }; + +export type ChatResult = + | { ok: true; response: ChatResponse } + | { ok: false; error: ChatError }; + +export type ParsedResponse = Omit; + +export interface Provider { + name: string; + buildRequest(req: ChatRequest): { + url: string; + headers: Record; + body: string; + }; + parseResponse(text: string, requestedModel: string): ParsedResponse; +} + +export function isRetryableError(err: ChatError): boolean { + if (err.kind === 'transport') return true; + if (err.kind === 'http') { + return ( + err.status === 429 || + err.status === 502 || + err.status === 503 || + err.status === 504 + ); + } + return false; +} + +export function callChat( + http: HttpLike, + provider: Provider, + req: ChatRequest +): ChatResult { + const retries = Math.max(0, req.retries ?? 0); + let lastError: ChatError | null = null; + for (let attempt = 0; attempt <= retries; attempt++) { + const result = callChatOnce(http, provider, req); + if (result.ok) return result; + lastError = result.error; + if (!isRetryableError(result.error)) return result; + } + return { + ok: false, + error: lastError ?? { kind: 'transport', message: 'no attempts made' }, + }; +} + +function callChatOnce( + http: HttpLike, + provider: Provider, + req: ChatRequest +): ChatResult { + const { url, headers, body } = provider.buildRequest(req); + + let res: { status: number; text(): string }; + try { + res = http.fetch(url, { method: 'POST', headers, body }); + } catch (err) { + return { + ok: false, + error: { + kind: 'transport', + message: err instanceof Error ? err.message : String(err), + }, + }; + } + + const text = res.text(); + if (res.status < 200 || res.status >= 300) { + return { + ok: false, + error: { kind: 'http', status: res.status, body: text }, + }; + } + + try { + return { ok: true, response: provider.parseResponse(text, req.model) }; + } catch (err) { + return { + ok: false, + error: { + kind: 'parse', + message: err instanceof Error ? err.message : String(err), + body: text, + }, + }; + } +} diff --git a/spacetime-agents-ts/src/providers.ts b/spacetime-agents-ts/src/providers.ts new file mode 100644 index 00000000000..cd071bef0bb --- /dev/null +++ b/spacetime-agents-ts/src/providers.ts @@ -0,0 +1,290 @@ +import type { + ChatRequest, + ChatMessage, + ContentBlock, + ToolCall, + Provider, + ParsedResponse, +} from './openrouter.ts'; + +type JsonObject = Record; + +function asObject(value: unknown): JsonObject | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +} + +function tokenCount(value: unknown): number { + const count = typeof value === 'number' ? value : Number(value); + return Number.isFinite(count) && count >= 0 + ? Math.min(Math.trunc(count), Number.MAX_SAFE_INTEGER) + : 0; +} + +function toOpenAiContent(content: string | ContentBlock[]): unknown { + if (typeof content === 'string') return content; + return content.map(b => + b.type === 'text' + ? { type: 'text', text: b.text } + : { + type: 'image_url', + image_url: { url: `data:${b.mimeType};base64,${b.data}` }, + } + ); +} + +function toOpenAiMessage(m: ChatMessage): unknown { + if (m.role === 'tool') return m; + const out: JsonObject = { role: m.role, content: toOpenAiContent(m.content) }; + if (m.tool_calls) out.tool_calls = m.tool_calls; + return out; +} + +function buildOpenAiBody(req: ChatRequest): unknown { + const messages = req.messages.map(toOpenAiMessage); + const body: Record = { + model: req.model, + messages: req.system + ? [{ role: 'system', content: req.system }, ...messages] + : messages, + }; + if (req.tools && req.tools.length > 0) { + body.tools = req.tools; + body.tool_choice = 'auto'; + } + if (req.maxTokens !== undefined) body.max_tokens = req.maxTokens; + if (req.responseFormat !== undefined) + body.response_format = req.responseFormat; + return body; +} + +function parseOpenAiResponse( + text: string, + requestedModel: string +): ParsedResponse { + const parsed: unknown = JSON.parse(text); + const root = asObject(parsed); + const choices = root?.choices; + const choice = Array.isArray(choices) ? asObject(choices[0]) : undefined; + if (!choice) throw new Error('no choices in response'); + const message = asObject(choice.message) ?? {}; + const toolCalls: ToolCall[] = Array.isArray(message.tool_calls) + ? message.tool_calls.map(value => { + const toolCall = asObject(value) ?? {}; + const fn = asObject(toolCall.function) ?? {}; + return { + id: String(toolCall.id ?? ''), + type: 'function', + function: { + name: String(fn.name ?? ''), + arguments: String(fn.arguments ?? '{}'), + }, + }; + }) + : []; + const usage = asObject(root?.usage) ?? {}; + return { + text: typeof message.content === 'string' ? message.content : null, + toolCalls, + finishReason: String(choice.finish_reason ?? 'stop'), + usage: { + promptTokens: tokenCount(usage.prompt_tokens), + completionTokens: tokenCount(usage.completion_tokens), + totalTokens: tokenCount(usage.total_tokens), + }, + model: String(root?.model ?? requestedModel), + raw: parsed, + }; +} + +export const openRouterProvider: Provider = { + name: 'openrouter', + buildRequest(req) { + return { + url: 'https://openrouter.ai/api/v1/chat/completions', + headers: { + Authorization: `Bearer ${req.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildOpenAiBody(req)), + }; + }, + parseResponse: parseOpenAiResponse, +}; + +export const openAiProvider: Provider = { + name: 'openai', + buildRequest(req) { + return { + url: 'https://api.openai.com/v1/chat/completions', + headers: { + Authorization: `Bearer ${req.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildOpenAiBody(req)), + }; + }, + parseResponse: parseOpenAiResponse, +}; + +type AnthropicContentBlock = + | { type: 'text'; text: string } + | { + type: 'image'; + source: { type: 'base64'; media_type: string; data: string }; + }; + +function toAnthropicBlocks( + content: string | ContentBlock[] +): AnthropicContentBlock[] { + if (typeof content === 'string') return [{ type: 'text', text: content }]; + return content.map(b => + b.type === 'text' + ? { type: 'text', text: b.text } + : { + type: 'image', + source: { type: 'base64', media_type: b.mimeType, data: b.data }, + } + ); +} + +export const anthropicProvider: Provider = { + name: 'anthropic', + buildRequest(req) { + const messages: JsonObject[] = []; + for (const m of req.messages) { + if (m.role === 'tool') { + messages.push({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: m.tool_call_id, + content: m.content, + }, + ], + }); + } else if ( + m.role === 'assistant' && + m.tool_calls && + m.tool_calls.length > 0 + ) { + const content: unknown[] = []; + const textBlocks = toAnthropicBlocks(m.content); + for (const b of textBlocks) { + if (b.type === 'text' && !b.text) continue; + content.push(b); + } + for (const tc of m.tool_calls) { + let input: unknown = {}; + try { + input = JSON.parse(tc.function.arguments); + } catch { + /* Preserve the empty fallback. */ + } + content.push({ + type: 'tool_use', + id: tc.id, + name: tc.function.name, + input, + }); + } + messages.push({ role: 'assistant', content }); + } else if (m.role === 'system') { + continue; + } else { + messages.push({ + role: m.role, + content: + typeof m.content === 'string' + ? m.content + : toAnthropicBlocks(m.content), + }); + } + } + + const body: Record = { + model: req.model, + messages, + max_tokens: req.maxTokens ?? 4096, + }; + if (req.system) body.system = req.system; + if (req.tools && req.tools.length > 0) { + body.tools = req.tools.map(t => ({ + name: t.function.name, + description: t.function.description, + input_schema: t.function.parameters, + })); + body.tool_choice = { type: 'auto' }; + } + return { + url: 'https://api.anthropic.com/v1/messages', + headers: { + 'x-api-key': req.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }; + }, + parseResponse(text, requestedModel) { + const parsed: unknown = JSON.parse(text); + const root = asObject(parsed); + if (!Array.isArray(root?.content)) + throw new Error('no content in anthropic response'); + + let textContent: string | null = null; + const toolCalls: ToolCall[] = []; + for (const value of root.content) { + const block = asObject(value); + if (!block) continue; + if (block.type === 'text') { + textContent = (textContent ?? '') + String(block.text ?? ''); + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: String(block.id ?? ''), + type: 'function', + function: { + name: String(block.name ?? ''), + arguments: JSON.stringify(block.input ?? {}), + }, + }); + } + } + + const stopReason = String(root.stop_reason ?? 'end_turn'); + const finishReason = + stopReason === 'end_turn' + ? 'stop' + : stopReason === 'tool_use' + ? 'tool_calls' + : stopReason === 'max_tokens' + ? 'length' + : stopReason === 'stop_sequence' + ? 'stop' + : stopReason; + + const usage = asObject(root.usage) ?? {}; + const inputTokens = tokenCount(usage.input_tokens); + const outputTokens = tokenCount(usage.output_tokens); + return { + text: textContent, + toolCalls, + finishReason, + usage: { + promptTokens: inputTokens, + completionTokens: outputTokens, + totalTokens: inputTokens + outputTokens, + }, + model: String(root.model ?? requestedModel), + raw: parsed, + }; + }, +}; + +export const BUILT_IN_PROVIDERS: Record = { + openrouter: openRouterProvider, + openai: openAiProvider, + anthropic: anthropicProvider, +}; diff --git a/spacetime-agents-ts/src/stale-locks.ts b/spacetime-agents-ts/src/stale-locks.ts new file mode 100644 index 00000000000..0e625c3b995 --- /dev/null +++ b/spacetime-agents-ts/src/stale-locks.ts @@ -0,0 +1,34 @@ +export const DEFAULT_STALE_LOCK_SWEEP_BATCH = 500; + +export interface ThreadLockLike { + lockedAt: { + microsSinceUnixEpoch: bigint; + }; +} + +export function staleLockCutoffMicros( + nowMicros: bigint, + thresholdMicros: bigint +): bigint { + return nowMicros - thresholdMicros; +} + +export function deleteStaleThreadLocks( + expiredLocks: Iterable, + cutoffMicros: bigint, + deleteLock: (lock: T) => void, + maxRows = DEFAULT_STALE_LOCK_SWEEP_BATCH +): number { + if (!Number.isInteger(maxRows) || maxRows <= 0) { + throw new Error('agents.invalid_stale_lock_sweep_batch'); + } + + let deleted = 0; + for (const lock of expiredLocks) { + if (deleted >= maxRows) break; + if (lock.lockedAt.microsSinceUnixEpoch >= cutoffMicros) break; + deleteLock(lock); + deleted++; + } + return deleted; +} diff --git a/spacetime-agents-ts/tsconfig.json b/spacetime-agents-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-agents-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-api-keys-ts/LICENSE.txt b/spacetime-api-keys-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-api-keys-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-api-keys-ts/README.md b/spacetime-api-keys-ts/README.md new file mode 100644 index 00000000000..b318a5bc95c --- /dev/null +++ b/spacetime-api-keys-ts/README.md @@ -0,0 +1,198 @@ +# @spacetimedb/api-keys + +Reusable SpacetimeDB submodule for server-to-server API keys. + +The submodule owns API key lifecycle state: key creation, hashed secret storage, +verification, scope checks, revocation, rotation, usage audit rows, and +admin-gated views. Host apps own what scopes mean. + +## Install + +```bash +npm install @spacetimedb/api-keys @spacetimedb/crypto spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +## Usage + +### Integrate into an application + +Mount the component in the host schema and install its private state from the +host lifecycle hook: + +```ts +import { schema, SenderError, t } from 'spacetimedb/server'; +import * as apiKeys from '@spacetimedb/api-keys/submodule'; + +const spacetimedb = schema({ + apiKeys, +}); + +export const init = spacetimedb.init(ctx => { + apiKeys.installApiKeys(ctx.as.apiKeys); +}); + +export default spacetimedb; +``` + +The root package exports a standalone `init`. The `./submodule` entrypoint +leaves lifecycle ownership with the host module. + +Next, wrap `verifyApiKey` in the host operation that needs bearer-token access; +the component validates key material and scopes, while the host decides what a +scope authorizes. The complete +[Colony host module](./example/spacetimedb/) +shows scoped HTTP routes and one-time key delivery. + +Verification runs through a host wrapper so the application can apply request +limits and derive the action being authorized. + +## API + +`create_api_key` creates a key for the caller's SpacetimeDB identity and returns +the raw key once. Only the hash and lookup prefix are stored. + +```ts +const result = await conn.procedures.createApiKey({ + name: 'Deploy Bot', + scopesJson: JSON.stringify(['files:read', 'files:write']), + metadataJson: JSON.stringify({ environment: 'prod' }), + expiresInSeconds: 60 * 60 * 24 * 90, + keyPrefix: 'stdb_live', +}); + +console.log(result.key); +``` + +`create_api_key_for_subject` lets a submodule admin create service keys for a +specific owner subject. + +Key lifecycle operations: + +- `rotate_api_key({ keyId, expiresInSeconds, keyPrefix })` replaces a + caller-owned key and returns the new raw key once. +- `revoke_api_key({ keyId })` revokes a caller-owned key. +- `revoke_api_key_for_subject({ keyId, ownerSubject })` is the administrative + equivalent. +- `sweep_api_key_usage({ maxAgeSeconds, maxRows })` removes a bounded audit + batch. +- `createApiKey`, `rotateApiKey`, `revokeApiKey`, and `verifyApiKey` are host + helper functions for mounted applications. +- `add_admin_identity({ identity })` and `remove_admin_identity({ identity })` + manage the administrator allowlist. + +Each owner may have up to 50 active, unexpired keys. Expiration may be set up to +10 years from creation. + +## Verify In A Host App + +Mounted apps can use the transactional helper directly: + +```ts +const result = apiKeys.verifyApiKey(ctx.as.apiKeys, { + key: bearerToken, + requiredScope: 'files:write', + action: 'upload_file', +}); + +if (!result.allowed) { + throw new SenderError(`unauthorized:${result.reason}`); +} +``` + +Scope matching supports exact scopes, `*`, and prefix wildcards like +`files:*`. + +After verification, keep the authorized mutation in the same host transaction: + +```ts +export const upload_with_api_key = spacetimedb.procedure( + { apiKey: t.string(), path: t.string(), bytes: t.array(t.u8()) }, + t.u64(), + (ctx, args) => + ctx.withTx(tx => { + const access = apiKeys.verifyApiKey(tx.as.apiKeys, { + key: args.apiKey, + requiredScope: 'files:write', + action: 'upload_file', + }); + if (!access.allowed || !access.ownerSubject) { + throw new SenderError('api_key.unauthorized'); + } + return writeAuthorizedFile( + tx, + access.ownerSubject, + args.path, + args.bytes + ); + }) +); +``` + +The generated client calls the host wrapper: + +```ts +const fileId = await conn.procedures.uploadWithApiKey({ + apiKey, + path: '/reports/latest.json', + bytes, +}); +``` + +`writeAuthorizedFile` represents the host application's protected mutation. It +uses the verified subject from the key record as its owner. + +Package entrypoints: + +- `@spacetimedb/api-keys` supports a standalone API-key database. +- `@spacetimedb/api-keys/submodule` supplies the mountable namespace, + helpers, operations, and views for host applications. + +## Tables And Views + +Private tables: + +- `api_key`: key hash, prefix, owner, scopes, status, expiration, timestamps. +- `api_key_admin_identity`: submodule admins. +- `api_key_usage`: audit rows for verification, creation, rotation, and + revocation. + +Public views: + +- `my_api_keys`: up to 500 current-identity key summaries, with no hashes or raw keys. +- `api_keys_admin`: up to 200 recent key summaries for submodule admins. +- `api_key_usage_admin`: recent usage/audit rows for submodule admins. + +`sweep_api_key_usage` lets an admin delete up to 1,000 audit rows older than a +chosen age. Schedule it from the host according to the application's retention +policy. + +## Security Model + +- Raw keys are returned once. Persistent state contains the hash and lookup + prefix. +- Stored hashes are SHA-256 of high-entropy random keys. +- A short key prefix is stored for lookup and display. +- Public views expose safe summaries and omit key hashes and raw secrets. +- The submodule validates scopes as strings; the host app defines their meaning. +- Audit rows cover recognized keys, including expired, revoked, and + scope-denied keys. Malformed and unknown input is rejected before audit + storage. + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +The example module exercises issuance, verification, rotation, revocation, and +admin views. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-api-keys-ts/example/.env.example b/spacetime-api-keys-ts/example/.env.example new file mode 100644 index 00000000000..2a2506f58ad --- /dev/null +++ b/spacetime-api-keys-ts/example/.env.example @@ -0,0 +1,5 @@ +HOST=127.0.0.1 +PORT=8798 +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_DATABASE=spacetime-api-keys-example diff --git a/spacetime-api-keys-ts/example/README.md b/spacetime-api-keys-ts/example/README.md new file mode 100644 index 00000000000..756ecd34df2 --- /dev/null +++ b/spacetime-api-keys-ts/example/README.md @@ -0,0 +1,198 @@ +# Colony API-key example + +Colony is a shared map editor built with +[`@spacetimedb/api-keys`](../). Each SpacetimeDB identity owns a colony and +can issue scoped bearer links that allow another browser to view or modify it. + +## What this demonstrates + +- Mounting the API Keys component under the `apiKeys` namespace. +- Creating, rotating, validating, and revoking scoped bearer keys. +- Validating keys in native SpacetimeDB HTTP handlers. +- Composing API Keys with the Grid and Presence components. +- Giving owners native reducer access while routing key holders through HTTP. +- Recording allowed and rejected holder actions in an audit-style world event log. +- Returning a raw key only at creation or rotation time. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity for publishing the example. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-api-keys-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open . Create a share link, copy it while it is visible, +and open it in a private/incognito window to test holder permissions. + +`build:module:fresh` deletes and recreates only the local +`spacetime-api-keys-example` database. Use `pnpm run build:module` to preserve existing +local rows. + +## Use in your project + +This workspace tests the component source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/api-keys @spacetimedb/crypto spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application), then wrap key +verification in the host routes or procedures that define your scopes. The +colony, grid, and presence features are application-specific demonstration code. + +## Configuration + +| Variable | Default | Purpose | +| --------------- | ---------------------------- | ------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8798` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | Upstream module HTTP endpoint. | +| `STDB_DATABASE` | `spacetime-api-keys-example` | Published database name. | + +The publish scripts target the SpacetimeDB server registered as `local`. If that +registration resolves to a different endpoint than `STDB_URI` and `STDB_HTTP`, +the browser and proxy may use a different database from the published module. + +## Roles and scopes + +The example combines four scopes into a small set of roles: + +| Scope | Allows | +| ------------------ | ---------------------------- | +| `colony:view` | Load the colony snapshot. | +| `colony:terraform` | Change cell terrain. | +| `colony:build` | Place and remove structures. | +| `colony:plant` | Place natural objects. | + +The owner may reset the colony, clear its event log, and manage access keys. A key +holder can perform only the actions represented by the key's current scopes. Key +validation also checks status and expiry. Rotation and revocation preserve the +share URL format. + +## HTTP routes + +The module exposes these native routes: + +```text +GET /api/colony/snapshot +POST /api/colony/terraform +POST /api/colony/build +POST /api/colony/unbuild +POST /api/colony/plant +POST /api/colony/clear +``` + +Holder requests send the raw key as `Authorization: Bearer `. The Node server +forwards `/api/colony/*` to the module's HTTP router. Owner actions use native +reducers authenticated by the owner's SpacetimeDB identity. + +## Key handling + +- The module stores a hash and safe key metadata. Raw keys are recoverable only + from creation and rotation responses. +- Creation and rotation return the raw key once. The owner UI displays a copyable + link for that response and keeps the raw secret out of persistent browser storage. +- Existing keys can be rotated or revoked, but their original link cannot be + reconstructed. This is intentional. +- Share links place the key in the URL fragment (`#key=...`), which browsers do + not include in the initial HTTP request. The browser reads the fragment and sends + the key only in the authorization header for colony API calls. +- Anyone with a share link has its permissions until the key expires, is rotated, + or is revoked. Treat the link as a secret. + +## Architecture and visibility + +```text +Owner browser -> SpacetimeDB reducers/procedures -> colony state + key management + +Holder browser -> Node /api/colony proxy -> module HTTP handler + -> API-key validation + scope check + -> colony mutation + +Both browsers -> SpacetimeDB subscriptions -> realtime colony and presence state +``` + +Colony world data is readable by colony identifier in this demonstration; write +authority is the behavior under test. The `my_access_keys` view is owner-scoped +and contains metadata only. Do not copy this public-read model into an application +where the resource itself must be confidential. + +## Security and deployment boundaries + +- Never log the `Authorization` header, raw creation response, pasted key, or full + share URL. +- Prefer short expirations and narrow scopes for real integrations. +- Treat XSS prevention as part of key security because holder credentials exist in + browser memory and the URL fragment while the page is open. +- The world-event rows provide UI feedback. Compliance auditing requires a + tamper-resistant external trail. +- The included Express proxy is for local development. Production needs TLS, + explicit network binding, request-size limits, origin policy, structured secret + redaction, and process supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test: + +1. Create one key for each role and capture each link when shown. +2. Confirm each holder can load the snapshot and perform only its allowed actions. +3. Confirm a disallowed action returns an authorization failure, records a + rejected world event, and leaves the grid unchanged. +4. Rotate a key and verify the previous link is rejected and the replacement is accepted. +5. Revoke the new key and verify subsequent snapshot and mutation requests fail. +6. Reload the owner and confirm no raw key can be recovered or copied from the key + list. + +## Troubleshooting + +- **The holder opens the owner's colony instead:** use the complete `#key=...` + link and verify no extension or redirect strips the URL fragment. +- **Every holder action is rejected:** inspect the key's scopes and status, then + confirm the proxy and browser target the same published database. +- **Realtime state is stale:** confirm the WebSocket endpoint in + `STDB_URI` is reachable independently of the HTTP proxy. +- **A browser identity fails after a fresh publish:** reload once so the + client can discard the rejected development token. + +## Important files + +- `spacetimedb/src/index.ts` - colony schema, component mounts, views, reducers, + and HTTP handlers. +- `server.ts` - static server and colony-route proxy. +- `src/app.ts` - owner/holder modes, key handling, subscriptions, and UI logic. +- `public/index.html` - colony interface. +- `public/styles.css` - colony presentation. diff --git a/spacetime-api-keys-ts/example/package.json b/spacetime-api-keys-ts/example/package.json new file mode 100644 index 00000000000..998fb98d054 --- /dev/null +++ b/spacetime-api-keys-ts/example/package.json @@ -0,0 +1,32 @@ +{ + "name": "spacetime-api-keys-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "test:unit": "tsx scripts/test-model.ts && tsx scripts/test-share-key.ts", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/api-keys": "workspace:*", + "@spacetimedb/crypto": "workspace:*", + "@spacetimedb/grid": "workspace:*", + "@spacetimedb/presence": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-api-keys-ts/example/public/assets/brand.svg b/spacetime-api-keys-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-api-keys-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-api-keys-ts/example/public/index.html b/spacetime-api-keys-ts/example/public/index.html new file mode 100644 index 00000000000..d08da6b7ed9 --- /dev/null +++ b/spacetime-api-keys-ts/example/public/index.html @@ -0,0 +1,239 @@ + + + + + + + + Colony + + + +
+ +
+
+
+
+
+
+ + +
+
+ +

Colony

+
+ +
+ + +
+ + + + + +
+ + +
+ + 100% + + +
+ + +
+
+
+ + + + + + + + + + + + + + +
+
+

Access removed

+

+ The owner revoked this share link. Colony access is unavailable. +

+
+
+ + + + diff --git a/spacetime-api-keys-ts/example/public/styles.css b/spacetime-api-keys-ts/example/public/styles.css new file mode 100644 index 00000000000..b1d06cdc6d4 --- /dev/null +++ b/spacetime-api-keys-ts/example/public/styles.css @@ -0,0 +1,1100 @@ +:root { + color-scheme: dark; + --bg: #0a0605; + --panel: #17100d; + --line: #3a251c; + --line-soft: #281812; + --text: #f6ece6; + --muted: #b08c7d; + --rust: #e07a4a; + --cyan: #59c6d6; + --green: #74c56a; + --yellow: #f0c05a; + --red: #f07676; + --sky: #8fb7d6; + --button: #eadfd7; + --button-text: #1a0f0a; +} + +* { + box-sizing: border-box; +} +html, +body { + height: 100%; +} +body { + margin: 0; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +button, +input, +select { + font: inherit; +} +button { + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(40, 24, 18, 0.9); + color: var(--text); + font-weight: 800; + cursor: pointer; + transition: + transform 90ms ease, + border-color 120ms ease, + background 120ms ease, + opacity 120ms ease; +} +button:hover:not(:disabled) { + border-color: var(--rust); + background: rgba(56, 32, 22, 0.95); +} +button:not(:disabled):active { + transform: translateY(1px); +} +button.primary { + border-color: transparent; + background: var(--button); + color: var(--button-text); +} +button:disabled { + cursor: not-allowed; + opacity: 0.38; +} +input { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: #120a07; + color: var(--text); + padding: 9px 11px; + outline: none; +} +input:focus { + border-color: var(--rust); +} +h1, +h2, +p { + margin: 0; +} +.eyebrow { + color: #c58f76; + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.sub { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +/* ============ the world IS the screen ============ */ +#gridViewport { + position: fixed; + inset: 0; + overflow: hidden; + touch-action: none; + user-select: none; + cursor: crosshair; + background: radial-gradient( + ellipse 130% 100% at 50% 26%, + #241009 0%, + #150a06 55%, + #0a0504 100% + ); +} +#gridViewport.panning { + cursor: grabbing; +} +#gridViewport::after { + content: ''; + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; + box-shadow: inset 0 0 260px 50px rgba(6, 3, 2, 0.92); +} + +.grid-stage { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; + will-change: transform; + z-index: 1; +} +.world-grid { + --cols: 12; + --tile: 78px; + position: relative; + display: grid; + grid-template-columns: repeat(var(--cols), var(--tile)); + grid-auto-rows: var(--tile); + gap: 0; + padding: 26px; + border-radius: 18px; + background: #1a0d08; + box-shadow: + 0 40px 120px rgba(0, 0, 0, 0.6), + inset 0 0 0 1px rgba(200, 130, 90, 0.07); +} + +.tile { + position: relative; + border: 0; + padding: 0; + overflow: hidden; + background: #6b3b28; + box-shadow: + inset 0 0 0 1px rgba(0, 0, 0, 0.26), + inset 0 0 0 1.5px rgba(230, 150, 110, 0.04); + transition: filter 110ms ease; +} +.tile::before { + content: ''; + position: absolute; + inset: 0; +} +.tile:hover { + filter: brightness(1.18); + z-index: 2; +} + +/* Mars surface terrain */ +.tile.regolith { + background: #7a4229; +} +.tile.regolith::before { + background: + radial-gradient( + circle at 26% 30%, + rgba(240, 180, 140, 0.18), + transparent 18% + ), + radial-gradient(circle at 70% 62%, rgba(90, 40, 20, 0.3), transparent 22%); +} +.tile.rock { + background: #4a4038; +} +.tile.rock::before { + background: + radial-gradient( + circle at 34% 32%, + rgba(255, 240, 220, 0.16), + transparent 20% + ), + radial-gradient(circle at 68% 70%, rgba(0, 0, 0, 0.34), transparent 26%), + linear-gradient( + 125deg, + transparent 46%, + rgba(0, 0, 0, 0.24) 48%, + transparent 52% + ); +} +.tile.grass { + background: #3f6b31; +} +.tile.grass::before { + background: + radial-gradient( + circle at 24% 28%, + rgba(150, 220, 120, 0.28), + transparent 16% + ), + radial-gradient( + circle at 60% 44%, + rgba(120, 190, 95, 0.24), + transparent 18% + ), + radial-gradient( + circle at 44% 74%, + rgba(140, 210, 110, 0.22), + transparent 16% + ); +} +.tile.water { + background: #1c6a86; +} +.tile.water::before { + background: + repeating-linear-gradient( + 58deg, + rgba(150, 225, 240, 0.22) 0 2px, + transparent 2px 9px + ), + radial-gradient( + ellipse at 50% 40%, + rgba(175, 235, 245, 0.3), + transparent 60% + ); +} +.tile.soil { + background: #4a3320; +} +.tile.soil::before { + background: + repeating-linear-gradient( + 90deg, + rgba(0, 0, 0, 0.16) 0 2px, + transparent 2px 13px + ), + radial-gradient( + circle at 30% 30%, + rgba(125, 82, 46, 0.35), + transparent 24% + ), + radial-gradient(circle at 68% 66%, rgba(28, 16, 8, 0.4), transparent 26%); +} + +.tile.flash-allow { + animation: flashAllow 0.5s ease; +} +.tile.flash-deny { + animation: flashDeny 0.6s ease; +} +@keyframes flashAllow { + 0%, + 100% { + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.26); + } + 30% { + box-shadow: + inset 0 0 0 3px var(--green), + inset 0 0 26px rgba(116, 197, 106, 0.5); + } +} +@keyframes flashDeny { + 0%, + 100% { + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.26); + } + 18%, + 58% { + box-shadow: + inset 0 0 0 3px var(--red), + inset 0 0 26px rgba(240, 118, 118, 0.55); + } +} + +/* structures + nature */ +.entity { + position: absolute; + left: 50%; + top: 50%; + width: 56%; + height: 56%; + transform: translate(-50%, -50%); + filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.55)); + z-index: 2; +} +/* Chunky 2.5D objects: top-lit, sitting on the tile with a footprint + shadow. */ +/* Biodome: an isometric gridded glass shell (drawn as inline SVG). */ +.entity.dome { + width: 82%; + height: 78%; + background: none; + box-shadow: none; + filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5)); +} +.entity.dome svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} + +/* Placed objects drawn as inline SVG (see ENTITY_SVG in app.ts). */ +.entity svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} +.entity.pod { + width: 62%; + height: 66%; + background: none; + box-shadow: none; + filter: none; +} +.entity.solar { + width: 66%; + height: 62%; + background: none; + box-shadow: none; + filter: none; +} +.entity.boulder { + width: 60%; + height: 54%; + background: none; + box-shadow: none; + filter: none; +} +.entity.road { + width: 100%; + height: 100%; + transform: none; + left: 0; + top: 0; + background: none; + box-shadow: none; +} +.entity.road .road-center { + position: absolute; + left: 50%; + top: 50%; + width: 38%; + height: 38%; + transform: translate(-50%, -50%); + background: #4a4f56; + border-radius: 3px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.28); +} +.entity.road .road-arm { + position: absolute; + background: #4a4f56; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18); +} +.entity.road .road-arm.n { + left: 50%; + top: 0; + width: 38%; + height: 52%; + transform: translateX(-50%); +} +.entity.road .road-arm.s { + left: 50%; + bottom: 0; + width: 38%; + height: 52%; + transform: translateX(-50%); +} +.entity.road .road-arm.e { + right: 0; + top: 50%; + width: 52%; + height: 38%; + transform: translateY(-50%); +} +.entity.road .road-arm.w { + left: 0; + top: 50%; + width: 52%; + height: 38%; + transform: translateY(-50%); +} +.entity.road .road-arm.n::after, +.entity.road .road-arm.s::after { + content: ''; + position: absolute; + left: 50%; + top: 12%; + bottom: 12%; + width: 2px; + transform: translateX(-50%); + background: repeating-linear-gradient( + rgba(240, 220, 180, 0.4) 0 5px, + transparent 5px 11px + ); +} +.entity.road .road-arm.e::after, +.entity.road .road-arm.w::after { + content: ''; + position: absolute; + top: 50%; + left: 12%; + right: 12%; + height: 2px; + transform: translateY(-50%); + background: repeating-linear-gradient( + 90deg, + rgba(240, 220, 180, 0.4) 0 5px, + transparent 5px 11px + ); +} +.entity.tree { + width: 62%; + height: 68%; + background: none; + box-shadow: none; + filter: none; +} +.entity.shrub { + width: 44%; + height: 40%; + background: radial-gradient(circle at 50% 40%, #a6d98f, #4f8f45); + border-radius: 50% 50% 46% 46%; +} + +.deny-pop { + position: absolute; + left: 50%; + top: 36%; + transform: translate(-50%, -50%); + z-index: 6; + pointer-events: none; + color: #ffd2d0; + background: rgba(40, 10, 12, 0.92); + border: 1px solid rgba(240, 118, 118, 0.7); + border-radius: 7px; + padding: 5px 8px; + font: + 900 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.07em; + white-space: nowrap; + animation: denyPop 1s ease-out forwards; +} +@keyframes denyPop { + 0% { + opacity: 0; + transform: translate(-50%, -10%) scale(0.8); + } + 16% { + opacity: 1; + transform: translate(-50%, -110%) scale(1); + } + 100% { + opacity: 0; + transform: translate(-50%, -210%) scale(0.95); + } +} + +/* live cursors, positioned in world space so they pan/zoom with the map */ +#cursorLayer { + position: absolute; + inset: 0; + z-index: 5; + pointer-events: none; +} +.cursor { + position: absolute; + transform: translate(-2px, -2px); + transition: + left 90ms linear, + top 90ms linear; +} +.cursor svg { + width: 20px; + height: 20px; + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.6)); + display: block; +} +.cursor .tag { + position: absolute; + left: 15px; + top: 14px; + white-space: nowrap; + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + padding: 3px 6px; + border-radius: 6px; + color: #120a07; +} + +/* ============ floating HUD ============ */ +.hud { + position: fixed; + z-index: 10; +} +.hud-tl { + left: 16px; + top: 16px; + display: grid; + gap: 10px; + justify-items: start; +} +.hud-tr { + right: 16px; + top: 16px; + display: flex; + align-items: center; + gap: 8px; +} +.hud-bl { + left: 16px; + bottom: 16px; +} +.hud-bottom { + left: 50%; + bottom: 16px; + transform: translateX(-50%); + max-width: calc(100vw - 32px); +} + +.glass { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(20, 11, 7, 0.86); + backdrop-filter: blur(8px); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); +} + +.title-tag { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 12px; +} +.title-tag .mark { + width: 22px; + height: 22px; + color: var(--rust); +} +.title-tag h1 { + margin: 0; + font-size: 14px; + line-height: 1; + letter-spacing: 0.02em; +} + +.role-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 12px; +} +.role-banner[hidden] { + display: none; +} +.role-banner .swatch { + width: 10px; + height: 10px; + border-radius: 999px; + box-shadow: 0 0 8px currentColor; +} +.role-banner b { + color: var(--text); +} +.role-banner.view-only { + color: var(--yellow); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 8px 12px; + background: rgba(20, 11, 7, 0.86); + backdrop-filter: blur(8px); + color: #e8cbbc; + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.04em; + white-space: nowrap; +} +.chip .dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 8px currentColor; +} +#connChip[hidden] { + display: none; +} +#connChip { + color: var(--yellow); +} + +.icon-btn { + height: 36px; + min-width: 36px; + padding: 0 12px; + display: inline-flex; + align-items: center; + gap: 7px; + border-radius: 999px; +} +.icon-btn .count { + color: var(--rust); +} + +.popover { + position: fixed; + right: 16px; + top: 62px; + z-index: 12; + width: 320px; + max-height: calc(100vh - 190px); + overflow: auto; + padding: 14px; + display: grid; + gap: 14px; +} +.popover.left { + left: 16px; + right: auto; +} +.popover[hidden], +.drawer[hidden] { + display: none; +} +.pop-head { + display: grid; + gap: 3px; +} +.pop-head h2 { + font-size: 15px; +} + +/* share panel */ +.role-grid { + display: grid; + gap: 6px; +} +.role-opt { + display: grid; + gap: 3px; + text-align: left; + padding: 9px 11px; + border: 1px solid var(--line-soft); + border-radius: 9px; + background: #140b07; + color: var(--text); +} +.role-opt.on { + border-color: var(--rust); + background: #20120b; +} +.role-opt b { + font-size: 12.5px; +} +.role-opt small { + color: var(--muted); + font-size: 10.5px; +} +.key-list { + display: grid; + gap: 8px; +} +.key-empty { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} +.key-card { + border: 1px solid var(--line-soft); + border-radius: 10px; + background: #140b07; + padding: 10px; + display: grid; + gap: 8px; +} +.key-card.revoked { + opacity: 0.5; +} +.key-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.key-top b { + font-size: 13px; +} +.key-role { + color: var(--rust); + font: + 800 9px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.key-meta { + color: var(--muted); + font: + 700 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} +.key-actions { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 6px; +} +.key-actions button { + height: 30px; + font-size: 11px; +} +.link-box { + border: 1px solid var(--line); + border-radius: 9px; + padding: 10px; + background: #120a07; + display: grid; + gap: 8px; +} +.link-box[hidden] { + display: none; +} +.link-box .lh { + color: var(--muted); + font: + 600 11px/1.4 Inter, + sans-serif; +} +.link-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px; +} +.link-code { + min-width: 0; + font: + 700 10.5px/1.45 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + color: #ffd9c2; + background: #180d08; + border: 1px solid var(--line-soft); + border-radius: 7px; + padding: 8px 9px; + word-break: break-all; + max-height: 56px; + overflow: auto; +} + +/* roster */ +.you-editor { + display: flex; + gap: 8px; + align-items: center; +} +.you-editor #myNameInput { + flex: 1; +} +.you-editor input[type='color'] { + width: 38px; + height: 38px; + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: #120a07; + cursor: pointer; + flex: none; +} +.you-editor input[type='color']::-webkit-color-swatch-wrapper { + padding: 3px; +} +.you-editor input[type='color']::-webkit-color-swatch { + border: none; + border-radius: 5px; +} +.roster { + display: grid; + gap: 7px; +} +.roster-row { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 9px; + border: 1px solid var(--line-soft); + border-radius: 9px; + background: #140b07; +} +.roster-row .swatch { + width: 11px; + height: 11px; + border-radius: 999px; + box-shadow: 0 0 8px currentColor; +} +.roster-row b { + font-size: 12.5px; +} +.roster-row small { + color: var(--muted); + font: + 700 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + margin-left: auto; +} +.roster-empty { + color: var(--muted); + font-size: 12px; +} + +/* toolbar */ +.toolbar { + display: flex; + align-items: stretch; + gap: 10px; + padding: 10px 12px; +} +.tool-group { + display: flex; + align-items: center; + gap: 6px; +} +.tool-group + .tool-group { + border-left: 1px solid var(--line); + padding-left: 10px; +} +.tool { + position: relative; + width: 52px; + padding: 7px 5px 6px; + border-radius: 10px; + border: 1px solid var(--line-soft); + background: #140b07; + color: #d8c3b6; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + cursor: pointer; +} +.tool .swatch { + width: 22px; + height: 22px; + border-radius: 5px; + border: 1px solid rgba(255, 255, 255, 0.12); +} +.tool .glyph { + width: 22px; + height: 22px; + display: grid; + place-items: center; +} +.tool .glyph svg { + width: 20px; + height: 20px; +} +.tool .name { + font: + 800 8.5px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.02em; + color: var(--muted); + white-space: nowrap; +} +.tool.on { + border-color: var(--rust); + background: #23130c; + color: var(--text); + box-shadow: 0 0 0 1px rgba(224, 122, 74, 0.4); +} +.tool.on .name { + color: #f0cdb8; +} +.tool:disabled { + cursor: not-allowed; + opacity: 0.3; +} +.toolbar-empty { + color: var(--muted); + font: + 800 11px/1.3 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + padding: 10px 16px; +} + +/* log drawer */ +.drawer { + position: fixed; + right: 16px; + top: 64px; + bottom: 118px; + z-index: 12; + width: 320px; + padding: 14px; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 12px; +} +.drawer h2 { + font-size: 15px; +} +.feed { + display: grid; + gap: 4px; + overflow: auto; + align-content: start; +} +.feed-empty { + color: var(--muted); + font-size: 12px; +} +.event { + border: 1px solid var(--line-soft); + border-radius: 7px; + padding: 6px 9px; + background: #140b07; + display: grid; + gap: 1px; +} +.event.denied { + background: #1c0b0a; +} +.event.denied b { + color: #ffb3ae; +} +.event b { + font-size: 11.5px; + font-weight: 700; +} +.event small { + color: var(--muted); + font: + 700 9px/1.25 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} +.drawer-foot { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; +} +.drawer-foot button { + height: 32px; + font-size: 11.5px; +} + +/* view controls */ +.view-controls { + display: flex; + align-items: center; + gap: 6px; + padding: 5px; + border-radius: 999px; +} +.view-controls button { + width: 30px; + height: 28px; + padding: 0; + border-radius: 999px; +} +.view-controls button.fit { + width: auto; + padding: 0 11px; +} +.zoom-label { + min-width: 42px; + text-align: center; + color: #f0d3c1; + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.toast { + position: fixed; + left: 50%; + top: 16px; + transform: translateX(-50%); + z-index: 40; + min-width: min(440px, calc(100vw - 28px)); + border: 1px solid var(--line); + border-radius: 9px; + background: rgba(20, 11, 7, 0.96); + color: #ffe6d6; + padding: 11px 14px; + display: none; + align-items: center; + justify-content: center; + font: + 800 12px/1.2 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + text-align: center; + backdrop-filter: blur(8px); +} +.toast.show { + display: flex; +} +.toast.error { + border-color: #823837; + background: rgba(36, 16, 16, 0.97); + color: #ffb8b3; +} + +/* access-removed overlay (holder) */ +.overlay { + position: fixed; + inset: 0; + z-index: 60; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 4, 3, 0.86); + backdrop-filter: blur(6px); +} +.overlay.show { + display: flex; +} +.overlay .card { + max-width: 360px; + text-align: center; + padding: 26px; + display: grid; + gap: 10px; +} +.overlay .card h2 { + font-size: 18px; +} + +@media (max-width: 720px) { + .popover { + width: calc(100vw - 32px); + } + .drawer { + width: calc(100vw - 32px); + } +} diff --git a/spacetime-api-keys-ts/example/scripts/test-model.ts b/spacetime-api-keys-ts/example/scripts/test-model.ts new file mode 100644 index 00000000000..d52ab7ce82a --- /dev/null +++ b/spacetime-api-keys-ts/example/scripts/test-model.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { + SCOPE_BUILD, + SCOPE_PLANT, + SCOPE_TERRAFORM, + clamp, + colorFor, + paintLinePoints, + parsePresencePayload, + parseScopes, + permissionsFor, + roleLabel, + safePresenceColor, + safePresenceCoordinate, + safePresenceText, + stepDirection, + toolAllowedFor, + worldPixelSize, +} from '../src/model.ts'; + +assert.equal(safePresenceColor('#59C6D6'), '#59c6d6'); +assert.equal(safePresenceColor('" onload="alert(1)', '#59c6d6'), '#59c6d6'); +assert.equal(safePresenceColor('red;position:fixed'), ''); + +assert.equal(safePresenceText(' Settler ', 'Someone', 64), 'Settler'); +assert.equal(safePresenceText('', 'Someone', 64), 'Someone'); +assert.equal(safePresenceText('abcdef', 'Someone', 4), 'abcd'); + +assert.equal(safePresenceCoordinate(4.5, -1, 13), 4.5); +assert.equal(safePresenceCoordinate(Infinity, -1, 13), 0); +assert.equal(safePresenceCoordinate(99, -1, 13), 13); + +assert.deepEqual(parseScopes('["colony:view", 7]'), ['colony:view', '7']); +assert.deepEqual(parseScopes('{"scope":"colony:view"}'), []); +assert.deepEqual(parseScopes('invalid'), []); +assert.equal(roleLabel([]), 'Viewer'); +assert.equal(roleLabel([SCOPE_TERRAFORM]), 'Terraformer'); +assert.equal(roleLabel([SCOPE_BUILD]), 'Builder'); +assert.equal(roleLabel([SCOPE_PLANT]), 'Planter'); +assert.equal( + roleLabel([SCOPE_TERRAFORM, SCOPE_BUILD, SCOPE_PLANT]), + 'Collaborator' +); + +assert.deepEqual(permissionsFor('holder', [SCOPE_BUILD]), { + terraform: false, + build: true, + plant: false, +}); +assert.deepEqual(permissionsFor('owner', []), { + terraform: true, + build: true, + plant: true, +}); +assert.equal( + toolAllowedFor('holder', [SCOPE_BUILD], { + id: 'dome', + group: 'structure', + label: 'Dome', + }), + true +); +assert.equal( + toolAllowedFor('holder', [SCOPE_BUILD], { + id: 'tree', + group: 'nature', + label: 'Tree', + }), + false +); + +assert.match(colorFor('subject'), /^#[0-9a-f]{6}$/); +assert.equal(colorFor('subject'), colorFor('subject')); +assert.deepEqual(parsePresencePayload(), { + name: 'Someone', + role: '', + color: '', + cx: 0, + cy: 0, + onGrid: false, +}); +assert.deepEqual( + parsePresencePayload( + JSON.stringify({ + name: ' Settler ', + role: 'Builder', + color: '#59C6D6', + cx: 99, + cy: 4.5, + onGrid: true, + }) + ), + { + name: 'Settler', + role: 'Builder', + color: '#59c6d6', + cx: 13, + cy: 4.5, + onGrid: true, + } +); + +assert.equal(clamp(9, 0, 5), 5); +assert.deepEqual(worldPixelSize(2, 3), { width: 208, height: 286 }); +assert.equal(stepDirection(1, 1, 1, 0), 'n'); +assert.equal(stepDirection(1, 1, 2, 1), 'e'); +assert.equal(stepDirection(1, 1, 1, 2), 's'); +assert.equal(stepDirection(1, 1, 0, 1), 'w'); +assert.equal(stepDirection(1, 1, 3, 3), ''); +assert.deepEqual(paintLinePoints({ x: 0, y: 0 }, { x: 2, y: 2 }), [ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 1 }, + { x: 2, y: 2 }, +]); + +console.log('api-keys model tests passed'); diff --git a/spacetime-api-keys-ts/example/scripts/test-share-key.ts b/spacetime-api-keys-ts/example/scripts/test-share-key.ts new file mode 100644 index 00000000000..240a9e440e6 --- /dev/null +++ b/spacetime-api-keys-ts/example/scripts/test-share-key.ts @@ -0,0 +1,15 @@ +import * as assert from 'node:assert/strict'; +import { parseShareKey, shareKeyFromHash } from '../src/share-key'; + +assert.equal(parseShareKey('raw-key-value'), 'raw-key-value'); +assert.equal( + parseShareKey('http://127.0.0.1:8798/#key=shared%20key'), + 'shared key' +); +assert.equal(parseShareKey('http://127.0.0.1:8798/?key=query-secret'), null); +assert.equal(parseShareKey('http://127.0.0.1:8798/'), null); +assert.equal(parseShareKey(' '), null); +assert.equal(shareKeyFromHash('#key=abc123'), 'abc123'); +assert.equal(shareKeyFromHash('?key=query-secret'), null); + +console.log('API key share-link tests passed'); diff --git a/spacetime-api-keys-ts/example/server.ts b/spacetime-api-keys-ts/example/server.ts new file mode 100644 index 00000000000..938c2541efb --- /dev/null +++ b/spacetime-api-keys-ts/example/server.ts @@ -0,0 +1,102 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) process.env[key] = value; + } +} + +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8798', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const STDB_DATABASE = + process.env.STDB_DATABASE ?? + process.env.STDB_APP_DATABASE ?? + 'spacetime-api-keys-example'; + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + stdbUri: STDB_URI, + database: STDB_DATABASE, + }); +}); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, database: STDB_DATABASE }); +}); + +app.use('/api/colony', async (req: Request, res: Response) => { + const fullPath = `/api/colony${req.url}`; + const qIdx = fullPath.indexOf('?'); + const subpath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_DATABASE}/route${subpath}${query}`; + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') headers[key] = value; + else if (Array.isArray(value)) headers[key] = value.join(', '); + } + delete headers.host; + delete headers['content-length']; + + const init: RequestInit = { + method: req.method, + headers, + redirect: 'manual', + }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(req.body ?? {}); + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, value); + }); + res.send(Buffer.from(await upstream.arrayBuffer())); + } catch (err) { + res.status(502).json({ + ok: false, + error: 'stdb_route_unreachable', + detail: err instanceof Error ? err.message : String(err), + }); + } +}); + +app.use(express.static(path.join(__dirname, 'public'))); + +app.listen(PORT, HOST, () => { + console.log(`Colony running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP}`); + console.log(` Database -> ${STDB_DATABASE}`); +}); diff --git a/spacetime-api-keys-ts/example/spacetimedb/package.json b/spacetime-api-keys-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..837676d86e7 --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/package.json @@ -0,0 +1,22 @@ +{ + "name": "spacetime-api-keys-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-api-keys-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-api-keys-example" + }, + "dependencies": { + "@spacetimedb/api-keys": "workspace:*", + "@spacetimedb/grid": "workspace:*", + "@spacetimedb/presence": "workspace:*", + "@spacetimedb/crypto": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/index.ts b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..e228579a343 --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,1106 @@ +import { + Router, + Range, + SenderError, + SyncResponse, + schema, + table, + t, + type HandlerContext, + type Infer, + type InferSchema, + type Request, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; +import * as apiKeys from '@spacetimedb/api-keys/submodule'; +import * as gridSubmodule from '@spacetimedb/grid/submodule'; +import { + GRID_KIND_SQUARE, + GRID_MODE_OWNER, + GRID_ORIENTATION_FLAT, +} from '@spacetimedb/grid/submodule'; +import { + installPresenceConfig, + removePresence, + runPresenceSweep, + upsertPresence, +} from '@spacetimedb/presence'; + +// A small colony you build (terraform / build / plant) and share by handing +// out scoped API keys. The api-keys submodule grants +// scoped, revocable access to your colony. Internally the colony container +// tables stay named world / world_event; everything user-facing (routes, +// scopes, kinds) is colony-themed. + +const COLONY_WIDTH = 12; +const COLONY_HEIGHT = 8; +const EVENT_RETAIN = 120; +const PRESENCE_TTL_SECONDS = 35; +const PRESENCE_SCOPE_MAX = 128; +const PRESENCE_NAME_MAX = 64; +const PRESENCE_ROLE_MAX = 32; +const PRESENCE_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; +const PRESENCE_ROLES = new Set([ + 'Owner', + 'Collaborator', + 'Terraformer', + 'Builder', + 'Planter', + 'Viewer', + 'Editor', +]); +const SWEEP_INTERVAL_MICROS = 10n * 1_000_000n; + +// Scopes a share key can carry. view is read; the three edit scopes are the +// granular powers a share link can grant. +const SCOPE_VIEW = 'colony:view'; +const SCOPE_TERRAFORM = 'colony:terraform'; +const SCOPE_BUILD = 'colony:build'; +const SCOPE_PLANT = 'colony:plant'; + +const world = table( + { name: 'world', public: true }, + { + ownerSubject: t.string().primaryKey(), + gridId: t.u64().index(), + name: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +const worldEvent = table( + { name: 'world_event', public: true }, + { + eventId: t.u64().primaryKey().autoInc(), + ownerSubject: t.string().index(), + keyPrefix: t.string(), + action: t.string().index(), + allowed: t.bool().index(), + reason: t.string(), + message: t.string(), + createdAt: t.timestamp().index(), + } +); + +const accessKeySummary = table( + { name: 'access_key_summary', public: false }, + { + keyId: t.string().primaryKey(), + prefix: t.string(), + ownerSubject: t.string().index(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + status: apiKeys.apiKeyStatus.index(), + createdAt: t.timestamp().index(), + expiresAt: t.option(t.timestamp()), + lastUsedAt: t.option(t.timestamp()), + revokedAt: t.option(t.timestamp()), + } +); + +// Presence-ts tables, declared locally so the submodule helpers can read +// and write them. presence_entry is public so anyone in a colony can see the +// live roster and cursors (scope === colony id). +const presenceEntry = table( + { name: 'presence_entry', public: true }, + { + key: t.string().primaryKey(), + scope: t.string().index(), + subject: t.string().index(), + status: t.string().index(), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + joinedAt: t.timestamp().index(), + lastSeenAt: t.timestamp().index(), + expiresAt: t.timestamp().index(), + updatedAt: t.timestamp(), + } +); + +const presenceConfig = table( + { name: 'presence_config', public: false }, + { + singleton: t.bool().primaryKey(), + defaultTtlSeconds: t.u32(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), + } +); + +const colonySweepTick = table( + { name: 'colony_sweep_tick', scheduled: (): any => colony_sweep }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + apiKeys, + grid: gridSubmodule, + world, + worldEvent, + accessKeySummary, + presenceEntry, + presenceConfig, + colonySweepTick, +}); + +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx | TransactionCtx; +type ReadCtx = Tx | ViewCtx; +type HttpCtx = HandlerContext; +type ApiKeyCreateResult = Infer; + +// Surface terrain. regolith is the default (no row); the rest are stored. +const DEFAULT_TERRAIN = 'regolith'; +const terrainCost: Record = { + rock: 0, + grass: 1, + water: 1, + soil: 1, +}; +const validTerrain = new Set([DEFAULT_TERRAIN, ...Object.keys(terrainCost)]); + +// Placeable objects. Structures are gated by colony:build, nature by +// colony:plant, so a build-only and a plant-only key are visibly different. +const STRUCTURE_KINDS = new Set(['dome', 'pod', 'solar', 'road']); +const NATURE_KINDS = new Set(['tree', 'shrub', 'boulder']); + +export const init = spacetimedb.init(ctx => { + apiKeys.installApiKeys(ctx.as.apiKeys); + gridSubmodule.installGrid(ctx.as.grid); + installPresenceConfig(ctx, { + defaultTtlSeconds: PRESENCE_TTL_SECONDS, + sweepBatch: 500, + }); + ctx.db.colonySweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(SWEEP_INTERVAL_MICROS), + }); +}); + +function senderSubject(ctx: { sender: unknown }): string { + const sender = ctx.sender as { toHexString?: () => string }; + return typeof sender?.toHexString === 'function' + ? sender.toHexString() + : String(ctx.sender); +} + +function jsonResponse(body: unknown, status = 200): SyncResponse { + return new SyncResponse( + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value + ), + { + status, + headers: { 'content-type': 'application/json' }, + } + ); +} + +function errorResponse( + error: string, + status: number, + extra: Record = {} +): SyncResponse { + return jsonResponse({ ok: false, error, ...extra }, status); +} + +function readBearer(req: Request): string | undefined { + const header = req.headers.get('authorization') ?? ''; + if (!header.toLowerCase().startsWith('bearer ')) return undefined; + const token = header.slice(7).trim(); + return token.length > 0 ? token : undefined; +} + +function safeJson(req: Request): unknown { + try { + return req.json(); + } catch { + throw new SenderError('world.invalid_json'); + } +} + +function asObject(value: unknown): Record { + if (value === null || Array.isArray(value) || typeof value !== 'object') { + throw new SenderError('world.invalid_json'); + } + return value as Record; +} + +function asI32(value: unknown, field: string): number { + if (!Number.isInteger(value)) throw new SenderError(`world.invalid_${field}`); + return value as number; +} + +function asOptionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function asString(value: unknown, field: string): string { + if (typeof value !== 'string') + throw new SenderError(`world.invalid_${field}`); + const out = value.trim(); + if (!out) throw new SenderError(`world.invalid_${field}`); + return out; +} + +function assertInBounds(x: number, y: number): void { + if (x < 0 || y < 0 || x >= COLONY_WIDTH || y >= COLONY_HEIGHT) { + throw new SenderError(`world.out_of_bounds:${x},${y}`); + } +} + +function ensureWorldTx(tx: Tx, ownerSubject: string) { + const existing = tx.db.world.ownerSubject.find(ownerSubject); + if (existing) return existing; + + const grid = tx.db.grid.grid.insert({ + id: 0n, + ownerUserId: ownerSubject, + name: 'Colony Grid', + kind: GRID_KIND_SQUARE, + orientation: GRID_ORIENTATION_FLAT, + width: COLONY_WIDTH, + height: COLONY_HEIGHT, + defaultCost: 1, + connectivity: 4, + mode: GRID_MODE_OWNER, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + + // A small starter patch: a meadow, a pond, some rock. + const seedCells = [ + [2, 1, 'grass'], + [3, 1, 'grass'], + [2, 2, 'grass'], + [8, 2, 'water'], + [9, 2, 'water'], + [10, 5, 'rock'], + [10, 6, 'rock'], + [3, 6, 'rock'], + ] as const; + for (const [x, y, terrain] of seedCells) { + tx.db.grid.cellState.insert({ + id: 0n, + gridId: grid.id, + x, + y, + cost: terrainCost[terrain], + terrain, + }); + } + + // One starter dome to anchor the colony. + tx.db.grid.gridEntity.insert({ + id: 0n, + gridId: grid.id, + ownerUserId: ownerSubject, + x: 5, + y: 3, + kind: 'dome', + blocksMovement: false, + label: 'Landing Dome', + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + + const row = tx.db.world.insert({ + ownerSubject, + gridId: grid.id, + name: 'Colony', + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + insertEvent( + tx, + ownerSubject, + '', + 'colony.created', + true, + 'created', + 'Colony founded' + ); + return row; +} + +function deleteWorldTx(tx: Tx, ownerSubject: string): void { + const existing = tx.db.world.ownerSubject.find(ownerSubject); + if (!existing) return; + for (const row of [...tx.db.grid.cellState.gridId.filter(existing.gridId)]) + tx.db.grid.cellState.delete(row); + for (const row of [...tx.db.grid.gridEntity.gridId.filter(existing.gridId)]) + tx.db.grid.gridEntity.delete(row); + for (const row of [...tx.db.grid.entityPath.gridId.filter(existing.gridId)]) + tx.db.grid.entityPath.delete(row); + const grid = tx.db.grid.grid.id.find(existing.gridId); + if (grid) tx.db.grid.grid.delete(grid); + tx.db.world.delete(existing); +} + +function insertEvent( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + action: string, + allowed: boolean, + reason: string, + message: string +): void { + tx.db.worldEvent.insert({ + eventId: 0n, + ownerSubject, + keyPrefix, + action, + allowed, + reason, + message, + createdAt: tx.timestamp, + }); + + const rows = [...tx.db.worldEvent.ownerSubject.filter(ownerSubject)]; + if (rows.length <= EVENT_RETAIN) return; + rows.sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + for (const row of rows.slice(0, rows.length - EVENT_RETAIN)) + tx.db.worldEvent.delete(row); +} + +function findCell(tx: Tx, gridId: bigint, x: number, y: number) { + for (const row of tx.db.grid.cellState.gridId.filter(gridId)) { + if (row.x === x && row.y === y) return row; + } + return undefined; +} + +function findEntityAt(tx: Tx, gridId: bigint, x: number, y: number) { + for (const row of tx.db.grid.gridEntity.gridId.filter(gridId)) { + if (row.x === x && row.y === y) return row; + } + return undefined; +} + +function upsertTerrain( + tx: Tx, + gridId: bigint, + x: number, + y: number, + terrain: string +): void { + assertInBounds(x, y); + if (!validTerrain.has(terrain)) + throw new SenderError(`world.invalid_terrain:${terrain}`); + const existing = findCell(tx, gridId, x, y); + // Regolith is the bare surface, so painting it clears the cell row. + if (terrain === DEFAULT_TERRAIN) { + if (existing) tx.db.grid.cellState.delete(existing); + return; + } + const cost = terrainCost[terrain]; + if (existing) tx.db.grid.cellState.id.update({ ...existing, cost, terrain }); + else tx.db.grid.cellState.insert({ id: 0n, gridId, x, y, cost, terrain }); +} + +// Core mutations shared by the owner reducers and the scoped +// HTTP routes. keyPrefix is '' for native owner edits, or the key prefix +// for share-key edits (for the activity feed). + +function doTerraform( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number, + terrain: string +) { + const w = ensureWorldTx(tx, ownerSubject); + upsertTerrain(tx, w.gridId, x, y, terrain); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'terraform', + true, + 'allowed', + `Terraformed ${terrain} at ${x},${y}` + ); + return { x, y, terrain }; +} + +function mergeRoadMask(a: string, b: string): string { + const out: string[] = []; + for (const ch of a + b) + if ('nesw'.includes(ch) && !out.includes(ch)) out.push(ch); + return out.join(''); +} + +function doBuild( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number, + kind: string, + label?: string +) { + assertInBounds(x, y); + if (!STRUCTURE_KINDS.has(kind)) + throw new SenderError(`world.invalid_kind:${kind}`); + const w = ensureWorldTx(tx, ownerSubject); + const occupant = findEntityAt(tx, w.gridId, x, y); + if (occupant) { + // Dragging a road into an existing road merges the connection into it (so + // adjacent existing roads link when you drag between them); anything else + // on the cell blocks the build. + if (kind === 'road' && occupant.kind === 'road') { + const merged = mergeRoadMask(occupant.label ?? '', label ?? ''); + if (merged !== (occupant.label ?? '')) { + tx.db.grid.gridEntity.id.update({ + ...occupant, + label: merged, + updatedAt: tx.timestamp, + }); + } + return { entityId: occupant.id, x, y, kind }; + } + throw new SenderError(`world.cell_occupied:${x},${y}`); + } + // Roads carry a connection mask (which sides link) in label, computed as you + // draw; other structures store their kind. + const entity = tx.db.grid.gridEntity.insert({ + id: 0n, + gridId: w.gridId, + ownerUserId: ownerSubject, + x, + y, + kind, + blocksMovement: false, + label: label ?? kind, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'build', + true, + 'allowed', + `Built ${kind} at ${x},${y}` + ); + return { entityId: entity.id, x, y, kind }; +} + +function doUnbuild( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number +) { + assertInBounds(x, y); + const w = ensureWorldTx(tx, ownerSubject); + const entity = findEntityAt(tx, w.gridId, x, y); + if (!entity || !STRUCTURE_KINDS.has(entity.kind)) + throw new SenderError(`world.nothing_to_remove:${x},${y}`); + tx.db.grid.gridEntity.delete(entity); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'unbuild', + true, + 'allowed', + `Removed ${entity.kind} at ${x},${y}` + ); + return { x, y }; +} + +function doPlant( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number, + kind: string +) { + assertInBounds(x, y); + if (!NATURE_KINDS.has(kind)) + throw new SenderError(`world.invalid_kind:${kind}`); + const w = ensureWorldTx(tx, ownerSubject); + if (findEntityAt(tx, w.gridId, x, y)) + throw new SenderError(`world.cell_occupied:${x},${y}`); + const entity = tx.db.grid.gridEntity.insert({ + id: 0n, + gridId: w.gridId, + ownerUserId: ownerSubject, + x, + y, + kind, + blocksMovement: false, + label: kind, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'plant', + true, + 'allowed', + `Planted ${kind} at ${x},${y}` + ); + return { entityId: entity.id, x, y, kind }; +} + +function doClear( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + x: number, + y: number +) { + assertInBounds(x, y); + const w = ensureWorldTx(tx, ownerSubject); + const entity = findEntityAt(tx, w.gridId, x, y); + if (!entity || !NATURE_KINDS.has(entity.kind)) + throw new SenderError(`world.nothing_to_clear:${x},${y}`); + tx.db.grid.gridEntity.delete(entity); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'clear', + true, + 'allowed', + `Cleared ${entity.kind} at ${x},${y}` + ); + return { x, y }; +} + +function readWorldSnapshot(tx: Tx, ownerSubject: string) { + const w = ensureWorldTx(tx, ownerSubject); + return { + world: w, + grid: tx.db.grid.grid.id.find(w.gridId), + cells: [...tx.db.grid.cellState.gridId.filter(w.gridId)], + entities: [...tx.db.grid.gridEntity.gridId.filter(w.gridId)], + }; +} + +function mirrorAccessKey(tx: Tx, row: ApiKeyCreateResult): void { + const summary = { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + name: row.name, + scopesJson: row.scopesJson, + metadataJson: row.metadataJson, + status: row.status, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + lastUsedAt: undefined, + revokedAt: undefined, + }; + const existing = tx.db.accessKeySummary.keyId.find(summary.keyId); + if (existing) tx.db.accessKeySummary.keyId.update(summary); + else tx.db.accessKeySummary.insert(summary); +} + +function verifyRequest( + tx: Tx, + req: Request, + requiredScope: string, + action: string +) { + const key = readBearer(req); + if (!key) + return { allowed: false, reason: 'missing_bearer', status: 401 } as const; + const result = apiKeys.verifyApiKey(tx.as.apiKeys, { + key, + requiredScope, + action, + }); + if (!result.allowed) { + if (result.ownerSubject) { + insertEvent( + tx, + result.ownerSubject, + result.prefix ?? '', + action, + false, + result.reason, + `${action} denied: ${result.reason}` + ); + } + return { + allowed: false, + reason: result.reason, + status: result.reason === 'scope_denied' ? 403 : 401, + } as const; + } + if (!result.ownerSubject) { + return { allowed: false, reason: 'missing_owner', status: 401 } as const; + } + return { + allowed: true, + keyPrefix: result.prefix ?? '', + ownerSubject: result.ownerSubject, + scopesJson: result.scopesJson ?? '[]', + } as const; +} + +function handleAuthedWorldAction( + ctx: HttpCtx, + req: Request, + requiredScope: string, + action: string, + fn: ( + tx: Tx, + ownerSubject: string, + keyPrefix: string, + scopesJson: string + ) => unknown +): SyncResponse { + try { + const out = ctx.withTx((tx: Tx) => { + const auth = verifyRequest(tx, req, requiredScope, action); + if (!auth.allowed) return { error: auth.reason, status: auth.status }; + return { + ok: true, + value: fn( + tx, + auth.ownerSubject, + auth.keyPrefix, + auth.scopesJson ?? '[]' + ), + }; + }); + if (out?.error) return errorResponse(out.error, out.status); + return jsonResponse({ ok: true, result: out?.value ?? null }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return errorResponse( + message, + message.startsWith('world.invalid') ? 400 : 500 + ); + } +} + +// Native reducers called by the SpacetimeDB client with the caller's +// identity. ensure_world creates the caller's colony; the edit reducers only +// touch your own colony (keyed by your subject), so no share key is needed +// to build your own world. + +export const ensure_world = spacetimedb.procedure( + {}, + t.object('EnsureWorldResult', { ownerSubject: t.string(), gridId: t.u64() }), + ctx => + ctx.withTx(tx => { + const w = ensureWorldTx(tx, senderSubject(ctx)); + return { ownerSubject: w.ownerSubject, gridId: w.gridId }; + }) +); + +export const reset_world = spacetimedb.reducer({}, ctx => { + const ownerSubject = senderSubject(ctx); + deleteWorldTx(ctx, ownerSubject); + ensureWorldTx(ctx, ownerSubject); + insertEvent( + ctx, + ownerSubject, + '', + 'colony.reset', + true, + 'reset', + 'Colony reset' + ); +}); + +export const clear_world_events = spacetimedb.reducer({}, ctx => { + const ownerSubject = senderSubject(ctx); + for (const row of [...ctx.db.worldEvent.ownerSubject.filter(ownerSubject)]) { + ctx.db.worldEvent.delete(row); + } +}); + +export const terraform = spacetimedb.reducer( + { x: t.i32(), y: t.i32(), terrain: t.string() }, + (ctx, args) => { + doTerraform(ctx, senderSubject(ctx), '', args.x, args.y, args.terrain); + } +); + +export const build = spacetimedb.reducer( + { x: t.i32(), y: t.i32(), kind: t.string(), label: t.option(t.string()) }, + (ctx, args) => { + doBuild(ctx, senderSubject(ctx), '', args.x, args.y, args.kind, args.label); + } +); + +export const unbuild = spacetimedb.reducer( + { x: t.i32(), y: t.i32() }, + (ctx, args) => { + doUnbuild(ctx, senderSubject(ctx), '', args.x, args.y); + } +); + +export const plant = spacetimedb.reducer( + { x: t.i32(), y: t.i32(), kind: t.option(t.string()) }, + (ctx, args) => { + doPlant(ctx, senderSubject(ctx), '', args.x, args.y, args.kind ?? 'tree'); + } +); + +export const clear = spacetimedb.reducer( + { x: t.i32(), y: t.i32() }, + (ctx, args) => { + doClear(ctx, senderSubject(ctx), '', args.x, args.y); + } +); + +// Presence includes the live roster and mouse cursors. Subject is derived from +// the caller identity to prevent spoofing. Scope is the colony id, so presence is +// per-colony. cx/cy are fractional tile coordinates. + +export const presence_heartbeat = spacetimedb.reducer( + { + scope: t.string(), + name: t.string(), + role: t.string(), + color: t.string(), + cx: t.f64(), + cy: t.f64(), + onGrid: t.bool(), + }, + (ctx, args) => { + const scope = args.scope.trim(); + const name = args.name.trim(); + const role = args.role.trim(); + if (!scope || scope.length > PRESENCE_SCOPE_MAX) { + throw new SenderError('presence.invalid_scope'); + } + if (!name || name.length > PRESENCE_NAME_MAX) { + throw new SenderError('presence.invalid_name'); + } + if (!role || role.length > PRESENCE_ROLE_MAX || !PRESENCE_ROLES.has(role)) { + throw new SenderError('presence.invalid_role'); + } + if (!PRESENCE_COLOR_PATTERN.test(args.color)) { + throw new SenderError('presence.invalid_color'); + } + if ( + !Number.isFinite(args.cx) || + !Number.isFinite(args.cy) || + args.cx < -1 || + args.cx > COLONY_WIDTH + 1 || + args.cy < -1 || + args.cy > COLONY_HEIGHT + 1 + ) { + throw new SenderError('presence.invalid_cursor'); + } + upsertPresence(ctx, { + scope, + subject: senderSubject(ctx), + status: 'online', + activity: role, + payloadJson: JSON.stringify({ + name, + role, + color: args.color.toLowerCase(), + cx: args.cx, + cy: args.cy, + onGrid: args.onGrid, + }), + ttlSeconds: PRESENCE_TTL_SECONDS, + }); + } +); + +export const presence_leave = spacetimedb.reducer( + { scope: t.string() }, + (ctx, args) => { + removePresence(ctx, args.scope.trim(), senderSubject(ctx)); + } +); + +export const colony_sweep = spacetimedb.reducer( + { arg: colonySweepTick.rowType }, + ctx => { + runPresenceSweep( + ctx, + ctx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: ctx.timestamp }) + ) + ); + } +); + +// Reads. world, world_event, and presence_entry are public tables the +// client subscribes to with a WHERE on the colony id. The grid submodule's +// tables are reached through these public projection views, filtered by +// grid_id. A holder learns the colony id (owner subject) from verifyApiKey, +// then the grid id from the world row. + +function allGridIds(ctx: ReadCtx): Set { + const ids = new Set(); + for (const w of ctx.db.world.iter()) ids.add(w.gridId); + return ids; +} + +export const colonyGrid = spacetimedb.view( + { name: 'colony_grid', public: true }, + t.array(gridSubmodule.grid.rowType), + ctx => { + const out = []; + for (const id of allGridIds(ctx)) { + const g = ctx.db.grid.grid.id.find(id); + if (g) out.push(g); + } + return out; + } +); + +export const colonyCells = spacetimedb.view( + { name: 'colony_cells', public: true }, + t.array(gridSubmodule.cellState.rowType), + ctx => { + const out = []; + for (const id of allGridIds(ctx)) { + for (const c of ctx.db.grid.cellState.gridId.filter(id)) out.push(c); + } + return out; + } +); + +export const colonyEntities = spacetimedb.view( + { name: 'colony_entities', public: true }, + t.array(gridSubmodule.gridEntity.rowType), + ctx => { + const out = []; + for (const id of allGridIds(ctx)) { + for (const e of ctx.db.grid.gridEntity.gridId.filter(id)) out.push(e); + } + return out; + } +); + +export const myAccessKeys = spacetimedb.view( + { name: 'my_access_keys', public: true }, + t.array(accessKeySummary.rowType), + ctx => { + const subject = senderSubject(ctx); + return [...ctx.db.accessKeySummary.ownerSubject.filter(subject)]; + } +); + +// Share keys + +export const create_access_key = spacetimedb.procedure( + { + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeys.apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => { + const result = apiKeys.createApiKey(tx.as.apiKeys, { + ownerSubject: senderSubject(ctx), + name: args.name, + scopesJson: args.scopesJson, + metadataJson: args.metadataJson, + expiresInSeconds: args.expiresInSeconds, + keyPrefix: args.keyPrefix, + }); + mirrorAccessKey(tx, result); + return result; + }) +); + +export const rotate_access_key = spacetimedb.procedure( + { + keyId: t.string(), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeys.apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => { + const result = apiKeys.rotateApiKey(tx.as.apiKeys, { + keyId: args.keyId, + ownerSubject: senderSubject(ctx), + expiresInSeconds: args.expiresInSeconds, + keyPrefix: args.keyPrefix, + }); + mirrorAccessKey(tx, result); + return result; + }) +); + +export const revoke_access_key = spacetimedb.reducer( + { keyId: t.string() }, + (ctx, args) => { + const ownerSubject = senderSubject(ctx); + apiKeys.revokeApiKey(ctx.as.apiKeys, { keyId: args.keyId, ownerSubject }); + const row = ctx.db.accessKeySummary.keyId.find(args.keyId); + if (!row || row.ownerSubject !== ownerSubject) return; + ctx.db.accessKeySummary.keyId.update({ + ...row, + status: apiKeys.ApiKeyStatus.Revoked, + revokedAt: ctx.timestamp, + }); + } +); + +// Scoped HTTP routes. A share-key holder calls these with the key as a +// bearer token; verifyApiKey checks the scope and resolves the colony owner. + +export const colonySnapshot = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_VIEW, + 'snapshot', + (tx, ownerSubject, keyPrefix, scopesJson) => { + const snapshot = readWorldSnapshot(tx, ownerSubject); + insertEvent( + tx, + ownerSubject, + keyPrefix, + 'snapshot', + true, + 'allowed', + 'Snapshot read' + ); + // The holder learns which world it is (owner subject + grid id) and what + // this key can do (scopes) in one call, so it can subscribe and enable + // only the allowed tools. + return { ...snapshot, ownerSubject, scopesJson }; + } + ) +); + +export const colonyTerraform = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_TERRAFORM, + 'terraform', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doTerraform( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y'), + asString(body.terrain, 'terrain') + ); + } + ) +); + +export const colonyBuild = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_BUILD, + 'build', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doBuild( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y'), + asString(body.kind, 'kind'), + asOptionalString(body.label) + ); + } + ) +); + +export const colonyUnbuild = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_BUILD, + 'unbuild', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doUnbuild( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y') + ); + } + ) +); + +export const colonyPlant = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_PLANT, + 'plant', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doPlant( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y'), + asOptionalString(body.kind) ?? 'tree' + ); + } + ) +); + +export const colonyClear = spacetimedb.httpHandler((ctx, req) => + handleAuthedWorldAction( + ctx, + req, + SCOPE_PLANT, + 'clear', + (tx, ownerSubject, keyPrefix) => { + const body = asObject(safeJson(req)); + return doClear( + tx, + ownerSubject, + keyPrefix, + asI32(body.x, 'x'), + asI32(body.y, 'y') + ); + } + ) +); + +export const router = spacetimedb.httpRouter( + new Router() + .get('/api/colony/snapshot', colonySnapshot) + .post('/api/colony/terraform', colonyTerraform) + .post('/api/colony/build', colonyBuild) + .post('/api/colony/unbuild', colonyUnbuild) + .post('/api/colony/plant', colonyPlant) + .post('/api/colony/clear', colonyClear) +); diff --git a/spacetime-api-keys-ts/example/spacetimedb/tsconfig.json b/spacetime-api-keys-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..f004a6cbc79 --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-api-keys-ts/example/src/app.ts b/spacetime-api-keys-ts/example/src/app.ts new file mode 100644 index 00000000000..5b6c550b688 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/app.ts @@ -0,0 +1,1199 @@ +import { DbConnection, type ErrorContext } from './codegen/app/index.ts'; +import { parseShareKey, shareKeyFromHash } from './share-key'; + +import { + WIDTH, + HEIGHT, + TOKEN_PREFIX, + NAME_KEY, + COLOR_KEY, + TILE_SIZE, + PAD, + MIN_ZOOM, + MAX_ZOOM, + ZOOM_STEP, + HEARTBEAT_MS, + KEEPALIVE_MS, + SCOPE_VIEW, + SCOPE_TERRAFORM, + SCOPE_BUILD, + SCOPE_PLANT, + ROLES, + STRUCT_GLYPH, + TOOLS, + REMOVE_GLYPH, + CLIENT_NATURE, + TREE_SVGS, + BOULDER_SVGS, + variantIndex, + ENTITY_SVG, + clamp, + colorFor, + paintLinePoints, + parsePresencePayload, + parseScopes, + permissionsFor, + roleLabel, + safePresenceColor, + stepDirection, + toolAllowedFor, + worldPixelSize, + type AccessMode, + type ServerConfig, + type Grid, + type CellState, + type GridEntity, + type WorldEvent, + type PresenceEntry, + type ApiKeySummary, + type ToolGroup, + type Tool, +} from './model'; + +let conn: DbConnection | null = null; +let config: ServerConfig | null = null; +let identityHex = ''; + +// mode + resolved colony +let mode: AccessMode = 'owner'; +let holderKey = ''; +let colonyId = ''; +let gridId = 0n; +let myScopes: string[] = []; +let selectedTool = 'soil'; +let subscribed = false; +// The last road placed in the current stroke, so roads connect along the +// direction you draw. This keeps connections intentional. +let lastRoad: { x: number; y: number } | null = null; + +let reconnectTimer: number | null = null; +let controlsWired = false; +let viewScale = 1; +let viewX = 0; +let viewY = 0; +let viewInitialized = false; +let lastGridWidth = 0; +let lastGridHeight = 0; +let isPanning = false; +let panStartClientX = 0; +let panStartClientY = 0; +let panStartViewX = 0; +let panStartViewY = 0; +let panMoved = false; +// Left-drag paints the current tool across tiles (draw roads, brush terrain). +let isPainting = false; +let paintRemove = false; +let lastPaintTile: { x: number; y: number } | null = null; + +// presence +let myName = ''; +let myColor = ''; +let lastBeatAt = 0; +let beatTimer: number | null = null; +let cursor = { cx: 0, cy: 0, onGrid: false }; +let keepaliveTimer: number | null = null; + +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function toast(message: string, kind: 'ok' | 'error' = 'ok'): void { + const el = $('toast'); + el.textContent = message; + el.className = `toast show ${kind === 'error' ? 'error' : ''}`; + window.setTimeout(() => el.classList.remove('show'), 2800); +} + +function tokenKey(): string { + return `${TOKEN_PREFIX}.${config?.stdbUri ?? 'unknown'}.${config?.database ?? 'unknown'}`; +} + +function requireConn(): DbConnection { + if (!conn) throw new Error('stdb.disconnected'); + return conn; +} + +function grids(): Grid[] { + return [...requireConn().db.colonyGrid.iter()].filter(g => g.id === gridId); +} +function cells(): CellState[] { + return [...requireConn().db.colonyCells.iter()].filter( + c => c.gridId === gridId + ); +} +function entities(): GridEntity[] { + return [...requireConn().db.colonyEntities.iter()].filter( + e => e.gridId === gridId + ); +} +function events(): WorldEvent[] { + return [...requireConn().db.worldEvent.iter()].filter( + e => e.ownerSubject === colonyId + ); +} +function presenceRows(): PresenceEntry[] { + return [...requireConn().db.presenceEntry.iter()].filter( + p => p.scope === colonyId + ); +} +function apiKeyRows(): ApiKeySummary[] { + return [...requireConn().db.myAccessKeys.iter()]; +} + +function myRole(): string { + return mode === 'owner' ? 'Owner' : roleLabel(myScopes); +} + +function canTerraform(): boolean { + return permissionsFor(mode, myScopes).terraform; +} +function canBuild(): boolean { + return permissionsFor(mode, myScopes).build; +} +function canPlant(): boolean { + return permissionsFor(mode, myScopes).plant; +} +function toolAllowed(tool: Tool): boolean { + return toolAllowedFor(mode, myScopes, tool); +} + +// Colors and names +function loadName(): string { + const stored = localStorage.getItem(NAME_KEY); + if (stored) return stored; + const suffix = + identityHex.slice(-4) || Math.floor(Math.random() * 9000 + 1000).toString(); + return `Settler-${suffix}`; +} +function loadColor(): string { + return safePresenceColor( + localStorage.getItem(COLOR_KEY), + colorFor(identityHex) + ); +} + +async function loadConfig(): Promise { + const res = await fetch('/api/config'); + if (!res.ok) throw new Error(`/api/config returned ${res.status}`); + return (await res.json()) as ServerConfig; +} + +function connect(cfg: ServerConfig): Promise { + return new Promise((resolve, reject) => { + let retriedWithoutToken = false; + const start = (token: string | undefined) => { + let builder = DbConnection.builder() + .withUri(cfg.stdbUri) + .withDatabaseName(cfg.database); + if (token) builder = builder.withToken(token); + builder + .onConnect((c, identity, nextToken) => { + conn = c; + identityHex = + typeof identity.toHexString === 'function' + ? identity.toHexString() + : String(identity); + if (nextToken) localStorage.setItem(tokenKey(), nextToken); + resolve(c); + }) + .onDisconnect((_ctx, err) => { + conn = null; + subscribed = false; + setStatus(err?.message ?? 'Disconnected'); + scheduleReconnect(); + }) + .onConnectError((_ctx, err) => { + if (token && !retriedWithoutToken) { + retriedWithoutToken = true; + localStorage.removeItem(tokenKey()); + start(undefined); + return; + } + reject(err); + }) + .build(); + }; + start(localStorage.getItem(tokenKey()) ?? undefined); + }); +} + +function setStatus(message: string): void { + const el = $('connChip'); + const connected = message === 'Connected'; + el.dataset.state = connected ? 'connected' : 'connecting'; + el.hidden = connected; + el.innerHTML = `${escapeHtml(connected ? '' : message.toLowerCase())}`; +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + run().catch(err => { + console.error(err); + setStatus(err instanceof Error ? err.message : String(err)); + scheduleReconnect(); + }); + }, 2000); +} + +// Subscriptions. Owner and holder subscribe to the same colony by id; +// only the id differs. Reads are public-by-colony; writes are gated. + +function subscribeAll(): void { + if (subscribed) return; + subscribed = true; + const c = requireConn(); + const queries = [ + `SELECT * FROM world WHERE owner_subject = '${colonyId}'`, + `SELECT * FROM world_event WHERE owner_subject = '${colonyId}'`, + `SELECT * FROM colony_grid WHERE id = ${gridId}`, + `SELECT * FROM colony_cells WHERE grid_id = ${gridId}`, + `SELECT * FROM colony_entities WHERE grid_id = ${gridId}`, + `SELECT * FROM presence_entry WHERE scope = '${colonyId}'`, + ]; + if (mode === 'owner') queries.push('SELECT * FROM my_access_keys'); + + c.subscriptionBuilder() + .onApplied(() => renderWorld()) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe(queries); + + c.db.world.onInsert(() => renderWorld()); + c.db.world.onUpdate(() => renderWorld()); + c.db.world.onDelete(() => renderWorld()); + c.db.colonyGrid.onInsert(() => renderWorld()); + c.db.colonyGrid.onUpdate(() => renderWorld()); + c.db.colonyGrid.onDelete(() => renderWorld()); + c.db.colonyCells.onInsert(() => renderWorld()); + c.db.colonyCells.onUpdate(() => renderWorld()); + c.db.colonyCells.onDelete(() => renderWorld()); + c.db.colonyEntities.onInsert(() => renderWorld()); + c.db.colonyEntities.onUpdate(() => renderWorld()); + c.db.colonyEntities.onDelete(() => renderWorld()); + c.db.worldEvent.onInsert(() => renderWorld()); + c.db.worldEvent.onUpdate(() => renderWorld()); + c.db.worldEvent.onDelete(() => renderWorld()); + c.db.myAccessKeys.onInsert(() => renderWorld()); + c.db.myAccessKeys.onUpdate(() => renderWorld()); + c.db.myAccessKeys.onDelete(() => renderWorld()); + c.db.presenceEntry.onInsert(() => renderPresence()); + c.db.presenceEntry.onUpdate(() => renderPresence()); + c.db.presenceEntry.onDelete(() => renderPresence()); +} + +// Viewport geometry, pan, and zoom + +function terrainFor(x: number, y: number): string { + return cells().find(c => c.x === x && c.y === y)?.terrain ?? 'regolith'; +} +function entityAt(x: number, y: number): GridEntity | undefined { + return entities().find(e => e.x === x && e.y === y); +} +function applyViewportTransform(): void { + $('gridStage').style.transform = + `translate(${viewX}px, ${viewY}px) scale(${viewScale})`; + $('zoomLabel').textContent = `${Math.round(viewScale * 100)}%`; +} + +function resetViewport( + width = grids()[0]?.width ?? WIDTH, + height = grids()[0]?.height ?? HEIGHT +): void { + const bounds = $('gridViewport').getBoundingClientRect(); + const world = worldPixelSize(width, height); + const topMargin = 76; + const bottomMargin = 116; + const sideMargin = 28; + const fitScale = Math.min( + (bounds.width - sideMargin * 2) / world.width, + (bounds.height - topMargin - bottomMargin) / world.height + ); + viewScale = clamp(Math.min(2.0, fitScale), MIN_ZOOM, MAX_ZOOM); + viewX = (bounds.width - world.width * viewScale) / 2; + viewY = + topMargin + + Math.max( + 0, + (bounds.height - topMargin - bottomMargin - world.height * viewScale) / 2 + ); + viewInitialized = true; + applyViewportTransform(); +} + +function zoomViewport( + nextScale: number, + clientX?: number, + clientY?: number +): void { + const rect = $('gridViewport').getBoundingClientRect(); + const anchorX = (clientX ?? rect.left + rect.width / 2) - rect.left; + const anchorY = (clientY ?? rect.top + rect.height / 2) - rect.top; + const clamped = clamp(nextScale, MIN_ZOOM, MAX_ZOOM); + const worldX = (anchorX - viewX) / viewScale; + const worldY = (anchorY - viewY) / viewScale; + viewScale = clamped; + viewX = anchorX - worldX * viewScale; + viewY = anchorY - worldY * viewScale; + applyViewportTransform(); +} + +// Screen point to fractional tile coordinates, accounting for pan + zoom. +function pointerToTile( + clientX: number, + clientY: number +): { cx: number; cy: number; onGrid: boolean } { + const rect = $('gridViewport').getBoundingClientRect(); + const worldX = (clientX - rect.left - viewX) / viewScale; + const worldY = (clientY - rect.top - viewY) / viewScale; + const cx = (worldX - PAD) / TILE_SIZE; + const cy = (worldY - PAD) / TILE_SIZE; + const width = grids()[0]?.width ?? WIDTH; + const height = grids()[0]?.height ?? HEIGHT; + const onGrid = cx >= 0 && cy >= 0 && cx < width && cy < height; + return { cx, cy, onGrid }; +} + +function tileAt(x: number, y: number): HTMLElement | null { + return $('worldGrid').querySelector( + `.tile[data-x="${x}"][data-y="${y}"]` + ); +} + +function flashTile( + x: number, + y: number, + kind: 'allow' | 'deny', + denyText = 'DENIED' +): void { + const tile = tileAt(x, y); + if (!tile) return; + const cls = kind === 'allow' ? 'flash-allow' : 'flash-deny'; + tile.classList.remove('flash-allow', 'flash-deny'); + void tile.offsetWidth; + tile.classList.add(cls); + window.setTimeout(() => tile.classList.remove(cls), 620); + if (kind === 'deny') { + const pop = document.createElement('span'); + pop.className = 'deny-pop'; + pop.textContent = denyText; + tile.appendChild(pop); + window.setTimeout(() => pop.remove(), 1000); + } +} + +// World rendering. Presence is rendered separately. + +// A road renders exactly the arms in its own stored mask. Connections are +// written to both roads when they are drawn/dragged together, so there is no +// mirror guessing: a road only links where you explicitly drew a link. +function entitySpan(entity: GridEntity): string { + if (entity.kind === 'road') { + const own = entity.label ?? ''; + const arms = ['n', 's', 'e', 'w'].filter(d => own.includes(d)); + return `${arms.map(a => ``).join('')}`; + } + if (entity.kind === 'tree') + return `${TREE_SVGS[variantIndex(entity.x, entity.y, TREE_SVGS.length)]}`; + if (entity.kind === 'boulder') + return `${BOULDER_SVGS[variantIndex(entity.x, entity.y, BOULDER_SVGS.length)]}`; + const svg = ENTITY_SVG[entity.kind]; + if (svg) + return `${svg}`; + return ``; +} + +function renderGrid(): void { + const grid = grids()[0]; + const width = grid?.width ?? WIDTH; + const height = grid?.height ?? HEIGHT; + const worldGrid = $('worldGrid'); + worldGrid.style.setProperty('--cols', String(width)); + worldGrid.style.setProperty('--tile', `${TILE_SIZE}px`); + const entityRows = entities(); + let html = ''; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const terrain = terrainFor(x, y); + const entity = entityRows.find(e => e.x === x && e.y === y); + html += ``; + } + } + worldGrid.innerHTML = html; + // Placement is handled on the viewport (pointer down + drag), not per-tile, + // so a click-drag can paint/draw a path across tiles. + if ( + !viewInitialized || + width !== lastGridWidth || + height !== lastGridHeight + ) { + lastGridWidth = width; + lastGridHeight = height; + resetViewport(width, height); + } else { + applyViewportTransform(); + } +} + +function renderToolbar(): void { + if (mode === 'holder' && !canTerraform() && !canBuild() && !canPlant()) { + $('toolbar').innerHTML = + 'View only. This key cannot change the colony.'; + return; + } + const groups: Array<{ key: ToolGroup; tools: Tool[] }> = [ + { key: 'surface', tools: TOOLS.filter(t => t.group === 'surface') }, + { key: 'structure', tools: TOOLS.filter(t => t.group === 'structure') }, + { key: 'nature', tools: TOOLS.filter(t => t.group === 'nature') }, + { key: 'remove', tools: TOOLS.filter(t => t.group === 'remove') }, + ]; + let html = ''; + for (const g of groups) { + const inner = g.tools + .map(tool => { + const allowed = toolAllowed(tool); + const face = + tool.group === 'surface' + ? `` + : tool.group === 'remove' + ? `${REMOVE_GLYPH}` + : `${STRUCT_GLYPH[tool.kind ?? ''] ?? tool.label}`; + return ``; + }) + .join(''); + html += `
${inner}
`; + } + const bar = $('toolbar'); + bar.innerHTML = html; + bar.querySelectorAll('[data-tool]').forEach(btn => { + btn.addEventListener('click', () => { + selectedTool = btn.dataset.tool ?? selectedTool; + lastRoad = null; + renderToolbar(); + }); + }); +} + +function renderRoleBanner(): void { + const banner = $('roleBanner'); + if (mode === 'owner') { + banner.hidden = true; + return; + } + banner.hidden = false; + const role = myRole(); + const viewOnly = role === 'Viewer'; + banner.className = `role-banner glass ${viewOnly ? 'view-only' : ''}`; + banner.innerHTML = `Joined as ${escapeHtml(role)}${viewOnly ? ' (view only)' : ''}`; +} + +function renderFeed(): void { + const ordered = events().sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch, + bv = b.createdAt.microsSinceUnixEpoch; + return av > bv ? -1 : av < bv ? 1 : 0; + }); + $('eventFeed').innerHTML = ordered.length + ? ordered + .map( + row => ` +
+ ${escapeHtml(row.message)} + ${escapeHtml(row.action)} | ${row.allowed ? 'allowed' : escapeHtml(row.reason)}${row.keyPrefix ? ` | ${escapeHtml(row.keyPrefix)}` : ''} +
` + ) + .join('') + : '

Nothing yet. Start building.

'; +} + +function renderKeys(): void { + if (mode !== 'owner') return; + const keys = apiKeyRows() + .filter(k => k.ownerSubject === colonyId) + .sort((a, b) => a.name.localeCompare(b.name)); + const active = keys.filter(k => k.status.tag === 'Active'); + if (!active.length) { + $('keyList').innerHTML = + '

No share links yet. Pick a role above and create one.

'; + return; + } + $('keyList').innerHTML = active + .map(key => { + const scopes = parseScopes(key.scopesJson); + return ` +
+
+ ${escapeHtml(key.name)} + ${escapeHtml(roleLabel(scopes))} +
+
${escapeHtml(key.prefix)}
+
+ + +
+
`; + }) + .join(''); + $('keyList') + .querySelectorAll('[data-rotate]') + .forEach(b => + b.addEventListener('click', () => void rotateKey(b.dataset.rotate ?? '')) + ); + $('keyList') + .querySelectorAll('[data-revoke]') + .forEach(b => + b.addEventListener('click', () => void revokeKey(b.dataset.revoke ?? '')) + ); +} + +function renderWorld(): void { + renderGrid(); + renderToolbar(); + renderRoleBanner(); + renderFeed(); + renderKeys(); + renderPresence(); +} + +function renderPresence(): void { + const people = presenceRows(); + // roster + const rosterHtml = people.length + ? people + .map(p => { + const payload = parsePresencePayload(p.payloadJson); + const isMe = p.subject === identityHex; + const color = safePresenceColor(payload.color, colorFor(p.subject)); + return `
${escapeHtml(payload.name || 'Someone')}${isMe ? ' (you)' : ''}${escapeHtml(payload.role || '')}
`; + }) + .join('') + : '

Nobody here yet.

'; + $('roster').innerHTML = rosterHtml; + $('rosterCount').textContent = String(people.length || 1); + + // live cursors (world-space, so they pan/zoom with the map) + const layer = $('cursorLayer'); + let html = ''; + for (const p of people) { + if (p.subject === identityHex) continue; + const payload = parsePresencePayload(p.payloadJson); + if (!payload.onGrid) continue; + const color = safePresenceColor(payload.color, colorFor(p.subject)); + const left = PAD + payload.cx * TILE_SIZE; + const top = PAD + payload.cy * TILE_SIZE; + html += `
+ + ${escapeHtml(payload.name || 'Someone')} +
`; + } + layer.innerHTML = html; +} + +// Tool application + +async function applyTool(x: number, y: number): Promise { + const tool = TOOLS.find(t => t.id === selectedTool); + if (!tool) return; + if (tool.group === 'remove') { + await removeAt(x, y); + return; + } + if (!toolAllowed(tool)) { + flashTile(x, y, 'deny', 'NO ACCESS'); + toast( + `This key cannot ${tool.group === 'surface' ? 'terraform' : tool.group === 'structure' ? 'build' : 'plant'} here.`, + 'error' + ); + return; + } + + try { + if (tool.group === 'surface') { + await mutate('terraform', { x, y, terrain: tool.kind }); + lastRoad = null; + } else if (tool.group === 'structure') { + if (tool.kind === 'road') { + // Link the new tile and the preceding tile in this stroke, on both + // sides. Nothing else (perpendicular neighbours) is touched. + const prev = lastRoad; + const toPrev = prev ? stepDirection(x, y, prev.x, prev.y) : ''; + lastRoad = { x, y }; + await mutate('build', { x, y, kind: 'road', label: toPrev }); + if (prev && toPrev) { + await mutate('build', { + x: prev.x, + y: prev.y, + kind: 'road', + label: stepDirection(prev.x, prev.y, x, y), + }); + } + } else { + await mutate('build', { x, y, kind: tool.kind }); + lastRoad = null; + } + } else { + await mutate('plant', { x, y, kind: tool.kind }); + lastRoad = null; + } + flashTile(x, y, 'allow'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const denied = /scope|denied|forbidden|401|403/i.test(message); + const occupied = /occupied|nothing/i.test(message); + flashTile( + x, + y, + 'deny', + denied ? 'NO ACCESS' : occupied ? 'BLOCKED' : 'FAILED' + ); + if (denied) toast('This key cannot do that here.', 'error'); + else if (!occupied) toast(message, 'error'); + } +} + +// Remove whatever is on a tile: an object (structure or nature), or if empty, +// reset the surface to bare. Used by the Remove tool and by ctrl/cmd-click. +async function removeAt(x: number, y: number): Promise { + lastRoad = null; + const ent = entityAt(x, y); + try { + if (ent && CLIENT_NATURE.has(ent.kind)) { + if (!canPlant()) throw new Error('scope_denied'); + await mutate('clear', { x, y }); + } else if (ent) { + if (!canBuild()) throw new Error('scope_denied'); + await mutate('unbuild', { x, y }); + } else { + if (!canTerraform()) throw new Error('scope_denied'); + await mutate('terraform', { x, y, terrain: 'regolith' }); + } + flashTile(x, y, 'allow'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const denied = /scope|denied|forbidden|401|403/i.test(message); + flashTile(x, y, 'deny', denied ? 'NO ACCESS' : 'FAILED'); + if (denied) toast('This key cannot remove that here.', 'error'); + } +} + +// Owner mutates via native reducers; holder via scoped HTTP routes. +function requiredNumber(body: Record, field: string): number { + const value = body[field]; + if (typeof value !== 'number' || !Number.isFinite(value)) + throw new Error(`invalid_${field}`); + return value; +} + +function requiredString(body: Record, field: string): string { + const value = body[field]; + if (typeof value !== 'string') throw new Error(`invalid_${field}`); + return value; +} + +async function mutate( + action: string, + body: Record +): Promise { + if (mode === 'owner') { + const r = requireConn().reducers; + const x = requiredNumber(body, 'x'); + const y = requiredNumber(body, 'y'); + if (action === 'terraform') + r.terraform({ x, y, terrain: requiredString(body, 'terrain') }); + else if (action === 'build') { + const label = body.label; + if (label !== undefined && typeof label !== 'string') + throw new Error('invalid_label'); + r.build({ x, y, kind: requiredString(body, 'kind'), label }); + } else if (action === 'plant') + r.plant({ x, y, kind: requiredString(body, 'kind') }); + else if (action === 'unbuild') r.unbuild({ x, y }); + else if (action === 'clear') r.clear({ x, y }); + else throw new Error(`unknown_action:${action}`); + return; + } + await colonyRequest(`/api/colony/${action}`, body); +} + +async function colonyRequest(path: string, body?: unknown): Promise { + const res = await fetch(path, { + method: body === undefined ? 'GET' : 'POST', + headers: { + authorization: `Bearer ${holderKey}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const error = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${res.status}`; + if (/revoked|expired|unknown_key|invalid_key/i.test(error)) + showAccessRemoved(error); + throw new Error(error); + } + return data; +} + +// Owner share keys + +let selectedRole = 'collaborator'; + +function renderRoleGrid(): void { + $('roleGrid').innerHTML = ROLES.map( + role => ` + ` + ).join(''); + $('roleGrid') + .querySelectorAll('[data-role]') + .forEach(b => { + b.addEventListener('click', () => { + selectedRole = b.dataset.role ?? selectedRole; + renderRoleGrid(); + }); + }); +} + +function shareLink(secret: string): string { + return `${location.origin}${location.pathname}#key=${encodeURIComponent(secret)}`; +} + +async function createKey(): Promise { + const role = ROLES.find(r => r.id === selectedRole) ?? ROLES[0]; + const input = document.getElementById( + 'keyNameInput' + ) as HTMLInputElement | null; + const name = input?.value.trim() || role.name; + try { + const result = await requireConn().procedures.createAccessKey({ + name, + scopesJson: JSON.stringify(role.scopes), + metadataJson: JSON.stringify({ role: role.id }), + expiresInSeconds: undefined, + keyPrefix: undefined, + }); + if (input) input.value = ''; + showLink(name, result.key); + toast(`${name} link created`); + renderKeys(); + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error'); + } +} + +function showLink(label: string, secret: string): void { + const box = $('linkBox'); + const link = shareLink(secret); + box.hidden = false; + box.innerHTML = ` +
${escapeHtml(label)} link. Anyone with it gets this access. Copy it now.
+ `; + box + .querySelector('#copyFreshLink') + ?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(link); + toast('Link copied'); + } catch { + toast('Copy failed. Select the text manually.', 'error'); + } + }); +} + +async function rotateKey(keyId: string): Promise { + if (!keyId) return; + try { + const result = await requireConn().procedures.rotateAccessKey({ + keyId, + expiresInSeconds: undefined, + keyPrefix: undefined, + }); + showLink(result.name ?? 'New', result.key); + toast('Replacement link issued. Previous link revoked.'); + renderKeys(); + } catch (err) { + toast(err instanceof Error ? err.message : String(err), 'error'); + } +} + +async function revokeKey(keyId: string): Promise { + if (!keyId) return; + requireConn().reducers.revokeAccessKey({ keyId }); + toast('Access revoked'); +} + +// Holder access removal state + +function showAccessRemoved(reason: string): void { + const overlay = $('accessOverlay'); + const expired = /expired/i.test(reason); + $('overlayTitle').textContent = expired ? 'Link expired' : 'Access removed'; + $('overlayText').textContent = expired + ? 'This share link has expired. Ask the owner for a new one.' + : 'The owner revoked this share link. Colony access is unavailable.'; + overlay.classList.add('show'); + if (keepaliveTimer) { + window.clearInterval(keepaliveTimer); + keepaliveTimer = null; + } +} + +// Presence heartbeats + +function sendBeat(): void { + if (!conn || !colonyId) return; + lastBeatAt = Date.now(); + try { + requireConn().reducers.presenceHeartbeat({ + scope: colonyId, + name: myName, + role: myRole(), + color: myColor, + cx: cursor.cx, + cy: cursor.cy, + onGrid: cursor.onGrid, + }); + } catch { + /* connection churn, keepalive will retry */ + } +} + +function queueBeat(): void { + const now = Date.now(); + const wait = HEARTBEAT_MS - (now - lastBeatAt); + if (wait <= 0) { + sendBeat(); + return; + } + if (beatTimer) return; + beatTimer = window.setTimeout(() => { + beatTimer = null; + sendBeat(); + }, wait); +} + +function startPresence(): void { + sendBeat(); + if (keepaliveTimer) window.clearInterval(keepaliveTimer); + keepaliveTimer = window.setInterval(() => { + if (mode === 'holder') void reverify(); + sendBeat(); + }, KEEPALIVE_MS); + window.addEventListener('beforeunload', () => { + try { + requireConn().reducers.presenceLeave({ scope: colonyId }); + } catch { + /* best-effort disconnect cleanup */ + } + }); +} + +// Confirm holder access and show the overlay after revocation. +async function reverify(): Promise { + if (mode !== 'holder') return; + try { + await colonyRequest('/api/colony/snapshot'); + } catch { + /* colonyRequest already shows the overlay on revoke/expire */ + } +} + +// Pointer-driven placement. Dragging paints across tiles. + +function tileFromEvent( + clientX: number, + clientY: number +): { x: number; y: number } | null { + const t = pointerToTile(clientX, clientY); + if (!t.onGrid) return null; + return { x: Math.floor(t.cx), y: Math.floor(t.cy) }; +} +function applyAtTile(x: number, y: number, remove: boolean): void { + if (remove) void removeAt(x, y); + else void applyTool(x, y); +} + +// Fill in every tile along a drag so a fast drag never leaves gaps (and roads +// chain tile-by-tile). Walks orthogonally so each step is adjacent to the last. +// HUD controls + +function toggle(id: string, others: string[]): void { + const panel = $(id); + const open = panel.hidden; + for (const o of others) $(o).hidden = true; + panel.hidden = !open; +} + +// Use the URL fragment so the bearer key is not sent in the initial HTTP request. +function joinColony(): void { + const raw = ($('joinInput') as HTMLInputElement).value.trim(); + if (!raw) { + toast('Paste a share link or key first', 'error'); + return; + } + const key = parseShareKey(raw); + if (!key) { + toast('Use a raw key or a share link with #key=...', 'error'); + return; + } + location.href = shareLink(key); +} + +function wireControls(): void { + if (controlsWired) return; + controlsWired = true; + + const viewport = $('gridViewport'); + $('zoomOut').addEventListener('click', () => + zoomViewport(viewScale / ZOOM_STEP) + ); + $('zoomIn').addEventListener('click', () => + zoomViewport(viewScale * ZOOM_STEP) + ); + $('resetView').addEventListener('click', () => resetViewport()); + viewport.addEventListener( + 'wheel', + event => { + event.preventDefault(); + zoomViewport( + viewScale * (event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP), + event.clientX, + event.clientY + ); + }, + { passive: false } + ); + viewport.addEventListener('contextmenu', event => event.preventDefault()); + viewport.addEventListener('mousedown', event => { + if (event.button === 1) event.preventDefault(); + }); + viewport.addEventListener('pointerdown', event => { + const touch = event.pointerType === 'touch'; + // Middle / right / touch drag pans. Left mouse paints the current tool. + const wantsPan = touch || event.button === 1 || event.button === 2; + if (wantsPan) { + if (!touch) event.preventDefault(); + isPanning = true; + panMoved = false; + panStartClientX = event.clientX; + panStartClientY = event.clientY; + panStartViewX = viewX; + panStartViewY = viewY; + viewport.classList.add('panning'); + viewport.setPointerCapture(event.pointerId); + return; + } + if (event.button !== 0) return; + const tile = tileFromEvent(event.clientX, event.clientY); + if (!tile) return; + isPainting = true; + paintRemove = event.ctrlKey || event.metaKey; + lastPaintTile = tile; + applyAtTile(tile.x, tile.y, paintRemove); + viewport.setPointerCapture(event.pointerId); + }); + viewport.addEventListener('pointermove', event => { + if (isPanning) { + const dx = event.clientX - panStartClientX, + dy = event.clientY - panStartClientY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) panMoved = true; + viewX = panStartViewX + dx; + viewY = panStartViewY + dy; + applyViewportTransform(); + } else if (isPainting) { + const tile = tileFromEvent(event.clientX, event.clientY); + if ( + tile && + (!lastPaintTile || + tile.x !== lastPaintTile.x || + tile.y !== lastPaintTile.y) + ) { + if (lastPaintTile) { + for (const point of paintLinePoints(lastPaintTile, tile)) { + applyAtTile(point.x, point.y, paintRemove); + } + } else applyAtTile(tile.x, tile.y, paintRemove); + lastPaintTile = tile; + } + } + // cursor presence + const t = pointerToTile(event.clientX, event.clientY); + cursor = { cx: t.cx, cy: t.cy, onGrid: t.onGrid }; + queueBeat(); + }); + viewport.addEventListener('pointerleave', () => { + cursor = { ...cursor, onGrid: false }; + sendBeat(); + }); + const endStroke = (event: PointerEvent): void => { + if (isPanning) { + isPanning = false; + viewport.classList.remove('panning'); + // A touch tap that did not pan places a single tile. + if (event.pointerType === 'touch' && !panMoved) { + const tile = tileFromEvent(event.clientX, event.clientY); + if (tile) applyAtTile(tile.x, tile.y, false); + } + } + // End the stroke: roads only chain within a single continuous drag, so a + // A separate click beside the road starts a distinct stroke. + if (isPainting) { + isPainting = false; + lastPaintTile = null; + lastRoad = null; + } + if (viewport.hasPointerCapture(event.pointerId)) + viewport.releasePointerCapture(event.pointerId); + }; + viewport.addEventListener('pointerup', endStroke); + viewport.addEventListener('pointercancel', endStroke); + + $('rosterBtn').addEventListener('click', () => + toggle('rosterPanel', ['sharePanel', 'joinPanel', 'logDrawer']) + ); + $('joinBtn').addEventListener('click', () => + toggle('joinPanel', ['sharePanel', 'rosterPanel', 'logDrawer']) + ); + $('shareBtn').addEventListener('click', () => + toggle('sharePanel', ['rosterPanel', 'joinPanel', 'logDrawer']) + ); + $('logBtn').addEventListener('click', () => + toggle('logDrawer', ['rosterPanel', 'sharePanel', 'joinPanel']) + ); + const colorInput = $('myColorInput') as HTMLInputElement; + const nameInput = $('myNameInput') as HTMLInputElement; + colorInput.value = /^#[0-9a-fA-F]{6}$/.test(myColor) ? myColor : '#59c6d6'; + nameInput.value = myName; + colorInput.addEventListener('input', () => { + myColor = colorInput.value; + localStorage.setItem(COLOR_KEY, myColor); + sendBeat(); + renderPresence(); + renderRoleBanner(); + }); + nameInput.addEventListener('input', () => { + myName = nameInput.value.trim() || `Settler-${identityHex.slice(-4)}`; + localStorage.setItem(NAME_KEY, myName); + sendBeat(); + renderPresence(); + }); + $('createKeyBtn').addEventListener('click', () => void createKey()); + $('joinSubmit').addEventListener('click', () => joinColony()); + ($('joinInput') as HTMLInputElement).addEventListener('keydown', e => { + if (e.key === 'Enter') joinColony(); + }); + $('resetWorld').addEventListener('click', () => { + requireConn().reducers.resetWorld({}); + toast('Colony reset'); + }); + $('clearEvents').addEventListener('click', () => { + requireConn().reducers.clearWorldEvents({}); + toast('Log cleared'); + }); + + document.addEventListener('pointerdown', event => { + const t = event.target as HTMLElement; + if (!t.closest('#sharePanel, #shareBtn')) $('sharePanel').hidden = true; + if (!t.closest('#rosterPanel, #rosterBtn')) $('rosterPanel').hidden = true; + if (!t.closest('#joinPanel, #joinBtn')) $('joinPanel').hidden = true; + if (!t.closest('#logDrawer, #logBtn')) $('logDrawer').hidden = true; + }); + document.addEventListener('keydown', event => { + if (event.key === 'Escape') { + $('sharePanel').hidden = true; + $('rosterPanel').hidden = true; + $('joinPanel').hidden = true; + $('logDrawer').hidden = true; + } + }); +} + +function applyModeChrome(): void { + // Show sharing and colony administration controls to the owner. + $('shareBtn').hidden = mode !== 'owner'; + $('drawerFoot').style.display = mode === 'owner' ? '' : 'none'; + if (mode === 'holder') { + $('sharePanel').hidden = true; + } +} + +async function run(): Promise { + setStatus('Connecting'); + config = await loadConfig(); + const c = await connect(config); + conn = c; + myName = loadName(); + myColor = loadColor(); + + const urlKey = shareKeyFromHash(location.hash); + if (urlKey) { + mode = 'holder'; + holderKey = urlKey; + setStatus('Opening colony'); + // Snapshot resolves which colony, its grid, and this key's scopes at once. + const data = (await colonyRequest('/api/colony/snapshot')) as { + result?: { + ownerSubject?: unknown; + world?: { ownerSubject?: unknown; gridId?: string | number | bigint }; + grid?: { id?: string | number | bigint }; + scopesJson?: unknown; + }; + }; + const result = data.result ?? {}; + colonyId = String(result.ownerSubject ?? result.world?.ownerSubject ?? ''); + gridId = BigInt(result.world?.gridId ?? result.grid?.id ?? 0); + myScopes = parseScopes( + typeof result.scopesJson === 'string' ? result.scopesJson : '[]' + ); + if (!colonyId) throw new Error('could not resolve colony'); + } else { + mode = 'owner'; + setStatus('Preparing colony'); + const r = await requireConn().procedures.ensureWorld({}); + colonyId = String(r.ownerSubject); + gridId = BigInt(r.gridId); + myScopes = [SCOPE_VIEW, SCOPE_TERRAFORM, SCOPE_BUILD, SCOPE_PLANT]; + } + + if (mode === 'holder' && !toolAllowed(TOOLS[0]) && !canBuild() && !canPlant()) + selectedTool = 'regolith'; + else if (mode === 'holder') { + // Default to a tool allowed by this key. + if (canTerraform()) selectedTool = 'soil'; + else if (canBuild()) selectedTool = 'dome'; + else if (canPlant()) selectedTool = 'tree'; + } + + setStatus('Connected'); + applyModeChrome(); + subscribeAll(); + wireControls(); + renderRoleGrid(); + renderWorld(); + startPresence(); +} + +run().catch(err => { + console.error(err); + setStatus(err instanceof Error ? err.message : String(err)); + toast(err instanceof Error ? err.message : String(err), 'error'); +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/add_admin_identity_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/add_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/add_admin_identity_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_key_usage_admin_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_key_usage_admin_table.ts new file mode 100644 index 00000000000..13ca42e538d --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_key_usage_admin_table.ts @@ -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({ + usageId: __t.u64().name("usage_id"), + keyId: __t.string().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + usedAt: __t.timestamp().name("used_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_keys_admin_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_keys_admin_table.ts new file mode 100644 index 00000000000..478edd2b013 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_keys_admin_table.ts @@ -0,0 +1,31 @@ +// 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 { + ApiKeyStatus, +} from "./types"; + + +export default __t.row({ + keyId: __t.string().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + name: __t.string(), + scopesJson: __t.string().name("scopes_json"), + metadataJson: __t.option(__t.string()).name("metadata_json"), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp().name("created_at"), + expiresAt: __t.option(__t.timestamp()).name("expires_at"), + lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_for_subject_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_for_subject_procedure.ts new file mode 100644 index 00000000000..b3ca01ada61 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_for_subject_procedure.ts @@ -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 { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_procedure.ts new file mode 100644 index 00000000000..2324c883938 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_procedure.ts @@ -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 { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/my_api_keys_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/my_api_keys_table.ts new file mode 100644 index 00000000000..478edd2b013 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/my_api_keys_table.ts @@ -0,0 +1,31 @@ +// 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 { + ApiKeyStatus, +} from "./types"; + + +export default __t.row({ + keyId: __t.string().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + name: __t.string(), + scopesJson: __t.string().name("scopes_json"), + metadataJson: __t.option(__t.string()).name("metadata_json"), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp().name("created_at"), + expiresAt: __t.option(__t.timestamp()).name("expires_at"), + lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/remove_admin_identity_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/remove_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/remove_admin_identity_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_for_subject_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_for_subject_reducer.ts new file mode 100644 index 00000000000..8088a2a4a47 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_for_subject_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + keyId: __t.string(), + ownerSubject: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_reducer.ts new file mode 100644 index 00000000000..74c389667b3 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_reducer.ts @@ -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 { + keyId: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/rotate_api_key_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/rotate_api_key_procedure.ts new file mode 100644 index 00000000000..8261e1ed9bc --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/rotate_api_key_procedure.ts @@ -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"; + +import { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + keyId: __t.string(), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/sweep_api_key_usage_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/sweep_api_key_usage_reducer.ts new file mode 100644 index 00000000000..77e9dcb6473 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/sweep_api_key_usage_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + maxAgeSeconds: __t.u32(), + maxRows: __t.u32(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/types.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/types.ts new file mode 100644 index 00000000000..0fbfafcd592 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/types.ts @@ -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. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const ApiKey = __t.object("ApiKey", { + keyId: __t.string(), + prefix: __t.string(), + hash: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + createdAtOrder: __t.i64(), + expiresAt: __t.option(__t.timestamp()), + lastUsedAt: __t.option(__t.timestamp()), + revokedAt: __t.option(__t.timestamp()), +}); +export type ApiKey = __Infer; + +export const ApiKeyAdminIdentity = __t.object("ApiKeyAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type ApiKeyAdminIdentity = __Infer; + +export const ApiKeyCreateResult = __t.object("ApiKeyCreateResult", { + keyId: __t.string(), + key: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), +}); +export type ApiKeyCreateResult = __Infer; + +// The tagged union or sum type for the algebraic type `ApiKeyStatus`. +export const ApiKeyStatus = __t.enum("ApiKeyStatus", { + Active: __t.unit(), + Revoked: __t.unit(), +}); +export type ApiKeyStatus = __Infer; + +export const ApiKeySummary = __t.object("ApiKeySummary", { + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), + lastUsedAt: __t.option(__t.timestamp()), + revokedAt: __t.option(__t.timestamp()), +}); +export type ApiKeySummary = __Infer; + +export const ApiKeyUsage = __t.object("ApiKeyUsage", { + usageId: __t.u64(), + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + usedAt: __t.timestamp(), + usedAtOrder: __t.i64(), +}); +export type ApiKeyUsage = __Infer; + +export const ApiKeyUsageAdmin = __t.object("ApiKeyUsageAdmin", {}); +export type ApiKeyUsageAdmin = __Infer; + +export const ApiKeyUsageSummary = __t.object("ApiKeyUsageSummary", { + usageId: __t.u64(), + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + usedAt: __t.timestamp(), +}); +export type ApiKeyUsageSummary = __Infer; + +export const ApiKeysAdmin = __t.object("ApiKeysAdmin", {}); +export type ApiKeysAdmin = __Infer; + +export const MyApiKeys = __t.object("MyApiKeys", {}); +export type MyApiKeys = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/codegen/app/build_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/build_reducer.ts new file mode 100644 index 00000000000..2b94f0ff71d --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/build_reducer.ts @@ -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 { + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + label: __t.option(__t.string()), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/clear_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/clear_reducer.ts new file mode 100644 index 00000000000..c061193b961 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/clear_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + x: __t.i32(), + y: __t.i32(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/clear_world_events_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/clear_world_events_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/clear_world_events_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/colony_cells_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/colony_cells_table.ts new file mode 100644 index 00000000000..9f1a3bfc694 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/colony_cells_table.ts @@ -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().primaryKey(), + gridId: __t.u64().name("grid_id"), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/colony_entities_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/colony_entities_table.ts new file mode 100644 index 00000000000..1b75cfb7ea0 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/colony_entities_table.ts @@ -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({ + id: __t.u64().primaryKey(), + gridId: __t.u64().name("grid_id"), + ownerUserId: __t.string().name("owner_user_id"), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool().name("blocks_movement"), + label: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/colony_grid_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/colony_grid_table.ts new file mode 100644 index 00000000000..ba03c161ea6 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/colony_grid_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + ownerUserId: __t.string().name("owner_user_id"), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32().name("default_cost"), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/create_access_key_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/create_access_key_procedure.ts new file mode 100644 index 00000000000..2324c883938 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/create_access_key_procedure.ts @@ -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 { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/ensure_world_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/ensure_world_procedure.ts new file mode 100644 index 00000000000..dd16b86efa5 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/ensure_world_procedure.ts @@ -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"; + +import { + EnsureWorldResult, +} from "./types"; + +export const params = { +}; +export const returnType = EnsureWorldResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/grid/types.ts b/spacetime-api-keys-ts/example/src/codegen/app/grid/types.ts new file mode 100644 index 00000000000..48f7c6524bd --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/grid/types.ts @@ -0,0 +1,69 @@ +// 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 const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const EntityPath = __t.object("EntityPath", { + entityId: __t.u64(), + gridId: __t.u64(), + get cells() { + return __t.array(PathCell); + }, + cost: __t.i32(), + computedAt: __t.timestamp(), +}); +export type EntityPath = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const PathCell = __t.object("PathCell", { + x: __t.i32(), + y: __t.i32(), +}); +export type PathCell = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/codegen/app/index.ts b/spacetime-api-keys-ts/example/src/codegen/app/index.ts new file mode 100644 index 00000000000..d4342f01cbe --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/index.ts @@ -0,0 +1,324 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 BuildReducer from "./build_reducer"; +import ClearReducer from "./clear_reducer"; +import ClearWorldEventsReducer from "./clear_world_events_reducer"; +import PlantReducer from "./plant_reducer"; +import PresenceHeartbeatReducer from "./presence_heartbeat_reducer"; +import PresenceLeaveReducer from "./presence_leave_reducer"; +import ResetWorldReducer from "./reset_world_reducer"; +import RevokeAccessKeyReducer from "./revoke_access_key_reducer"; +import TerraformReducer from "./terraform_reducer"; +import UnbuildReducer from "./unbuild_reducer"; + +// Import all procedure arg schemas +import * as CreateAccessKeyProcedure from "./create_access_key_procedure"; +import * as EnsureWorldProcedure from "./ensure_world_procedure"; +import * as RotateAccessKeyProcedure from "./rotate_access_key_procedure"; + +// Import all table schema definitions +import ColonyCellsRow from "./colony_cells_table"; +import ColonyEntitiesRow from "./colony_entities_table"; +import ColonyGridRow from "./colony_grid_table"; +import MyAccessKeysRow from "./my_access_keys_table"; +import PresenceEntryRow from "./presence_entry_table"; +import WorldRow from "./world_table"; +import WorldEventRow from "./world_event_table"; + +// Import namespace table schema definitions +import ApiKeys_ApiKeyUsageAdminRow from "./apiKeys/api_key_usage_admin_table"; +import ApiKeys_ApiKeysAdminRow from "./apiKeys/api_keys_admin_table"; +import ApiKeys_MyApiKeysRow from "./apiKeys/my_api_keys_table"; + +// Import namespace reducer arg schemas +import ApiKeys_AddAdminIdentityReducer from "./apiKeys/add_admin_identity_reducer"; +import ApiKeys_RemoveAdminIdentityReducer from "./apiKeys/remove_admin_identity_reducer"; +import ApiKeys_RevokeApiKeyReducer from "./apiKeys/revoke_api_key_reducer"; +import ApiKeys_RevokeApiKeyForSubjectReducer from "./apiKeys/revoke_api_key_for_subject_reducer"; +import ApiKeys_SweepApiKeyUsageReducer from "./apiKeys/sweep_api_key_usage_reducer"; + +// Import namespace procedure arg schemas +import * as ApiKeys_CreateApiKeyProcedure from "./apiKeys/create_api_key_procedure"; +import * as ApiKeys_CreateApiKeyForSubjectProcedure from "./apiKeys/create_api_key_for_subject_procedure"; +import * as ApiKeys_RotateApiKeyProcedure from "./apiKeys/rotate_api_key_procedure"; + +/** 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({ + presenceEntry: __table({ + name: 'presence_entry', + indexes: [ + { accessor: 'expiresAt', name: 'presence_entry_expires_at_idx_btree', algorithm: 'btree', columns: [ + 'expiresAt', + ] }, + { accessor: 'joinedAt', name: 'presence_entry_joined_at_idx_btree', algorithm: 'btree', columns: [ + 'joinedAt', + ] }, + { accessor: 'key', name: 'presence_entry_key_idx_btree', algorithm: 'btree', columns: [ + 'key', + ] }, + { accessor: 'lastSeenAt', name: 'presence_entry_last_seen_at_idx_btree', algorithm: 'btree', columns: [ + 'lastSeenAt', + ] }, + { accessor: 'scope', name: 'presence_entry_scope_idx_btree', algorithm: 'btree', columns: [ + 'scope', + ] }, + { accessor: 'status', name: 'presence_entry_status_idx_btree', algorithm: 'btree', columns: [ + 'status', + ] }, + { accessor: 'subject', name: 'presence_entry_subject_idx_btree', algorithm: 'btree', columns: [ + 'subject', + ] }, + ], + constraints: [ + { name: 'presence_entry_key_key', constraint: 'unique', columns: ['key'] }, + ], + }, PresenceEntryRow), + world: __table({ + name: 'world', + indexes: [ + { accessor: 'gridId', name: 'world_grid_id_idx_btree', algorithm: 'btree', columns: [ + 'gridId', + ] }, + { accessor: 'ownerSubject', name: 'world_owner_subject_idx_btree', algorithm: 'btree', columns: [ + 'ownerSubject', + ] }, + ], + constraints: [ + { name: 'world_owner_subject_key', constraint: 'unique', columns: ['ownerSubject'] }, + ], + }, WorldRow), + worldEvent: __table({ + name: 'world_event', + indexes: [ + { accessor: 'action', name: 'world_event_action_idx_btree', algorithm: 'btree', columns: [ + 'action', + ] }, + { accessor: 'allowed', name: 'world_event_allowed_idx_btree', algorithm: 'btree', columns: [ + 'allowed', + ] }, + { accessor: 'createdAt', name: 'world_event_created_at_idx_btree', algorithm: 'btree', columns: [ + 'createdAt', + ] }, + { accessor: 'eventId', name: 'world_event_event_id_idx_btree', algorithm: 'btree', columns: [ + 'eventId', + ] }, + { accessor: 'ownerSubject', name: 'world_event_owner_subject_idx_btree', algorithm: 'btree', columns: [ + 'ownerSubject', + ] }, + ], + constraints: [ + { name: 'world_event_event_id_key', constraint: 'unique', columns: ['eventId'] }, + ], + }, WorldEventRow), + colonyCells: __table({ + name: 'colony_cells', + indexes: [ + ], + constraints: [ + ], + }, ColonyCellsRow), + colonyEntities: __table({ + name: 'colony_entities', + indexes: [ + ], + constraints: [ + ], + }, ColonyEntitiesRow), + colonyGrid: __table({ + name: 'colony_grid', + indexes: [ + ], + constraints: [ + ], + }, ColonyGridRow), + myAccessKeys: __table({ + name: 'my_access_keys', + indexes: [ + ], + constraints: [ + ], + }, MyAccessKeysRow), + "apiKeys.api_key_usage_admin": __table({ + name: 'apiKeys.api_key_usage_admin', + indexes: [ + ], + constraints: [ + ], + }, ApiKeys_ApiKeyUsageAdminRow), + "apiKeys.api_keys_admin": __table({ + name: 'apiKeys.api_keys_admin', + indexes: [ + ], + constraints: [ + ], + }, ApiKeys_ApiKeysAdminRow), + "apiKeys.my_api_keys": __table({ + name: 'apiKeys.my_api_keys', + indexes: [ + ], + constraints: [ + ], + }, ApiKeys_MyApiKeysRow), +}); + +/** 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( + __reducerSchema("build", BuildReducer), + __reducerSchema("clear", ClearReducer), + __reducerSchema("clear_world_events", ClearWorldEventsReducer), + __reducerSchema("plant", PlantReducer), + __reducerSchema("presence_heartbeat", PresenceHeartbeatReducer), + __reducerSchema("presence_leave", PresenceLeaveReducer), + __reducerSchema("reset_world", ResetWorldReducer), + __reducerSchema("revoke_access_key", RevokeAccessKeyReducer), + __reducerSchema("terraform", TerraformReducer), + __reducerSchema("unbuild", UnbuildReducer), + __reducerSchema("apiKeys.add_admin_identity", ApiKeys_AddAdminIdentityReducer), + __reducerSchema("apiKeys.remove_admin_identity", ApiKeys_RemoveAdminIdentityReducer), + __reducerSchema("apiKeys.revoke_api_key", ApiKeys_RevokeApiKeyReducer), + __reducerSchema("apiKeys.revoke_api_key_for_subject", ApiKeys_RevokeApiKeyForSubjectReducer), + __reducerSchema("apiKeys.sweep_api_key_usage", ApiKeys_SweepApiKeyUsageReducer), +); + +/** 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("create_access_key", CreateAccessKeyProcedure.params, CreateAccessKeyProcedure.returnType), + __procedureSchema("ensure_world", EnsureWorldProcedure.params, EnsureWorldProcedure.returnType), + __procedureSchema("rotate_access_key", RotateAccessKeyProcedure.params, RotateAccessKeyProcedure.returnType), + __procedureSchema("apiKeys.create_api_key", ApiKeys_CreateApiKeyProcedure.params, ApiKeys_CreateApiKeyProcedure.returnType), + __procedureSchema("apiKeys.create_api_key_for_subject", ApiKeys_CreateApiKeyForSubjectProcedure.params, ApiKeys_CreateApiKeyForSubjectProcedure.returnType), + __procedureSchema("apiKeys.rotate_api_key", ApiKeys_RotateApiKeyProcedure.params, ApiKeys_RotateApiKeyProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + presenceEntry: __qb.presenceEntry, + world: __qb.world, + worldEvent: __qb.worldEvent, + colonyCells: __qb.colonyCells, + colonyEntities: __qb.colonyEntities, + colonyGrid: __qb.colonyGrid, + myAccessKeys: __qb.myAccessKeys, + apiKeys: { + apiKeyUsageAdmin: __qb["apiKeys.api_key_usage_admin"], + apiKeysAdmin: __qb["apiKeys.api_keys_admin"], + myApiKeys: __qb["apiKeys.my_api_keys"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + build: __reducerAccessors.build, + clear: __reducerAccessors.clear, + clearWorldEvents: __reducerAccessors.clearWorldEvents, + plant: __reducerAccessors.plant, + presenceHeartbeat: __reducerAccessors.presenceHeartbeat, + presenceLeave: __reducerAccessors.presenceLeave, + resetWorld: __reducerAccessors.resetWorld, + revokeAccessKey: __reducerAccessors.revokeAccessKey, + terraform: __reducerAccessors.terraform, + unbuild: __reducerAccessors.unbuild, + apiKeys: { + addAdminIdentity: __reducerAccessors["apiKeys.addAdminIdentity"], + removeAdminIdentity: __reducerAccessors["apiKeys.removeAdminIdentity"], + revokeApiKey: __reducerAccessors["apiKeys.revokeApiKey"], + revokeApiKeyForSubject: __reducerAccessors["apiKeys.revokeApiKeyForSubject"], + sweepApiKeyUsage: __reducerAccessors["apiKeys.sweepApiKeyUsage"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + createAccessKey: __procedureAccessors.createAccessKey, + ensureWorld: __procedureAccessors.ensureWorld, + rotateAccessKey: __procedureAccessors.rotateAccessKey, + apiKeys: { + createApiKey: __procedureAccessors["apiKeys.createApiKey"], + createApiKeyForSubject: __procedureAccessors["apiKeys.createApiKeyForSubject"], + rotateApiKey: __procedureAccessors["apiKeys.rotateApiKey"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-api-keys-ts/example/src/codegen/app/my_access_keys_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/my_access_keys_table.ts new file mode 100644 index 00000000000..63686ec3ca6 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/my_access_keys_table.ts @@ -0,0 +1,31 @@ +// 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 { + ApiKeyStatus, +} from "./types"; + + +export default __t.row({ + keyId: __t.string().primaryKey().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + name: __t.string(), + scopesJson: __t.string().name("scopes_json"), + metadataJson: __t.option(__t.string()).name("metadata_json"), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp().name("created_at"), + expiresAt: __t.option(__t.timestamp()).name("expires_at"), + lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/plant_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/plant_reducer.ts new file mode 100644 index 00000000000..2b4c9c3b0f5 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/plant_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + x: __t.i32(), + y: __t.i32(), + kind: __t.option(__t.string()), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/presence_entry_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/presence_entry_table.ts new file mode 100644 index 00000000000..70af5d56d15 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/presence_entry_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()).name("payload_json"), + joinedAt: __t.timestamp().name("joined_at"), + lastSeenAt: __t.timestamp().name("last_seen_at"), + expiresAt: __t.timestamp().name("expires_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/presence_heartbeat_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/presence_heartbeat_reducer.ts new file mode 100644 index 00000000000..46b19979f5d --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/presence_heartbeat_reducer.ts @@ -0,0 +1,21 @@ +// 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 { + scope: __t.string(), + name: __t.string(), + role: __t.string(), + color: __t.string(), + cx: __t.f64(), + cy: __t.f64(), + onGrid: __t.bool(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/presence_leave_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/presence_leave_reducer.ts new file mode 100644 index 00000000000..7a16fc253db --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/presence_leave_reducer.ts @@ -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 { + scope: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/reset_world_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/reset_world_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/reset_world_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/revoke_access_key_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/revoke_access_key_reducer.ts new file mode 100644 index 00000000000..74c389667b3 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/revoke_access_key_reducer.ts @@ -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 { + keyId: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/rotate_access_key_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/rotate_access_key_procedure.ts new file mode 100644 index 00000000000..8261e1ed9bc --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/rotate_access_key_procedure.ts @@ -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"; + +import { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + keyId: __t.string(), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/terraform_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/terraform_reducer.ts new file mode 100644 index 00000000000..fea18233964 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/terraform_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + x: __t.i32(), + y: __t.i32(), + terrain: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/types.ts b/spacetime-api-keys-ts/example/src/codegen/app/types.ts new file mode 100644 index 00000000000..54619126f70 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/types.ts @@ -0,0 +1,159 @@ +// 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 const AccessKeySummary = __t.object("AccessKeySummary", { + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), + lastUsedAt: __t.option(__t.timestamp()), + revokedAt: __t.option(__t.timestamp()), +}); +export type AccessKeySummary = __Infer; + +export const ApiKeyCreateResult = __t.object("ApiKeyCreateResult", { + keyId: __t.string(), + key: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), +}); +export type ApiKeyCreateResult = __Infer; + +// The tagged union or sum type for the algebraic type `ApiKeyStatus`. +export const ApiKeyStatus = __t.enum("ApiKeyStatus", { + Active: __t.unit(), + Revoked: __t.unit(), +}); +export type ApiKeyStatus = __Infer; + +export const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const ColonyCells = __t.object("ColonyCells", {}); +export type ColonyCells = __Infer; + +export const ColonyEntities = __t.object("ColonyEntities", {}); +export type ColonyEntities = __Infer; + +export const ColonyGrid = __t.object("ColonyGrid", {}); +export type ColonyGrid = __Infer; + +export const ColonySweepTick = __t.object("ColonySweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type ColonySweepTick = __Infer; + +export const EnsureWorldResult = __t.object("EnsureWorldResult", { + ownerSubject: __t.string(), + gridId: __t.u64(), +}); +export type EnsureWorldResult = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const MyAccessKeys = __t.object("MyAccessKeys", {}); +export type MyAccessKeys = __Infer; + +export const PresenceConfig = __t.object("PresenceConfig", { + singleton: __t.bool(), + defaultTtlSeconds: __t.u32(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type PresenceConfig = __Infer; + +export const PresenceEntry = __t.object("PresenceEntry", { + key: __t.string(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()), + joinedAt: __t.timestamp(), + lastSeenAt: __t.timestamp(), + expiresAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type PresenceEntry = __Infer; + +export const World = __t.object("World", { + ownerSubject: __t.string(), + gridId: __t.u64(), + name: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type World = __Infer; + +export const WorldEvent = __t.object("WorldEvent", { + eventId: __t.u64(), + ownerSubject: __t.string(), + keyPrefix: __t.string(), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + message: __t.string(), + createdAt: __t.timestamp(), +}); +export type WorldEvent = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/codegen/app/types/procedures.ts b/spacetime-api-keys-ts/example/src/codegen/app/types/procedures.ts new file mode 100644 index 00000000000..4655da2c690 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/types/procedures.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as CreateAccessKeyProcedure from "../create_access_key_procedure"; +import * as EnsureWorldProcedure from "../ensure_world_procedure"; +import * as RotateAccessKeyProcedure from "../rotate_access_key_procedure"; + +export type CreateAccessKeyArgs = __Infer; +export type CreateAccessKeyResult = __Infer; +export type EnsureWorldArgs = __Infer; +export type EnsureWorldResult = __Infer; +export type RotateAccessKeyArgs = __Infer; +export type RotateAccessKeyResult = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/codegen/app/types/reducers.ts b/spacetime-api-keys-ts/example/src/codegen/app/types/reducers.ts new file mode 100644 index 00000000000..b44ba89c968 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/types/reducers.ts @@ -0,0 +1,30 @@ +// 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 +import BuildReducer from "../build_reducer"; +import ClearReducer from "../clear_reducer"; +import ClearWorldEventsReducer from "../clear_world_events_reducer"; +import PlantReducer from "../plant_reducer"; +import PresenceHeartbeatReducer from "../presence_heartbeat_reducer"; +import PresenceLeaveReducer from "../presence_leave_reducer"; +import ResetWorldReducer from "../reset_world_reducer"; +import RevokeAccessKeyReducer from "../revoke_access_key_reducer"; +import TerraformReducer from "../terraform_reducer"; +import UnbuildReducer from "../unbuild_reducer"; + +export type BuildParams = __Infer; +export type ClearParams = __Infer; +export type ClearWorldEventsParams = __Infer; +export type PlantParams = __Infer; +export type PresenceHeartbeatParams = __Infer; +export type PresenceLeaveParams = __Infer; +export type ResetWorldParams = __Infer; +export type RevokeAccessKeyParams = __Infer; +export type TerraformParams = __Infer; +export type UnbuildParams = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/codegen/app/unbuild_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/unbuild_reducer.ts new file mode 100644 index 00000000000..c061193b961 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/unbuild_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + x: __t.i32(), + y: __t.i32(), +}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/world_event_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/world_event_table.ts new file mode 100644 index 00000000000..ec04549e0ef --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/world_event_table.ts @@ -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({ + eventId: __t.u64().primaryKey().name("event_id"), + ownerSubject: __t.string().name("owner_subject"), + keyPrefix: __t.string().name("key_prefix"), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + message: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/world_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/world_table.ts new file mode 100644 index 00000000000..7a4304b505b --- /dev/null +++ b/spacetime-api-keys-ts/example/src/codegen/app/world_table.ts @@ -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({ + ownerSubject: __t.string().primaryKey().name("owner_subject"), + gridId: __t.u64().name("grid_id"), + name: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/model.ts b/spacetime-api-keys-ts/example/src/model.ts new file mode 100644 index 00000000000..bc20d2bd976 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/model.ts @@ -0,0 +1,381 @@ +export type TimestampLike = { microsSinceUnixEpoch: bigint }; +export type EnumTag = { tag: T }; + +export type ServerConfig = { stdbUri: string; database: string }; + +export type World = { + ownerSubject: string; + gridId: bigint; + name: string; + updatedAt: TimestampLike; +}; +export type Grid = { id: bigint; width: number; height: number }; +export type CellState = { + id: bigint; + gridId: bigint; + x: number; + y: number; + cost: number; + terrain?: string; +}; +export type GridEntity = { + id: bigint; + gridId: bigint; + ownerUserId: string; + x: number; + y: number; + kind: string; + label?: string; +}; +export type WorldEvent = { + eventId: bigint; + ownerSubject: string; + keyPrefix: string; + action: string; + allowed: boolean; + reason: string; + message: string; + createdAt: TimestampLike; +}; +export type PresenceEntry = { + key: string; + scope: string; + subject: string; + status: string; + payloadJson?: string; + lastSeenAt: TimestampLike; +}; +export type ApiKeySummary = { + keyId: string; + prefix: string; + ownerSubject: string; + name: string; + scopesJson: string; + metadataJson?: string; + status: EnumTag<'Active' | 'Revoked'>; + createdAt: TimestampLike; + expiresAt?: TimestampLike; + lastUsedAt?: TimestampLike; + revokedAt?: TimestampLike; +}; + +export const WIDTH = 12; +export const HEIGHT = 8; +export const TOKEN_PREFIX = 'colony.stdb-token'; +export const NAME_KEY = 'colony.name'; +export const COLOR_KEY = 'colony.color'; +export const TILE_SIZE = 78; +export const PAD = 26; +export const MIN_ZOOM = 0.5; +export const MAX_ZOOM = 2.6; +export const ZOOM_STEP = 1.18; +export const HEARTBEAT_MS = 80; +export const KEEPALIVE_MS = 9000; +export const PRESENCE_NAME_MAX = 64; +export const PRESENCE_ROLE_MAX = 32; + +export function safePresenceColor(value: unknown, fallback = ''): string { + return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) + ? value.toLowerCase() + : fallback; +} + +export function safePresenceText( + value: unknown, + fallback: string, + maxLength: number +): string { + if (typeof value !== 'string') return fallback; + const text = value.trim(); + return text ? text.slice(0, maxLength) : fallback; +} + +export function safePresenceCoordinate( + value: unknown, + min: number, + max: number +): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.min(max, Math.max(min, value)) + : 0; +} + +export const SCOPE_VIEW = 'colony:view'; +export const SCOPE_TERRAFORM = 'colony:terraform'; +export const SCOPE_BUILD = 'colony:build'; +export const SCOPE_PLANT = 'colony:plant'; + +export type AccessMode = 'owner' | 'holder'; + +export function parseScopes(json: string): string[] { + try { + const parsed: unknown = JSON.parse(json); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + return []; + } +} + +export function hasScope(scopes: string[], scope: string): boolean { + return ( + scopes.includes('*') || + scopes.includes('colony:*') || + scopes.includes(scope) + ); +} + +export function roleLabel(scopes: string[]): string { + const canTerraform = hasScope(scopes, SCOPE_TERRAFORM); + const canBuild = hasScope(scopes, SCOPE_BUILD); + const canPlant = hasScope(scopes, SCOPE_PLANT); + if (canTerraform && canBuild && canPlant) return 'Collaborator'; + if (canTerraform && !canBuild && !canPlant) return 'Terraformer'; + if (canBuild && !canTerraform && !canPlant) return 'Builder'; + if (canPlant && !canTerraform && !canBuild) return 'Planter'; + if (!canTerraform && !canBuild && !canPlant) return 'Viewer'; + return 'Editor'; +} + +export function permissionsFor(mode: AccessMode, scopes: string[]) { + const owner = mode === 'owner'; + return { + terraform: owner || hasScope(scopes, SCOPE_TERRAFORM), + build: owner || hasScope(scopes, SCOPE_BUILD), + plant: owner || hasScope(scopes, SCOPE_PLANT), + }; +} + +// Share roles: a small, honest set that maps straight onto scopes. +export const ROLES: Array<{ + id: string; + name: string; + scopes: string[]; + blurb: string; +}> = [ + { + id: 'viewer', + name: 'Viewer', + scopes: [SCOPE_VIEW], + blurb: 'Can look around, cannot change anything.', + }, + { + id: 'terraformer', + name: 'Terraformer', + scopes: [SCOPE_VIEW, SCOPE_TERRAFORM], + blurb: 'Can reshape the surface.', + }, + { + id: 'builder', + name: 'Builder', + scopes: [SCOPE_VIEW, SCOPE_BUILD], + blurb: 'Can place structures and roads.', + }, + { + id: 'planter', + name: 'Planter', + scopes: [SCOPE_VIEW, SCOPE_PLANT], + blurb: 'Can plant and clear greenery.', + }, + { + id: 'collaborator', + name: 'Collaborator', + scopes: [SCOPE_VIEW, SCOPE_TERRAFORM, SCOPE_BUILD, SCOPE_PLANT], + blurb: 'Can do everything.', + }, +]; + +export type ToolGroup = 'surface' | 'structure' | 'nature' | 'remove'; +export type Tool = { + id: string; + group: ToolGroup; + kind?: string; + label: string; +}; + +export function toolAllowedFor( + mode: AccessMode, + scopes: string[], + tool: Tool +): boolean { + const permissions = permissionsFor(mode, scopes); + if (tool.group === 'surface') return permissions.terraform; + if (tool.group === 'structure') return permissions.build; + if (tool.group === 'nature') return permissions.plant; + return permissions.build || permissions.plant; +} + +export const STRUCT_GLYPH: Record = { + dome: '', + pod: '', + solar: + '', + road: '', + tree: '', + shrub: + '', + boulder: + '', +}; +export const TOOLS: Tool[] = [ + { id: 'regolith', group: 'surface', kind: 'regolith', label: 'Bare' }, + { id: 'rock', group: 'surface', kind: 'rock', label: 'Rock' }, + { id: 'grass', group: 'surface', kind: 'grass', label: 'Grass' }, + { id: 'water', group: 'surface', kind: 'water', label: 'Water' }, + { id: 'soil', group: 'surface', kind: 'soil', label: 'Soil' }, + { id: 'dome', group: 'structure', kind: 'dome', label: 'Dome' }, + { id: 'pod', group: 'structure', kind: 'pod', label: 'Pod' }, + { id: 'solar', group: 'structure', kind: 'solar', label: 'Solar' }, + { id: 'road', group: 'structure', kind: 'road', label: 'Road' }, + { id: 'tree', group: 'nature', kind: 'tree', label: 'Tree' }, + { id: 'boulder', group: 'nature', kind: 'boulder', label: 'Boulder' }, + { id: 'remove', group: 'remove', label: 'Remove' }, +]; +export const REMOVE_GLYPH = + ''; +export const CLIENT_NATURE = new Set(['tree', 'shrub', 'boulder']); + +// A biodome drawn in 3/4 isometric perspective: a gridded glass shell (latitude +// rings + meridians + top cap) on a short cylindrical base. +export const DOME_SVG = ``; + +// Solid isometric objects, each sitting on a ground shadow. +export const POD_SVG = ``; + +export const SOLAR_SVG = ``; + +// A few tree and boulder variants for natural variety; the variant is chosen +// from the tile position so it is stable across renders and identical for +// everyone viewing the colony. +export const TREE_SVGS = [ + ``, + ``, + ``, +]; + +export const BOULDER_SVGS = [ + ``, + ``, + ``, +]; + +export function variantIndex(x: number, y: number, n: number): number { + const h = ((x * 73856093) ^ (y * 19349663)) >>> 0; + return h % n; +} + +const PRESENCE_PALETTE = [ + '#e0714a', + '#59c6d6', + '#74c56a', + '#f0c05a', + '#c58fe0', + '#f07676', + '#8fb7d6', + '#e08fc0', + '#7ad6b0', + '#d6b84a', + '#9b8cff', + '#5ad1a0', +]; + +export function colorFor(subject: string): string { + let hash = 0; + for (let index = 0; index < subject.length; index++) { + hash = (hash * 31 + subject.charCodeAt(index)) >>> 0; + } + return PRESENCE_PALETTE[hash % PRESENCE_PALETTE.length]; +} + +export type PresencePayload = { + name: string; + role: string; + color: string; + cx: number; + cy: number; + onGrid: boolean; +}; + +export function parsePresencePayload(json?: string): PresencePayload { + const fallback: PresencePayload = { + name: 'Someone', + role: '', + color: '', + cx: 0, + cy: 0, + onGrid: false, + }; + if (!json) return fallback; + try { + const parsed: unknown = JSON.parse(json); + if (typeof parsed !== 'object' || parsed === null) return fallback; + const payload = parsed as Record; + return { + name: safePresenceText(payload.name, 'Someone', PRESENCE_NAME_MAX), + role: safePresenceText(payload.role, '', PRESENCE_ROLE_MAX), + color: safePresenceColor(payload.color), + cx: safePresenceCoordinate(payload.cx, -1, WIDTH + 1), + cy: safePresenceCoordinate(payload.cy, -1, HEIGHT + 1), + onGrid: payload.onGrid === true, + }; + } catch { + return fallback; + } +} + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function worldPixelSize(width: number, height: number) { + return { + width: width * TILE_SIZE + 2 * PAD, + height: height * TILE_SIZE + 2 * PAD, + }; +} + +export function stepDirection( + fromX: number, + fromY: number, + toX: number, + toY: number +): string { + if (toX === fromX && toY === fromY - 1) return 'n'; + if (toX === fromX + 1 && toY === fromY) return 'e'; + if (toX === fromX && toY === fromY + 1) return 's'; + if (toX === fromX - 1 && toY === fromY) return 'w'; + return ''; +} + +export function paintLinePoints( + from: { x: number; y: number }, + to: { x: number; y: number } +): Array<{ x: number; y: number }> { + const points: Array<{ x: number; y: number }> = []; + let { x, y } = from; + let guard = 0; + while ((x !== to.x || y !== to.y) && guard++ < 256) { + if (x !== to.x) x += Math.sign(to.x - x); + else y += Math.sign(to.y - y); + points.push({ x, y }); + } + return points; +} + +export const ENTITY_SVG: Record = { + dome: DOME_SVG, + pod: POD_SVG, + solar: SOLAR_SVG, +}; diff --git a/spacetime-api-keys-ts/example/src/share-key.ts b/spacetime-api-keys-ts/example/src/share-key.ts new file mode 100644 index 00000000000..7e7c66c3e99 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/share-key.ts @@ -0,0 +1,16 @@ +export function shareKeyFromHash(hash: string): string | null { + if (!hash.startsWith('#')) return null; + const key = new URLSearchParams(hash.slice(1)).get('key')?.trim(); + return key || null; +} + +export function parseShareKey(raw: string): string | null { + const value = raw.trim(); + if (!value) return null; + + try { + return shareKeyFromHash(new URL(value).hash); + } catch { + return value; + } +} diff --git a/spacetime-api-keys-ts/example/tsconfig.json b/spacetime-api-keys-ts/example/tsconfig.json new file mode 100644 index 00000000000..ebdadf9f78c --- /dev/null +++ b/spacetime-api-keys-ts/example/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-api-keys-ts/package.json b/spacetime-api-keys-ts/package.json new file mode 100644 index 00000000000..9f39a981e1c --- /dev/null +++ b/spacetime-api-keys-ts/package.json @@ -0,0 +1,63 @@ +{ + "name": "@spacetimedb/api-keys", + "description": "API key issuance, verification, rotation, and usage tracking for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-api-keys-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-api-keys-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "api-keys", + "authentication", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts" + }, + "dependencies": { + "@spacetimedb/crypto": "workspace:^" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-api-keys-ts/scripts/test.ts b/spacetime-api-keys-ts/scripts/test.ts new file mode 100644 index 00000000000..4ecb917f547 --- /dev/null +++ b/spacetime-api-keys-ts/scripts/test.ts @@ -0,0 +1,30 @@ +import * as assert from 'node:assert/strict'; +import { + base64Url, + extractLookupPrefix, + hashApiKey, + hashMatches, + hasScope, +} from '../src/key-utils.ts'; + +assert.equal(base64Url(new Uint8Array([102, 111, 111])), 'Zm9v'); +assert.equal(base64Url(new Uint8Array([255, 255, 255])), '____'); +assert.equal( + extractLookupPrefix('stdb_live_abcdefghijklmnop'), + 'stdb_live_abcdefghij' +); +assert.equal(extractLookupPrefix('missing-secret'), undefined); + +const key = 'stdb_live_abcdefghijklmnop'; +const hash = hashApiKey(key); +assert.match(hash, /^[0-9a-f]{64}$/); +assert.equal(hashMatches(key, hash), true); +assert.equal(hashMatches(`${key}x`, hash), false); +assert.equal(hashMatches(key, 'not-hex'), false); + +assert.equal(hasScope('["files:*","jobs:read"]', 'files:write'), true); +assert.equal(hasScope('["files:*","jobs:read"]', 'jobs:read'), true); +assert.equal(hasScope('["files:*","jobs:read"]', 'admin:write'), false); +assert.equal(hasScope('not-json', 'files:read'), false); + +console.log('api-keys tests passed'); diff --git a/spacetime-api-keys-ts/src/index.ts b/spacetime-api-keys-ts/src/index.ts new file mode 100644 index 00000000000..d10b7a64098 --- /dev/null +++ b/spacetime-api-keys-ts/src/index.ts @@ -0,0 +1,14 @@ +export { default, init } from './submodule/schema'; +export { + add_admin_identity, + apiKeyUsageAdmin, + apiKeysAdmin, + create_api_key, + create_api_key_for_subject, + myApiKeys, + remove_admin_identity, + revoke_api_key, + revoke_api_key_for_subject, + rotate_api_key, + sweep_api_key_usage, +} from './submodule/operations'; diff --git a/spacetime-api-keys-ts/src/key-utils.ts b/spacetime-api-keys-ts/src/key-utils.ts new file mode 100644 index 00000000000..7ab63a8c616 --- /dev/null +++ b/spacetime-api-keys-ts/src/key-utils.ts @@ -0,0 +1,79 @@ +import { + bytesToHex, + hexToBytes, + sha256, + timingSafeEqual, +} from '@spacetimedb/crypto'; + +export const LOOKUP_SECRET_CHARS = 10; +const textEncoder = new TextEncoder(); + +export function base64Url(bytes: Uint8Array): string { + const alphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + let out = ''; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + out += alphabet[bytes[i] >> 2]; + out += alphabet[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += alphabet[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + out += alphabet[bytes[i + 2] & 63]; + } + if (i < bytes.length) { + out += alphabet[bytes[i] >> 2]; + if (i + 1 === bytes.length) { + out += alphabet[(bytes[i] & 3) << 4]; + } else { + out += alphabet[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += alphabet[(bytes[i + 1] & 15) << 2]; + } + } + return out; +} + +export function extractLookupPrefix(key: string): string | undefined { + const trimmed = key.trim(); + const lastUnderscore = trimmed.lastIndexOf('_'); + if (lastUnderscore <= 0) return undefined; + const keyPrefix = trimmed.slice(0, lastUnderscore); + const secret = trimmed.slice(lastUnderscore + 1); + if (secret.length < LOOKUP_SECRET_CHARS) return undefined; + return `${keyPrefix}_${secret.slice(0, LOOKUP_SECRET_CHARS)}`; +} + +export function hashApiKey(key: string): string { + return bytesToHex(sha256(textEncoder.encode(key))); +} + +export function hashMatches(key: string, expectedHex: string): boolean { + try { + return timingSafeEqual( + hexToBytes(expectedHex), + sha256(textEncoder.encode(key)) + ); + } catch { + return false; + } +} + +export function hasScope( + scopesJson: string, + requiredScope: string | undefined +): boolean { + if (requiredScope === undefined || requiredScope.trim() === '') return true; + const required = requiredScope.trim(); + let scopes: unknown; + try { + scopes = JSON.parse(scopesJson); + } catch { + return false; + } + if (!Array.isArray(scopes)) return false; + return scopes.some( + scope => + typeof scope === 'string' && + (scope === '*' || + scope === required || + (scope.endsWith(':*') && required.startsWith(scope.slice(0, -1)))) + ); +} diff --git a/spacetime-api-keys-ts/src/submodule.ts b/spacetime-api-keys-ts/src/submodule.ts new file mode 100644 index 00000000000..3b095be7921 --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule.ts @@ -0,0 +1,33 @@ +export { default } from './submodule/schema'; +export { + ApiKeyStatus, + apiKey, + apiKeyAdminIdentity, + apiKeyCreateResult, + apiKeyStatus, + apiKeySummary, + apiKeyUsage, + apiKeyVerifyResult, + t, +} from './submodule/schema'; +export { installApiKeys } from './submodule/install'; +export { + add_admin_identity, + apiKeyUsageAdmin, + apiKeysAdmin, + createApiKey, + create_api_key, + create_api_key_for_subject, + myApiKeys, + remove_admin_identity, + revokeApiKey, + revoke_api_key, + revoke_api_key_for_subject, + rotateApiKey, + rotate_api_key, + sweep_api_key_usage, + verifyApiKey, + type ApiKeyVerifyResult, + type CreateApiKeyArgs, + type VerifyApiKeyArgs, +} from './submodule/operations'; diff --git a/spacetime-api-keys-ts/src/submodule/auth.ts b/spacetime-api-keys-ts/src/submodule/auth.ts new file mode 100644 index 00000000000..6837339b855 --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule/auth.ts @@ -0,0 +1,19 @@ +import type { + ReducerModuleCtx, + TransactionModuleCtx, + ViewModuleCtx, +} from './schema'; +import { SenderError } from './schema'; + +type AdminCtx = ReducerModuleCtx | TransactionModuleCtx | ViewModuleCtx; + +export function isAdmin(ctx: AdminCtx, sender = ctx.sender): boolean { + return ctx.db.apiKeyAdminIdentity.identity.find(sender) != null; +} + +export function requireAdmin( + ctx: ReducerModuleCtx | TransactionModuleCtx, + sender = ctx.sender +): void { + if (!isAdmin(ctx, sender)) throw new SenderError('api_keys.not_authorized'); +} diff --git a/spacetime-api-keys-ts/src/submodule/install.ts b/spacetime-api-keys-ts/src/submodule/install.ts new file mode 100644 index 00000000000..f48d6aafdaa --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule/install.ts @@ -0,0 +1,9 @@ +import type { ReducerModuleCtx } from './schema'; + +export function installApiKeys(ctx: ReducerModuleCtx) { + if (ctx.db.apiKeyAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.apiKeyAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-api-keys-ts/src/submodule/operations.ts b/spacetime-api-keys-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..75e1671f5f9 --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule/operations.ts @@ -0,0 +1,696 @@ +import { Timestamp } from 'spacetimedb'; +import { Range, type Infer } from 'spacetimedb/server'; +import { + ApiKeyStatus, + apiKey, + apiKeyCreateResult, + apiKeySummary, + apiKeyUsageSummary, + spacetimedb, + t, + SenderError, + type ReducerModuleCtx, + type ViewModuleCtx, + type WriteCtx, +} from './schema'; +import { isAdmin, requireAdmin } from './auth'; +import { sha256 } from '@spacetimedb/crypto'; +import { + base64Url, + extractLookupPrefix, + hashApiKey, + hashMatches, + hasScope, + LOOKUP_SECRET_CHARS, +} from '../key-utils'; + +const DEFAULT_KEY_PREFIX = 'stdb_live'; +const MAX_NAME_LENGTH = 120; +const MAX_OWNER_SUBJECT_LENGTH = 256; +const MAX_SCOPE_LENGTH = 128; +const MAX_SCOPES = 128; +const MAX_METADATA_JSON_LENGTH = 8192; +const MAX_RAW_KEY_LENGTH = 128; +const MAX_ACTIVE_KEYS_PER_OWNER = 50; +const MAX_EXPIRATION_SECONDS = 60 * 60 * 24 * 365 * 10; +const MAX_USAGE_SWEEP_ROWS = 1000; +const ONE_SECOND_MICROS = 1_000_000n; + +const textEncoder = new TextEncoder(); + +type ApiKeyRow = Infer; + +export type CreateApiKeyArgs = { + ownerSubject: string; + name: string; + scopesJson: string; + metadataJson?: string | undefined; + expiresInSeconds?: number | undefined; + keyPrefix?: string | undefined; +}; + +export type VerifyApiKeyArgs = { + key: string; + requiredScope?: string | undefined; + action?: string | undefined; +}; + +export type ApiKeyVerifyResult = { + allowed: boolean; + reason: string; + keyId: string | undefined; + prefix: string | undefined; + ownerSubject: string | undefined; + scopesJson: string | undefined; + metadataJson: string | undefined; +}; + +function throwSenderError(message: string): never { + throw new SenderError(message); +} + +function takeRows(rows: Iterable, limit: number): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +function uniqueSecret(ctx: WriteCtx): string { + const material = [ + ctx.newUuidV7().toString(), + ctx.newUuidV7().toString(), + ctx.timestamp.microsSinceUnixEpoch.toString(), + ].join(':'); + // The key is `${keyPrefix}_${secret}` and lookup splits on the last '_', so + // the secret must not contain '_' or the prefix lookup lands mid-secret and + // verification fails. base64url can emit '_', so fold it to '-'. + return base64Url(sha256(textEncoder.encode(material))).replace(/_/g, '-'); +} + +function normalizeKeyPrefix(prefix: string | undefined): string { + const value = (prefix ?? DEFAULT_KEY_PREFIX).trim(); + if (!/^[A-Za-z][A-Za-z0-9_]{1,31}$/.test(value)) { + throwSenderError('api_keys.invalid_key_prefix'); + } + return value; +} + +function generateRawKey( + ctx: WriteCtx, + keyPrefix: string +): { key: string; prefix: string } { + const secret = uniqueSecret(ctx); + const key = `${keyPrefix}_${secret}`; + const prefix = `${keyPrefix}_${secret.slice(0, LOOKUP_SECRET_CHARS)}`; + return { key, prefix }; +} + +function normalizeName(name: string): string { + const value = name.trim().replace(/\s+/g, ' '); + if (value.length === 0 || value.length > MAX_NAME_LENGTH) { + throwSenderError('api_keys.invalid_name'); + } + return value; +} + +function normalizeOwnerSubject(ownerSubject: string): string { + const value = ownerSubject.trim(); + if (value.length === 0 || value.length > MAX_OWNER_SUBJECT_LENGTH) { + throwSenderError('api_keys.invalid_owner_subject'); + } + return value; +} + +function normalizeAction( + action: string | undefined, + requiredScope: string | undefined +): string { + const value = (action ?? requiredScope ?? 'verify').trim(); + if (value.length === 0 || value.length > MAX_SCOPE_LENGTH) { + throwSenderError('api_keys.invalid_action'); + } + return value; +} + +function normalizeRequiredScope( + requiredScope: string | undefined +): string | undefined { + if (requiredScope === undefined) return undefined; + const value = requiredScope.trim(); + if ( + value.length === 0 || + value.length > MAX_SCOPE_LENGTH || + !/^[A-Za-z0-9:_*.-]+$/.test(value) + ) { + throwSenderError('api_keys.invalid_required_scope'); + } + return value; +} + +function normalizeScopesJson(scopesJson: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(scopesJson); + } catch { + throwSenderError('api_keys.invalid_scopes_json'); + } + if (!Array.isArray(parsed)) throwSenderError('api_keys.invalid_scopes_json'); + if (parsed.length === 0 || parsed.length > MAX_SCOPES) { + throwSenderError('api_keys.invalid_scopes_json'); + } + const seen = new Set(); + const scopes: string[] = []; + for (const raw of parsed) { + if (typeof raw !== 'string') + throwSenderError('api_keys.invalid_scopes_json'); + const scope = raw.trim(); + if (scope.length === 0 || scope.length > MAX_SCOPE_LENGTH) { + throwSenderError('api_keys.invalid_scopes_json'); + } + if (!/^[A-Za-z0-9:_*.-]+$/.test(scope)) { + throwSenderError('api_keys.invalid_scopes_json'); + } + if (!seen.has(scope)) { + seen.add(scope); + scopes.push(scope); + } + } + return JSON.stringify(scopes); +} + +function normalizeMetadataJson( + metadataJson: string | undefined +): string | undefined { + if (metadataJson === undefined) return undefined; + const value = metadataJson.trim(); + if (value.length === 0) return undefined; + if (value.length > MAX_METADATA_JSON_LENGTH) + throwSenderError('api_keys.invalid_metadata_json'); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throwSenderError('api_keys.invalid_metadata_json'); + } + if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') { + throwSenderError('api_keys.invalid_metadata_json'); + } + return JSON.stringify(parsed); +} + +function senderSubject(sender: unknown): string { + if ( + sender && + typeof (sender as { toHexString?: unknown }).toHexString === 'function' + ) { + return (sender as { toHexString: () => string }).toHexString(); + } + return String(sender); +} + +function expiresAtFromSeconds( + ctx: WriteCtx, + expiresInSeconds: number | undefined +): Timestamp | undefined { + if (expiresInSeconds === undefined) return undefined; + if ( + !Number.isInteger(expiresInSeconds) || + expiresInSeconds <= 0 || + expiresInSeconds > MAX_EXPIRATION_SECONDS + ) { + throwSenderError('api_keys.invalid_expires_in_seconds'); + } + return new Timestamp( + (ctx.timestamp.microsSinceUnixEpoch as bigint) + + BigInt(expiresInSeconds) * ONE_SECOND_MICROS + ); +} + +function isExpired(row: ApiKeyRow, now: Timestamp): boolean { + return ( + row.expiresAt !== undefined && + (row.expiresAt.microsSinceUnixEpoch as bigint) <= + (now.microsSinceUnixEpoch as bigint) + ); +} + +function toSummary(row: ApiKeyRow) { + return { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + name: row.name, + scopesJson: row.scopesJson, + metadataJson: row.metadataJson, + status: row.status, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + lastUsedAt: row.lastUsedAt, + revokedAt: row.revokedAt, + }; +} + +function toCreateResult(row: ApiKeyRow, key: string) { + return { + keyId: row.keyId, + key, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + name: row.name, + scopesJson: row.scopesJson, + metadataJson: row.metadataJson, + status: row.status, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + }; +} + +function recordUsage( + ctx: WriteCtx, + args: { + keyId?: string | undefined; + prefix?: string | undefined; + ownerSubject?: string | undefined; + action: string; + allowed: boolean; + reason: string; + } +): void { + ctx.db.apiKeyUsage.insert({ + usageId: 0n, + keyId: args.keyId ?? '', + prefix: args.prefix ?? '', + ownerSubject: args.ownerSubject ?? '', + action: args.action, + allowed: args.allowed, + reason: args.reason, + usedAt: ctx.timestamp, + usedAtOrder: -ctx.timestamp.microsSinceUnixEpoch, + }); +} + +export function createApiKey(ctx: WriteCtx, args: CreateApiKeyArgs) { + const ownerSubject = normalizeOwnerSubject(args.ownerSubject); + const name = normalizeName(args.name); + const scopesJson = normalizeScopesJson(args.scopesJson); + const metadataJson = normalizeMetadataJson(args.metadataJson); + const expiresAt = expiresAtFromSeconds(ctx, args.expiresInSeconds); + const keyPrefix = normalizeKeyPrefix(args.keyPrefix); + let activeKeys = 0; + for (const row of ctx.db.apiKey.ownerSubject.filter(ownerSubject)) { + if (row.status.tag === 'Active' && !isExpired(row, ctx.timestamp)) { + activeKeys++; + if (activeKeys >= MAX_ACTIVE_KEYS_PER_OWNER) { + throwSenderError('api_keys.active_key_limit_reached'); + } + } + } + const { key, prefix } = generateRawKey(ctx, keyPrefix); + const keyId = `ak_${ctx.newUuidV7().toString()}`; + const row = ctx.db.apiKey.insert({ + keyId, + prefix, + hash: hashApiKey(key), + ownerSubject, + name, + scopesJson, + metadataJson, + status: ApiKeyStatus.Active, + createdAt: ctx.timestamp, + createdAtOrder: -ctx.timestamp.microsSinceUnixEpoch, + expiresAt, + lastUsedAt: undefined, + revokedAt: undefined, + }); + recordUsage(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action: 'create', + allowed: true, + reason: 'created', + }); + return toCreateResult(row, key); +} + +function denied( + ctx: WriteCtx, + args: { + keyId?: string | undefined; + prefix?: string | undefined; + ownerSubject?: string | undefined; + action: string; + reason: string; + record?: boolean | undefined; + } +): ApiKeyVerifyResult { + if (args.record !== false) recordUsage(ctx, { ...args, allowed: false }); + return { + allowed: false, + reason: args.reason, + keyId: undefined, + prefix: args.prefix, + ownerSubject: undefined, + scopesJson: undefined, + metadataJson: undefined, + }; +} + +export function verifyApiKey( + ctx: WriteCtx, + args: VerifyApiKeyArgs +): ApiKeyVerifyResult { + const requiredScope = normalizeRequiredScope(args.requiredScope); + const action = normalizeAction(args.action, requiredScope); + const key = args.key.trim(); + if (key.length === 0 || key.length > MAX_RAW_KEY_LENGTH) { + return denied(ctx, { action, reason: 'invalid_key', record: false }); + } + const prefix = extractLookupPrefix(key); + if (prefix === undefined) { + return denied(ctx, { action, reason: 'invalid_key', record: false }); + } + const row = ctx.db.apiKey.prefix.find(prefix); + if (!row) { + return denied(ctx, { + prefix, + action, + reason: 'unknown_key', + record: false, + }); + } + if (!hashMatches(key, row.hash)) { + return denied(ctx, { + prefix, + action, + reason: 'invalid_key', + record: false, + }); + } + if (row.status.tag !== 'Active') { + return denied(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action, + reason: 'revoked', + }); + } + if (isExpired(row, ctx.timestamp)) { + return denied(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action, + reason: 'expired', + }); + } + if (!hasScope(row.scopesJson, requiredScope)) { + return denied(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action, + reason: 'scope_denied', + }); + } + ctx.db.apiKey.keyId.update({ + ...row, + lastUsedAt: ctx.timestamp, + }); + recordUsage(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action, + allowed: true, + reason: 'allowed', + }); + return { + allowed: true, + reason: 'allowed', + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + scopesJson: row.scopesJson, + metadataJson: row.metadataJson, + }; +} + +function canManageKey( + ctx: ReducerModuleCtx | WriteCtx, + row: ApiKeyRow, + subject: string +): boolean { + return row.ownerSubject === subject || isAdmin(ctx, ctx.sender); +} + +export function revokeApiKey( + ctx: WriteCtx, + args: { keyId: string; ownerSubject?: string | undefined } +): void { + const keyId = args.keyId.trim(); + if (!keyId) throwSenderError('api_keys.invalid_key_id'); + const row = ctx.db.apiKey.keyId.find(keyId); + if (!row) throwSenderError('api_keys.not_found'); + const subject = normalizeOwnerSubject( + args.ownerSubject ?? senderSubject(ctx.sender) + ); + if (!canManageKey(ctx, row, subject)) + throwSenderError('api_keys.not_authorized'); + if (row.status.tag === 'Revoked') return; + ctx.db.apiKey.keyId.update({ + ...row, + status: ApiKeyStatus.Revoked, + revokedAt: ctx.timestamp, + }); + recordUsage(ctx, { + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action: 'revoke', + allowed: true, + reason: 'revoked', + }); +} + +export function rotateApiKey( + ctx: WriteCtx, + args: { + keyId: string; + ownerSubject?: string | undefined; + expiresInSeconds?: number | undefined; + keyPrefix?: string | undefined; + } +) { + const keyId = args.keyId.trim(); + if (!keyId) throwSenderError('api_keys.invalid_key_id'); + const row = ctx.db.apiKey.keyId.find(keyId); + if (!row) throwSenderError('api_keys.not_found'); + const subject = normalizeOwnerSubject( + args.ownerSubject ?? senderSubject(ctx.sender) + ); + if (!canManageKey(ctx, row, subject)) + throwSenderError('api_keys.not_authorized'); + const keyPrefix = normalizeKeyPrefix(args.keyPrefix); + const { key, prefix } = generateRawKey(ctx, keyPrefix); + const expiresAt = + args.expiresInSeconds === undefined + ? row.expiresAt + : expiresAtFromSeconds(ctx, args.expiresInSeconds); + const updated = { + ...row, + prefix, + hash: hashApiKey(key), + status: ApiKeyStatus.Active, + expiresAt, + lastUsedAt: undefined, + revokedAt: undefined, + }; + ctx.db.apiKey.keyId.update(updated); + recordUsage(ctx, { + keyId: row.keyId, + prefix, + ownerSubject: row.ownerSubject, + action: 'rotate', + allowed: true, + reason: 'rotated', + }); + return toCreateResult(updated, key); +} + +export const create_api_key = spacetimedb.procedure( + { + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => + createApiKey(tx, { + ownerSubject: senderSubject(ctx.sender), + name: args.name, + scopesJson: args.scopesJson, + metadataJson: args.metadataJson, + expiresInSeconds: args.expiresInSeconds, + keyPrefix: args.keyPrefix, + }) + ) +); + +export const create_api_key_for_subject = spacetimedb.procedure( + { + ownerSubject: t.string(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + return createApiKey(tx, args); + }) +); + +export const rotate_api_key = spacetimedb.procedure( + { + keyId: t.string(), + expiresInSeconds: t.option(t.u32()), + keyPrefix: t.option(t.string()), + }, + apiKeyCreateResult, + (ctx, args) => + ctx.withTx(tx => + rotateApiKey(tx, { + keyId: args.keyId, + ownerSubject: senderSubject(ctx.sender), + expiresInSeconds: args.expiresInSeconds, + keyPrefix: args.keyPrefix, + }) + ) +); + +export const revoke_api_key = spacetimedb.reducer( + { keyId: t.string() }, + (ctx, args) => { + revokeApiKey(ctx, { + keyId: args.keyId, + ownerSubject: senderSubject(ctx.sender), + }); + } +); + +export const revoke_api_key_for_subject = spacetimedb.reducer( + { keyId: t.string(), ownerSubject: t.string() }, + (ctx, args) => { + requireAdmin(ctx); + revokeApiKey(ctx, args); + } +); + +export const add_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + if (ctx.db.apiKeyAdminIdentity.identity.find(args.identity) == null) { + ctx.db.apiKeyAdminIdentity.insert({ + identity: args.identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + } +); + +export const remove_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + const row = ctx.db.apiKeyAdminIdentity.identity.find(args.identity); + if (!row) return; + if (ctx.db.apiKeyAdminIdentity.count() <= 1n) { + throwSenderError('api_keys.cannot_remove_last_admin'); + } + ctx.db.apiKeyAdminIdentity.delete(row); + } +); + +export const sweep_api_key_usage = spacetimedb.reducer( + { maxAgeSeconds: t.u32(), maxRows: t.u32() }, + (ctx, args) => { + requireAdmin(ctx); + if ( + args.maxAgeSeconds < 3600 || + args.maxAgeSeconds > MAX_EXPIRATION_SECONDS || + args.maxRows < 1 || + args.maxRows > MAX_USAGE_SWEEP_ROWS + ) { + throwSenderError('api_keys.invalid_sweep_args'); + } + const cutoff = new Timestamp( + (ctx.timestamp.microsSinceUnixEpoch as bigint) - + BigInt(args.maxAgeSeconds) * ONE_SECOND_MICROS + ); + let examined = 0; + for (const row of ctx.db.apiKeyUsage.usedAt.filter( + new Range(undefined, { tag: 'included', value: cutoff }) + )) { + if (examined >= args.maxRows) break; + examined++; + ctx.db.apiKeyUsage.usageId.delete(row.usageId); + } + } +); + +export const myApiKeys = spacetimedb.view( + { name: 'my_api_keys', public: true }, + t.array(apiKeySummary), + ctx => { + const subject = senderSubject(ctx.sender); + return takeRows(ctx.db.apiKey.ownerSubject.filter(subject), 500).map( + toSummary + ); + } +); + +export const apiKeysAdmin = spacetimedb.view( + { name: 'api_keys_admin', public: true }, + t.array(apiKeySummary), + (ctx: ViewModuleCtx) => { + if (!isAdmin(ctx)) return []; + const rows = takeRows( + ctx.db.apiKey.createdAtOrder.filter(new Range()), + 200 + ); + return rows.map(toSummary); + } +); + +export const apiKeyUsageAdmin = spacetimedb.view( + { name: 'api_key_usage_admin', public: true }, + t.array(apiKeyUsageSummary), + (ctx: ViewModuleCtx) => { + if (!isAdmin(ctx)) return []; + return takeRows( + ctx.db.apiKeyUsage.usedAtOrder.filter(new Range()), + 100 + ).map(row => ({ + usageId: row.usageId, + keyId: row.keyId, + prefix: row.prefix, + ownerSubject: row.ownerSubject, + action: row.action, + allowed: row.allowed, + reason: row.reason, + usedAt: row.usedAt, + })); + } +); diff --git a/spacetime-api-keys-ts/src/submodule/schema.ts b/spacetime-api-keys-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..d17acc78537 --- /dev/null +++ b/spacetime-api-keys-ts/src/submodule/schema.ts @@ -0,0 +1,130 @@ +import { + SenderError, + schema, + table, + t, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { installApiKeys } from './install'; + +export const apiKeyStatus = t.enum('ApiKeyStatus', ['Active', 'Revoked']); + +export const ApiKeyStatus = { + Active: { tag: 'Active' as const }, + Revoked: { tag: 'Revoked' as const }, +}; + +export const apiKey = table( + { name: 'api_key', public: false }, + { + keyId: t.string().primaryKey(), + prefix: t.string().unique(), + hash: t.string(), + ownerSubject: t.string().index(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + status: apiKeyStatus.index(), + createdAt: t.timestamp().index(), + createdAtOrder: t.i64().index(), + expiresAt: t.option(t.timestamp()), + lastUsedAt: t.option(t.timestamp()), + revokedAt: t.option(t.timestamp()), + } +); + +export const apiKeyAdminIdentity = table( + { name: 'api_key_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +export const apiKeyUsage = table( + { name: 'api_key_usage', public: false }, + { + usageId: t.u64().primaryKey().autoInc(), + keyId: t.string().index(), + prefix: t.string(), + ownerSubject: t.string().index(), + action: t.string().index(), + allowed: t.bool().index(), + reason: t.string().index(), + usedAt: t.timestamp().index(), + usedAtOrder: t.i64().index(), + } +); + +export const apiKeySummary = t.object('ApiKeySummary', { + keyId: t.string(), + prefix: t.string(), + ownerSubject: t.string(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + status: apiKeyStatus, + createdAt: t.timestamp(), + expiresAt: t.option(t.timestamp()), + lastUsedAt: t.option(t.timestamp()), + revokedAt: t.option(t.timestamp()), +}); + +export const apiKeyCreateResult = t.object('ApiKeyCreateResult', { + keyId: t.string(), + key: t.string(), + prefix: t.string(), + ownerSubject: t.string(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + status: apiKeyStatus, + createdAt: t.timestamp(), + expiresAt: t.option(t.timestamp()), +}); + +export const apiKeyUsageSummary = t.object('ApiKeyUsageSummary', { + usageId: t.u64(), + keyId: t.string(), + prefix: t.string(), + ownerSubject: t.string(), + action: t.string(), + allowed: t.bool(), + reason: t.string(), + usedAt: t.timestamp(), +}); + +export const apiKeyVerifyResult = t.object('ApiKeyVerifyResult', { + allowed: t.bool(), + reason: t.string(), + keyId: t.option(t.string()), + prefix: t.option(t.string()), + ownerSubject: t.option(t.string()), + scopesJson: t.option(t.string()), + metadataJson: t.option(t.string()), +}); + +export const spacetimedb = schema({ + apiKey, + apiKeyAdminIdentity, + apiKeyUsage, +}); + +export const init = spacetimedb.init(ctx => { + installApiKeys(ctx); +}); + +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; + +export { SenderError, t }; diff --git a/spacetime-api-keys-ts/tsconfig.json b/spacetime-api-keys-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-api-keys-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-auth-ts/LICENSE.txt b/spacetime-auth-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-auth-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-auth-ts/README.md b/spacetime-auth-ts/README.md new file mode 100644 index 00000000000..e745f203ecd --- /dev/null +++ b/spacetime-auth-ts/README.md @@ -0,0 +1,181 @@ +# @spacetimedb/auth + +Authentication primitives for SpacetimeDB TypeScript modules. The package +provides password and OAuth handlers, ES256 sessions, connection binding, +profile management, and in-module rate limiting. + +## Install + +```bash +npm install @spacetimedb/auth @spacetimedb/rate-limit spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +Mount `@spacetimedb/rate-limit/submodule` beside the auth submodule. The +host module owns HTTP route registration and any mail-delivery adapter. + +## Usage + +### Integrate into an application + +Import the mountable namespace, register the handlers your application needs, +then install Auth and its Rate Limit dependency from the host `init` hook. + +```ts +import { schema } from 'spacetimedb/server'; +import * as auth from '@spacetimedb/auth/submodule'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; + +const spacetimedb = schema({ auth, rateLimit }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + rateLimit.installRateLimit(ctx.as.rateLimit); + auth.installAuth(ctx.as.auth); +}); +``` + +Register only the HTTP handlers and connection procedures your application +uses. For example, a login route calls `auth.passwordLoginHandler` with +`ctx.as.auth`; the host owns its router and trusted-proxy policy. + +The complete wiring covers routes, connection binding, caller-scoped views, +and mail callbacks in the +[Auth example host module](./example/spacetimedb/). + +Handlers set `Secure` cookies by default. Local HTTP examples pass +`{ secureCookies: false }` explicitly. To apply IP-based limits behind a proxy, +pass an `AuthHttpOptions` value naming the header that the proxy overwrites: + +```ts +const authHttp = { + trustedProxyHeader: 'x-forwarded-for', +} satisfies auth.AuthHttpOptions; + +auth.passwordLoginHandler(ctx.as.auth, req, authHttp); +``` + +Register the handlers on the host router and expose authenticated application +operations through the connection binding: + +```ts +import { Router } from 'spacetimedb/server'; + +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + auth.passwordSignupHandler(ctx.as.auth, req) +); + +export const link_connection = spacetimedb.reducer( + auth.linkConnectionParams, + (ctx, args) => auth.link_connection(ctx.as.auth, args) +); + +export const update_profile = spacetimedb.reducer( + auth.updateProfileParams, + (ctx, args) => auth.update_profile(ctx.as.auth, args) +); + +export const router = spacetimedb.httpRouter( + new Router().post('/auth/password/signup', authPasswordSignup) +); +``` + +The browser obtains a session through HTTP, binds its SpacetimeDB connection, +then calls normal generated operations: + +```ts +const signup = await fetch('/auth/password/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password, name }), + credentials: 'same-origin', +}); +if (!signup.ok) throw new Error(`signup_failed:${signup.status}`); +const { token } = (await signup.json()) as { token: string }; + +await conn.reducers.linkConnection({ sessionToken: token }); +await conn.reducers.updateProfile({ name: 'Ada', image: undefined }); +``` + +## API + +The root entrypoint exports table builders, password and OAuth handlers, JWT +and key helpers, connection-binding procedures, and caller helpers. The +`./submodule` entrypoint exports the mountable schema, registered operations, +views, handlers, and `installAuth`. The host module owns `init`. + +Supported flows: + +- Email/password signup and login +- ES256 JWT session cookies, refresh, logout, revoke, and sweep +- Google and GitHub OAuth through provider user-info endpoints +- Email verification and password-reset handler factories +- SpacetimeDB identity binding through `link_connection` +- Caller profile reads and updates +- Fixed-window limits for authentication endpoints + +Mounted operations: + +- Configuration and keys: `set_auth_config`, `get_auth_public_key`. +- Connection binding: `link_connection`, `unlink_connection`, and `whoami`. +- Profiles and sessions: `update_profile`, `list_my_sessions`, + `revoke_my_session`, and administrative `revoke_session`. +- HTTP handlers: password signup/login, session refresh, current user, logout, + Google and GitHub OAuth, password reset, and email verification. +- Caller helpers: `getCallerUserId` and the `my_auth_user` scoped view. + +The handler exports are `passwordSignupHandler`, `passwordLoginHandler`, +`meHandler`, `refreshHandler`, `logoutHandler`, `googleStartHandler`, +`googleCallbackHandler`, `githubStartHandler`, `githubCallbackHandler`, +`makeForgotPasswordHandler`, `resetPasswordHandler`, +`makeEmailVerifyRequestHandler`, and `makeEmailVerifyHandler`. + +Package entrypoints: + +- `@spacetimedb/auth/submodule` is the normal host integration surface. +- `@spacetimedb/auth/handlers` exports HTTP handler factories. +- `@spacetimedb/auth/tables` exports lower-level table definitions. +- `@spacetimedb/auth/crypto`, `/jwt`, and `/keys` export focused helpers. +- `@spacetimedb/auth` re-exports the supported public surface. + +## Security guarantees + +- Passwords use scrypt with parameters encoded in the stored hash. +- Signing keys, OAuth secrets, and session state live in private tables. +- The publishing owner seeds the initial admin state during `init`. +- Authentication handlers use deterministic module context for time and + randomness when running inside SpacetimeDB. +- Default authentication limits are production-oriented. Email-based limits + work directly. IP-based limits and stored session IPs are + enabled only when the host explicitly selects a trusted proxy header. + +- Google honors the provider's `email_verified` claim. GitHub selects a + verified address from the `/user/emails` response. +- When a new OAuth identity has the same email as an existing user, the callback + returns `account_link_required`. The host can provide an authenticated account + linking flow. +- OAuth completion redirects accept application-relative paths up to 2,048 + characters. Unsafe absolute, protocol-relative, backslash, fragment, control + character, and encoded forms are rejected before state is stored. + +Applications remain responsible for route exposure, cookie policy, mail +delivery, and the user experience for explicit account linking. + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +The unit suite covers key generation, JWT validation, password hashing, PKCE, +tokens, and UUID generation. Build the example module to validate mounted +schema integration. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-auth-ts/example/.env.example b/spacetime-auth-ts/example/.env.example new file mode 100644 index 00000000000..5b173ef3b9c --- /dev/null +++ b/spacetime-auth-ts/example/.env.example @@ -0,0 +1,32 @@ +# Copy to .env. The example server loads this on startup and bootstraps auth. + +# ---------------- Static server ---------------- +HOST=127.0.0.1 +PORT=8791 + +# ---------------- SpacetimeDB ---------------- +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_APP_DATABASE=spacetime-auth-example +STDB_SERVER=http://127.0.0.1:3000 + +# ---------------- Auth ---------------- +AUTH_ISSUER_URL=http://localhost:8791 +AUTH_BASE_URL=http://localhost:8791 +AUTH_COOKIE_NAME=stdb_auth +AUTH_SESSION_TTL_SECONDS=604800 + +# Optional. Leave blank to have the module generate an ES256 keypair on first startup bootstrap. +# Use \n escapes if putting a PEM on one line. +AUTH_ES256_PRIVATE_KEY_PEM= + +# Optional override for the local namespace-capable CLI. + +# OAuth (optional). Without these the corresponding buttons are disabled. +# Redirect URI to register with each provider: +# http://localhost:8791/auth/google/callback +# http://localhost:8791/auth/github/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/spacetime-auth-ts/example/.gitignore b/spacetime-auth-ts/example/.gitignore new file mode 100644 index 00000000000..3ebf7ec943f --- /dev/null +++ b/spacetime-auth-ts/example/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +src/codegen/ +public/app.js +public/app.js.map +.env + +# STDB runtime data lives in %LOCALAPPDATA%/auth-ts-example/stdb-data (not here) +# Listed defensively in case anyone runs STDB with --data-dir=. +.stdb-data/ +.secrets/ +*.pid diff --git a/spacetime-auth-ts/example/README.md b/spacetime-auth-ts/example/README.md new file mode 100644 index 00000000000..f409bf797b0 --- /dev/null +++ b/spacetime-auth-ts/example/README.md @@ -0,0 +1,195 @@ +# Auth notes example + +This example is an end-to-end authentication application built with +[`@spacetimedb/auth`](../). A small realtime notes feature shows how an +authenticated application user is linked to a SpacetimeDB connection and used +for server-side authorization. + +## What this demonstrates + +- Password signup, login, logout, and session refresh. +- Optional Google and GitHub OAuth. +- ES256-signed application sessions stored in an HTTP-only cookie. +- Password reset and email-verification flows with a development mailer. +- Listing and revoking the current user's sessions. +- Linking an application session to a SpacetimeDB connection. +- Caller-scoped notes and profile views with realtime updates. +- Reconnecting the browser after a WebSocket interruption. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity. A fresh publish seeds the publisher as the initial auth + administrator. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-auth-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open , create an account, and add a note. + +`build:module:fresh` deletes and recreates only the local `spacetime-auth-example` +database. Use `pnpm run build:module` when the existing local data must be +preserved. + +## Use in your project + +This workspace tests the component source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/auth @spacetimedb/rate-limit spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the HTTP +routes, connection binding, and caller-view patterns you use; replace the +console mailer and development server before production. + +## Configuration + +| Variable | Default | Purpose | +| ------------------------------ | ------------------------ | ----------------------------------------------------------------- | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8791` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth proxy. | +| `STDB_SERVER` | `STDB_HTTP` | CLI target used during startup configuration. | +| `STDB_APP_DATABASE` | `spacetime-auth-example` | Published database name. | +| `AUTH_ISSUER_URL` | `http://localhost:8791` | JWT issuer and OAuth redirect origin. | +| `AUTH_BASE_URL` | `http://localhost:8791` | Browser-visible auth base URL. | +| `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | +| `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | +| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by component | Optional persistent ES256 private key. | +| Google/GitHub client variables | empty | Enables the matching OAuth provider when both values are present. | + +The server loads `.env` and calls `set_auth_config` on every startup using the +logged-in CLI identity. Restart the server after changing auth or OAuth values. + +Keep `STDB_URI`, `STDB_HTTP`, and `STDB_SERVER` on the same SpacetimeDB instance. +Set `AUTH_ISSUER_URL` and `AUTH_BASE_URL` to the exact origin users load, including +scheme and port. + +## OAuth setup + +Register an application with each provider and add its client ID and secret to +`.env`. For the default local origin, register these callbacks: + +- Google: `http://localhost:8791/auth/google/callback` +- GitHub: `http://localhost:8791/auth/github/callback` + +The browser hides a provider button unless both corresponding values are present. +Do not put provider secrets in frontend code or `/api/config`. + +## Architecture + +```text +Browser + -> same-origin /auth/* requests -> Node proxy -> module HTTP router + -> SpacetimeDB WebSocket -> link_connection -> my_notes / my_auth_user + +SpacetimeDB module + -> private auth/session/account tables + -> application connection bindings + -> caller-scoped notes and profile views +``` + +The Node proxy exists so development cookies remain same-origin. After signup, +login, or refresh, the browser links the returned application token to its +SpacetimeDB connection before subscribing. The module's `my_notes` and +`my_auth_user` views derive the user from that binding and ignore user IDs sent +by the browser. + +## Development mailer + +The example intentionally uses a console mailer. Password-reset and +email-verification messages, including their one-time links, appear in the +SpacetimeDB module logs. Production deployments require a delivery provider. + +Production applications should send mail through a real provider, avoid logging +tokens, and apply appropriate retention and redaction to application logs. + +## Security and deployment boundaries + +- A fresh publish seeds the publishing owner in the private + `auth_admin_identity` table. +- Auth configuration can be changed only by an existing administrator. +- Password hashes, OAuth secrets, signing keys, session cookies, reset tokens, and + `.env` must not be committed or logged. +- `AUTH_ES256_PRIVATE_KEY_PEM` should come from durable secret storage in + production. Relying on a generated development key makes sessions dependent on + the database's retained state. +- The included Express process is a development server. Production deployment + needs TLS, explicit network binding, origin/host policy, trusted-proxy settings, + durable secrets, and process supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test, use two accounts and verify: + +1. Signup, login, reload-based refresh, and logout. +2. Create, edit, and delete notes with realtime subscription updates. +3. One account cannot subscribe to or mutate the other account's notes. +4. Session listing and revocation invalidate the selected session. +5. Password reset and email verification complete using the one-time links in the + module log. +6. Each configured OAuth provider completes its callback and establishes a linked + SpacetimeDB session. + +Useful owner-only diagnostics: + +```powershell +spacetime sql --server http://127.0.0.1:3000 spacetime-auth-example "SELECT user_id, email FROM auth_user" +spacetime sql --server http://127.0.0.1:3000 spacetime-auth-example "SELECT * FROM auth_connection_binding" +``` + +## Troubleshooting + +- **Startup configuration fails:** verify the database is published and the CLI is + logged in as its owner or a registered auth administrator. +- **Cookies fail to restore:** use one consistent hostname. `localhost` and + `127.0.0.1` are different cookie hosts. +- **Scoped subscriptions are empty:** confirm `link_connection` succeeded before + the subscriptions were created. +- **OAuth reports a redirect mismatch:** compare the registered callback byte for + byte with the URL derived from `AUTH_ISSUER_URL`. +- **Sessions fail after a fresh publish:** clear site data and sign in again; + the database was deliberately replaced. + +## Important files + +- `spacetimedb/src/index.ts` - auth mount, scoped views, notes, and HTTP handlers. +- `server.ts` - startup configuration, static serving, and auth proxy. +- `src/app.ts` - auth calls, connection linking, subscriptions, and reconnects. +- `public/index.html` - notes and account-management interface. +- `public/ui.js` - DOM state, rendering, and interaction handling. +- `public/styles.css` - application presentation. diff --git a/spacetime-auth-ts/example/package.json b/spacetime-auth-ts/example/package.json new file mode 100644 index 00000000000..e033aa3041e --- /dev/null +++ b/spacetime-auth-ts/example/package.json @@ -0,0 +1,29 @@ +{ + "name": "spacetime-auth-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-auth-ts/example/public/assets/brand.svg b/spacetime-auth-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-auth-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-auth-ts/example/public/assets/logo.svg b/spacetime-auth-ts/example/public/assets/logo.svg new file mode 100644 index 00000000000..adaf36cbf9c --- /dev/null +++ b/spacetime-auth-ts/example/public/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/spacetime-auth-ts/example/public/index.html b/spacetime-auth-ts/example/public/index.html new file mode 100644 index 00000000000..a36026b2c71 --- /dev/null +++ b/spacetime-auth-ts/example/public/index.html @@ -0,0 +1,280 @@ + + + + + + + SpacetimeDB Notes + + + +
+ + SpacetimeDB Notes +
+
+
+
+ SpacetimeDB +

Notes Test App

+
+
+ + + initializing… + + +
+
+ + +
+
+
+ +

Welcome back

+

Sign in to continue.

+ +
+ + +
+ +
or
+ +
+ + +
+ +
+ + +
+ + + +

+ Forgot password? +

+

+ Don't have an account? + Sign up +

+
+
+
+ + + +
+ + + +
+ + + + + diff --git a/spacetime-auth-ts/example/public/styles.css b/spacetime-auth-ts/example/public/styles.css new file mode 100644 index 00000000000..353c6d2e7fd --- /dev/null +++ b/spacetime-auth-ts/example/public/styles.css @@ -0,0 +1,1069 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=Source+Code+Pro:wght@400;500;600&display=swap'); + +:root { + /* Tokens copied from spacetime-web/spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-white); + background: var(--color-shade7); + overflow: hidden; +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} +a { + color: var(--color-green); + text-decoration: none; +} +a:visited { + color: var(--color-purple); +} +a:hover { + text-decoration: underline; +} + +/* Compact scrollbars matching the SpacetimeDB dashboard. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) var(--color-shade7); +} +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 2px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade3); +} +*::-webkit-scrollbar-corner { + background: var(--color-shade7); +} + +/* shell + topnav */ +.shell { + width: min(1320px, calc(100% - 32px)); + margin: 14px auto; + height: calc(100dvh - 28px); + display: flex; + flex-direction: column; + gap: 14px; +} +.topnav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #0d1920, #0b1319); + padding: 9px 12px; + box-shadow: inset 0 1px 0 #26435166; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; +} +.brand-wordmark { + display: block; + height: 28px; +} +.brand-sub { + margin: 0; + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 500; + line-height: 1.2; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.topnav-actions { + display: flex; + align-items: center; + gap: 8px; +} +.meta-pill { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid #27414e; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #c6d1e1; + background: transparent; +} +.meta-pill.good { + border-color: #31684c; + color: var(--color-green); +} +.meta-pill.err { + border-color: #6a2929; + color: var(--color-red); +} +.meta-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-n4); +} +.meta-pill.good .meta-dot { + background: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +.meta-pill.err .meta-dot { + background: var(--color-red); +} + +/* main layout */ +.main { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 380px 1fr; + gap: 14px; + align-items: stretch; +} +.main.anon { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient( + ellipse 80% 50% at 50% 0%, + var(--color-green-20), + transparent 60% + ), + var(--color-shade7); +} +.main.anon .auth-stack { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ============================================================ + Auth panel. Shared block across the auth-using test apps. + Uses STDB tokens (--color-*, --font-*, --radius-*). + Keep these rules in sync across apps. + ============================================================ */ +.auth-card { + width: 100%; + max-width: 380px; + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + padding: 28px; + display: flex; + flex-direction: column; + gap: 12px; +} +.auth-logo { + width: 56px; + height: auto; + margin: 0 auto 4px; + display: block; +} +.auth-card h1 { + font-family: var(--font-inter); + font-size: 18px; + font-weight: 700; + margin: 0; + text-align: center; + color: var(--color-n1); +} +.auth-sub { + font-family: var(--font-inter); + font-size: 13px; + color: var(--color-n4); + margin: 0 0 8px; + text-align: center; +} +.auth-oauth { + display: flex; + flex-direction: column; + gap: 8px; +} +.btn.oauth { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px 14px; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 500; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + color: var(--color-n1); + border-radius: var(--radius-sm); + cursor: pointer; +} +.btn.oauth:hover:not(:disabled) { + background: var(--color-shade4); + border-color: var(--color-n4); +} +.btn.oauth svg { + flex-shrink: 0; + width: 16px; + height: 16px; +} +.btn.block { + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} +.auth-divider { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0; + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.auth-divider::before, +.auth-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--color-shade4); +} +.auth-field { + display: flex; + flex-direction: column; + gap: 4px; +} +.auth-field label { + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-n4); +} +.auth-field input { + background: var(--color-shade6); + border: 1px solid var(--color-shade4); + color: var(--color-n1); + font-family: var(--font-inter); + font-size: 13px; + padding: 8px 10px; + border-radius: var(--radius-sm); + outline: none; +} +.auth-field input:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +.auth-foot { + margin: 0; + text-align: center; + font-family: var(--font-inter); + font-size: 12px; + color: var(--color-n4); +} +.auth-foot a { + color: var(--color-green); + cursor: pointer; + text-decoration: none; + font-weight: 600; +} +.auth-foot a:hover { + text-decoration: underline; +} +.auth-card .btn.primary.block { + margin-top: 4px; +} +/* Lock down sizing so the card renders identically across apps + regardless of their per-app global input/.btn rules. */ +.auth-card { + width: 380px; + gap: 12px; +} +.auth-card .auth-logo { + width: 56px; + height: 56px; +} +.auth-card h1 { + font-size: 18px; + line-height: 24px; +} +.auth-card .auth-sub { + font-size: 13px; + line-height: 18px; +} +.auth-card .auth-field input, +.auth-card .btn { + height: 40px; + box-sizing: border-box; + width: 100%; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; +} +.auth-card .auth-field input { + padding: 0 12px; +} +.auth-card .btn.oauth { + padding: 0 14px; +} +.auth-card .auth-field label { + line-height: 14px; +} +.auth-card .auth-foot { + font-size: 12px; + line-height: 18px; +} +.divider { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 10px; + align-items: center; + margin: 14px 0; + font-family: var(--font-ibm); + font-size: 10px; + color: var(--color-n4); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.divider::before, +.divider::after { + content: ''; + height: 1px; + background: #17303b; +} +.toggle-foot { + margin: 14px 0 0; + text-align: center; + font-size: 12px; + color: var(--color-n4); +} +.toggle-foot a { + color: var(--color-green); + text-decoration: none; + cursor: pointer; + font-weight: 600; +} +.toggle-foot a:hover { + text-decoration: underline; +} +@media (max-width: 960px) { + .main:not(.anon) { + grid-template-columns: 1fr; + } +} +.panel { + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 18px; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.panel h2 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 700; + display: inline-flex; + align-items: baseline; + gap: 8px; +} +.panel h2 .count { + font-family: var(--font-ibm); + font-size: 11px; + font-weight: 500; + color: var(--color-n4); +} +.panel-sub { + margin: 0 0 14px; + color: var(--color-n4); + font-size: 12px; + line-height: 1.5; +} +.panel-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0 -18px -18px; + padding: 0 18px 18px; +} + +/* form controls */ +label { + display: block; + font-size: 11px; + color: var(--color-n4); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} +.field { + margin-bottom: 12px; +} +input, +select, +textarea { + width: 100%; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + color: var(--color-text); + font-family: inherit; + font-size: 13px; + padding: 9px 11px; + outline: none; + transition: + border-color 0.2s, + box-shadow 0.2s; +} +input:focus, +select:focus, +textarea:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +textarea { + font-family: var(--font-ibm); + font-size: 12px; + min-height: 60px; + resize: vertical; +} +input.mono { + font-family: var(--font-ibm); + font-size: 12px; +} + +/* Buttons match spacetimedb.com Button.module.css: + primary = n3 bg, n8 text, white hover, green active, green focus outline + tertiary = shade7 bg, n2 text, shade4 hover, green active + text = transparent, green text + danger = bordered, soft orange text, soft orange hover wash */ +.btn { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 8px 16px; + border-radius: 4px; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; + border: 1px solid transparent; + background: var(--color-shade7); + color: var(--color-n2); + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s, + color 0.2s; +} +.btn:hover:not(:disabled) { + background: var(--color-shade4); + color: var(--color-white); +} +.btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} + +.btn.danger { + background: transparent; + border: 1px solid var(--color-shade1); + color: var(--color-orange); +} +.btn.danger:hover:not(:disabled) { + background: rgba(255, 158, 158, 0.08); + border-color: var(--color-orange); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn.tiny { + height: auto; + font-size: 11px; + padding: 4px 8px; +} +.btn.block { + width: 100%; +} + +.row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +/* avatar + dropdown menu */ +.avatar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--color-shade1); + background: var(--color-shade5); + color: var(--color-n2); + font-family: var(--font-inter); + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s; +} +.avatar-btn:hover { + background: var(--color-shade4); + border-color: var(--color-white); +} +.avatar-btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.avatar-wrap { + position: relative; +} +.avatar-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 320px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5); + padding: 16px; + z-index: 70; + opacity: 0; + transform: translateY(-6px); + pointer-events: none; + transition: + opacity 160ms ease, + transform 160ms ease; +} +.avatar-menu.is-open { + opacity: 1; + transform: none; + pointer-events: auto; +} +.avatar-menu .who { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} +.avatar-menu .who .big-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--color-shade7); + border: 1px solid var(--color-shade1); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--color-white); +} +.avatar-menu .who .who-text { + min-width: 0; +} +.avatar-menu .who .name { + font-weight: 600; + font-size: 13px; + color: var(--color-white); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.avatar-menu .who .email { + font-size: 12px; + color: var(--color-n4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-ibm); +} +.avatar-menu hr { + border: none; + border-top: 1px solid var(--color-shade1); + margin: 12px 0; +} +.avatar-menu .menu-actions { + display: flex; + flex-direction: column; + gap: 6px; +} +.avatar-menu details { + margin: 4px 0 8px; +} +.avatar-menu details summary { + cursor: pointer; + font-size: 11px; + color: var(--color-n4); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; + list-style: none; + padding: 4px 0; +} +.avatar-menu details summary::-webkit-details-marker { + display: none; +} +.avatar-menu details summary::after { + content: ' ▸'; + opacity: 0.5; +} +.avatar-menu details[open] summary::after { + content: ' ▾'; +} +.avatar-menu .id-row { + font-family: var(--font-ibm); + font-size: 11px; + padding: 8px 10px; + border-radius: var(--radius); + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + margin-top: 6px; + word-break: break-all; + color: var(--color-n3); +} +.avatar-menu .id-row .lbl { + color: var(--color-n4); + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 10px; + font-weight: 600; + font-family: var(--font-inter); + margin-bottom: 2px; +} + +/* Compose + notes grid */ +.notes-view { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} +.notes-shell { + display: flex; + flex-direction: column; + gap: 18px; + width: 100%; + margin: 0 auto; + padding: 24px 0; +} +.compose-card { + background: var(--color-shade6); + border: 1px solid var(--color-shade1); + border-radius: var(--radius-lg); + padding: 12px 14px; + transition: + box-shadow 0.2s, + border-color 0.2s; +} +.compose-card:focus-within { + border-color: var(--color-white); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} +.compose-card .compose-title, +.compose-card .compose-body { + background: transparent; + border: none; + padding: 4px 0; + font-size: 14px; + color: var(--color-text); +} +.compose-card .compose-title { + font-weight: 600; + font-family: var(--font-inter); +} +.compose-card .compose-title::placeholder, +.compose-card .compose-body::placeholder { + color: var(--color-n4); +} +.compose-card .compose-body { + font-family: var(--font-inter); + font-size: 13px; + min-height: 24px; +} +.compose-card .compose-title:focus, +.compose-card .compose-body:focus { + box-shadow: none; + outline: none; +} +.compose-card.collapsed .compose-title, +.compose-card.collapsed .compose-actions { + display: none; +} +.compose-card .compose-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; + border-top: 1px solid var(--color-shade1); + padding-top: 8px; +} + +.notes-grid { + column-count: 4; + column-gap: 12px; +} +@media (max-width: 1200px) { + .notes-grid { + column-count: 3; + } +} +@media (max-width: 900px) { + .notes-grid { + column-count: 2; + } +} +@media (max-width: 560px) { + .notes-grid { + column-count: 1; + } +} + +.note-card { + break-inside: avoid; + margin-bottom: 12px; + background: var(--color-shade6); + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + padding: 12px 14px; + position: relative; + transition: + border-color 0.2s, + box-shadow 0.2s; + cursor: default; + display: inline-block; + width: 100%; +} +.note-card:hover { + border-color: var(--color-white); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} +.note-card .nc-title { + font-weight: 600; + font-size: 14px; + color: var(--color-white); + margin-bottom: 4px; + word-break: break-word; + overflow-wrap: anywhere; +} +.note-card .nc-body { + font-size: 13px; + color: var(--color-n2); + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-inter); +} +.note-card .nc-meta { + margin-top: 10px; + font-family: var(--font-ibm); + font-size: 10px; + color: var(--color-n4); +} +.note-card .nc-del { + position: absolute; + top: 6px; + right: 6px; + width: 24px; + height: 24px; + border-radius: 50%; + background: transparent; + border: none; + color: var(--color-n4); + cursor: pointer; + opacity: 0; + transition: + opacity 0.15s, + background 0.15s, + color 0.15s; + font-size: 14px; + line-height: 1; +} +.note-card:hover .nc-del { + opacity: 1; +} +.note-card .nc-del:hover { + background: var(--color-shade4); + color: var(--color-orange); +} +.note-card { + cursor: pointer; +} +.note-card .nc-del { + cursor: pointer; +} + +/* edit modal */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 90; + display: none; + align-items: center; + justify-content: center; + padding: 24px; +} +.modal-backdrop.is-open { + display: flex; +} +.modal-card { + width: min(560px, 100%); + max-height: calc(100dvh - 48px); + background: var(--color-shade6); + border: 1px solid var(--color-shade1); + border-radius: var(--radius-lg); + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 4px; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6); +} +.modal-card .modal-title, +.modal-card .modal-body { + background: transparent; + border: none; + padding: 6px 0; + font-size: 15px; + color: var(--color-text); + outline: none; + box-shadow: none; +} +.modal-card .modal-title { + font-weight: 600; +} +.modal-card .modal-body { + font-family: var(--font-inter); + font-size: 14px; + min-height: 120px; + flex: 1 1 auto; + resize: vertical; +} +.modal-card .modal-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--color-shade1); + gap: 8px; +} +.modal-card .modal-actions .right { + display: flex; + gap: 8px; +} + +.empty-hero { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 32px 16px; + color: var(--color-n4); +} +.empty-hero .glyph { + font-size: 22px; + opacity: 0.45; + margin-bottom: 4px; +} +.empty-hero .title { + font-size: 13px; + font-weight: 600; + color: var(--color-n3); +} +.empty-hero .sub { + font-size: 11px; + font-family: var(--font-ibm); + color: var(--color-n4); +} + +/* error toast */ +#toast { + position: fixed; + top: 18px; + left: 50%; + transform: translateX(-50%); + z-index: 80; + pointer-events: none; + max-width: min(420px, calc(100% - 44px)); +} +#toast:empty { + display: none; +} +.toast-msg { + pointer-events: auto; + padding: 10px 14px; + border-radius: var(--radius); + font-size: 13px; + font-family: var(--font-ibm); + background: var(--color-orange-08); + border: 1px solid var(--color-orange-45); + color: var(--color-orange); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5); + animation: slideIn 180ms ease; + cursor: pointer; +} +.toast-msg.ok { + background: rgba(76, 244, 144, 0.08); + border-color: rgba(76, 244, 144, 0.45); + color: var(--color-green); +} +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.boot-splash { + position: fixed; + inset: 0; + z-index: 9999; + background: var(--color-shade7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + color: var(--color-green); + transition: opacity 200ms ease; +} +.boot-splash svg { + animation: boot-pulse 1.4s ease-in-out infinite; +} +.boot-splash-label { + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-n4); +} +.boot-splash.fading { + opacity: 0; + pointer-events: none; +} +@keyframes boot-pulse { + 0%, + 100% { + opacity: 0.4; + transform: scale(0.95); + } + 50% { + opacity: 1; + transform: scale(1); + } +} diff --git a/spacetime-auth-ts/example/public/ui.js b/spacetime-auth-ts/example/public/ui.js new file mode 100644 index 00000000000..86d92c54af2 --- /dev/null +++ b/spacetime-auth-ts/example/public/ui.js @@ -0,0 +1,488 @@ +const $ = id => document.getElementById(id); + +function fmtTimestamp(micros) { + if (micros == null) return '-'; + return new Date(Number(BigInt(micros) / 1000n)).toLocaleString(); +} +function fmtUnixSeconds(s) { + if (!s) return '-'; + return new Date(s * 1000).toLocaleString(); +} +function escapeHtml(s) { + return String(s ?? '').replace( + /[&<>"']/g, + c => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[c] + ); +} +function showToast(kind, msg, dur = 4500) { + const el = $('toast'); + el.innerHTML = `
${escapeHtml(msg)}
`; + const child = el.firstChild; + child.addEventListener('click', () => (el.innerHTML = '')); + setTimeout(() => { + if (el.firstChild === child) el.innerHTML = ''; + }, dur); +} + +// OAuth callback error surfacing. The submodule redirects to /?error=... +// on failure (state expired, denied, etc.). +(function checkOauthError() { + const params = new URLSearchParams(window.location.search); + const err = params.get('error'); + if (err) { + showToast('err', `OAuth: ${err}`, 8000); + params.delete('error'); + const qs = params.toString(); + const url = window.location.pathname + (qs ? '?' + qs : ''); + window.history.replaceState({}, '', url); + } +})(); + +// Connection pill +window.addEventListener('auth:conn', e => { + const pill = $('conn-pill'); + const text = $('conn-text'); + pill.classList.remove('good', 'err'); + if (e.detail.state === 'connected') { + pill.classList.add('good'); + text.textContent = 'connected'; + } else if (e.detail.state === 'connecting' || e.detail.state === 'idle') { + text.textContent = e.detail.state; + } else { + pill.classList.add('err'); + text.textContent = e.detail.detail || 'disconnected'; + } +}); +window.addEventListener('auth:server-config', e => { + const oauth = e.detail?.oauth || {}; + const google = $('oauth-google'); + const github = $('oauth-github'); + google.disabled = !oauth.google; + github.disabled = !oauth.github; + google.title = oauth.google + ? '' + : 'Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env'; + github.title = oauth.github + ? '' + : 'Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in .env'; + google.setAttribute('aria-disabled', String(!oauth.google)); + github.setAttribute('aria-disabled', String(!oauth.github)); +}); + +function dismissBootSplash() { + const splash = document.getElementById('bootSplash'); + if (!splash) return; + splash.classList.add('fading'); + setTimeout(() => splash.remove(), 250); +} +window.addEventListener('auth:ready', dismissBootSplash); +setTimeout(dismissBootSplash, 4000); + +function initial(s) { + const t = String(s ?? '').trim(); + return t ? t[0].toUpperCase() : '?'; +} +window.addEventListener('auth:state', e => { + const user = e.detail.user; + const anon = $('anon-view'); + const view = $('user-view'); + const wrap = $('avatar-wrap'); + + if (user) { + anon.hidden = true; + view.hidden = false; + wrap.hidden = false; + const letter = initial(user.name || user.email); + $('avatar-btn').textContent = letter; + $('big-avatar').textContent = letter; + $('who-name').textContent = user.name || user.email; + $('who-email').textContent = user.email; + $('who-uid').textContent = user.userId; + $('who-stdb').textContent = e.detail.senderHex ?? '-'; + $('who-exp').textContent = fmtUnixSeconds(e.detail.sessionExpiresAt); + $('email-unverified').hidden = !!user.emailVerified; + } else { + anon.hidden = false; + view.hidden = true; + wrap.hidden = true; + $('avatar-menu').classList.remove('is-open'); + } +}); + +// Avatar dropdown +const avatarBtn = $('avatar-btn'); +const avatarMenu = $('avatar-menu'); +avatarBtn.addEventListener('click', e => { + e.stopPropagation(); + const open = avatarMenu.classList.toggle('is-open'); + avatarBtn.setAttribute('aria-expanded', String(open)); + if (open) refreshSessionsList(); +}); +async function refreshSessionsList() { + const box = $('sessions-list'); + try { + const r = await window.auth.listMySessions(); + if (!r.sessions.length) { + box.innerHTML = '
No active sessions.
'; + return; + } + box.innerHTML = r.sessions + .map( + s => ` +
+
${fmtTimestamp(s.createdAt.microsSinceUnixEpoch)}
+
${escapeHtml(s.userAgent ?? 'unknown UA')}
+ +
+ ` + ) + .join(''); + box.querySelectorAll('[data-revoke]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Revoke this session?')) return; + try { + await window.auth.revokeMySession(btn.dataset.revoke); + refreshSessionsList(); + } catch (err) { + showToast('err', err.message ?? String(err)); + } + }); + }); + } catch (err) { + box.innerHTML = `
${escapeHtml(err.message ?? String(err))}
`; + } +} +$('resend-verify-btn').addEventListener('click', () => + tryCall('resend-verify-btn', async () => { + await window.auth.requestEmailVerify(); + showToast('ok', 'verification email sent (check STDB log in dev)', 7000); + }) +); +document.addEventListener('click', e => { + if (!avatarMenu.classList.contains('is-open')) return; + if (!avatarMenu.contains(e.target) && e.target !== avatarBtn) { + avatarMenu.classList.remove('is-open'); + avatarBtn.setAttribute('aria-expanded', 'false'); + } +}); + +const notesById = new Map(); +window.addEventListener('auth:notes', e => { + const notes = e.detail.notes; + notesById.clear(); + notes.forEach(n => notesById.set(n.noteId, n)); + + const list = $('notes-list'); + const empty = $('notes-empty'); + if (notes.length === 0) { + list.hidden = true; + empty.hidden = false; + return; + } + empty.hidden = true; + list.hidden = false; + list.innerHTML = notes + .map( + n => ` +
+ + ${n.title ? `
${escapeHtml(n.title)}
` : ''} +
${escapeHtml(n.body)}
+
${fmtTimestamp(n.createdAt.microsSinceUnixEpoch)}
+
+ ` + ) + .join(''); + list.querySelectorAll('[data-del]').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + if (!confirm('Delete this note?')) return; + try { + await window.auth.deleteNote(btn.dataset.del); + } catch (err) { + showToast('err', err.message ?? String(err)); + } + }); + }); + list.querySelectorAll('[data-edit]').forEach(card => { + card.addEventListener('click', () => openEdit(card.dataset.edit)); + }); + + // Refresh open modal if its note row changed underneath us. + if (editingId && notesById.has(editingId)) { + const updated = notesById.get(editingId); + if ( + $('edit-title').value === editOriginal.title && + $('edit-body').value === editOriginal.body + ) { + // user hasn't typed; pull the new values in + $('edit-title').value = updated.title; + $('edit-body').value = updated.body; + editOriginal = { title: updated.title, body: updated.body }; + } + } +}); + +// Edit modal +let editingId = null; +let editOriginal = { title: '', body: '' }; +const editBackdrop = $('edit-backdrop'); +const editTitle = $('edit-title'); +const editBody = $('edit-body'); + +function openEdit(noteId) { + const n = notesById.get(noteId); + if (!n) return; + editingId = noteId; + editOriginal = { title: n.title, body: n.body }; + editTitle.value = n.title; + editBody.value = n.body; + editBackdrop.classList.add('is-open'); + setTimeout(() => editBody.focus(), 0); +} +function closeEdit() { + editBackdrop.classList.remove('is-open'); + editingId = null; +} +async function saveEdit() { + if (!editingId) return; + const id = editingId; + const title = editTitle.value; + const body = editBody.value; + if (title === editOriginal.title && body === editOriginal.body) { + closeEdit(); + return; + } + try { + await window.auth.updateNote({ noteId: id, title, body }); + closeEdit(); + } catch (err) { + showToast('err', err.message ?? String(err)); + } +} +async function deleteFromEdit() { + if (!editingId) return; + if (!confirm('Delete this note?')) return; + const id = editingId; + try { + await window.auth.deleteNote(id); + closeEdit(); + } catch (err) { + showToast('err', err.message ?? String(err)); + } +} +editBackdrop.addEventListener('click', e => { + if (e.target === editBackdrop) saveEdit(); +}); +$('edit-cancel').addEventListener('click', closeEdit); +$('edit-save').addEventListener('click', saveEdit); +$('edit-del').addEventListener('click', deleteFromEdit); +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && editBackdrop.classList.contains('is-open')) + closeEdit(); +}); + +// Compose-card expand/collapse +const compose = $('compose-card'); +const ntBody = $('nt-body'); +const ntTitle = $('nt-title'); +function expandCompose() { + compose.classList.remove('collapsed'); +} +function collapseCompose() { + compose.classList.add('collapsed'); + ntTitle.value = ''; + ntBody.value = ''; + ntBody.style.height = ''; +} +ntBody.addEventListener('focus', expandCompose); +ntTitle.addEventListener('focus', expandCompose); +ntBody.addEventListener('input', () => { + // auto-grow / shrink textarea + ntBody.style.height = 'auto'; + ntBody.style.height = ntBody.scrollHeight + 'px'; +}); +$('nt-cancel').addEventListener('click', collapseCompose); + +async function tryCall(btnId, fn, okMsg) { + const btn = $(btnId); + btn.disabled = true; + try { + await fn(); + if (okMsg) showToast('ok', okMsg); + } catch (err) { + showToast('err', err.message ?? String(err)); + } finally { + btn.disabled = false; + } +} + +let authMode = 'login'; // 'login' | 'signup' | 'forgot' | 'reset' +let resetToken = null; +function applyAuthMode() { + const isSignup = authMode === 'signup'; + const isForgot = authMode === 'forgot'; + const isReset = authMode === 'reset'; + $('auth-title').textContent = isSignup + ? 'Create account' + : isForgot + ? 'Reset your password' + : isReset + ? 'Set a new password' + : 'Sign in'; + $('auth-sub').textContent = isSignup + ? 'Email and password, 8+ chars.' + : isForgot + ? "Enter your email. We'll send a reset link if the account exists." + : isReset + ? 'Enter a new password.' + : 'Welcome back.'; + $('em-name-field').hidden = !isSignup; + document.querySelector('label[for=em-email]').parentElement.hidden = isReset; + document.querySelector('label[for=em-pass]').parentElement.hidden = isForgot; + $('em-pass').setAttribute( + 'autocomplete', + isSignup || isReset ? 'new-password' : 'current-password' + ); + $('em-pass').setAttribute( + 'placeholder', + isSignup || isReset ? 'min 8 chars' : '' + ); + $('em-btn').textContent = isSignup + ? 'Create account' + : isForgot + ? 'Send reset email' + : isReset + ? 'Reset password' + : 'Sign in'; + $('forgot-foot').hidden = isSignup || isForgot || isReset; + $('toggle-prompt').textContent = isSignup + ? 'Already have an account?' + : "Don't have an account?"; + $('toggle-link').textContent = + isForgot || isReset ? 'Back to sign in' : isSignup ? 'Sign in' : 'Sign up'; +} +$('toggle-link').addEventListener('click', () => { + if (authMode === 'forgot' || authMode === 'reset') { + authMode = 'login'; + } else { + authMode = authMode === 'login' ? 'signup' : 'login'; + } + applyAuthMode(); +}); +$('forgot-link').addEventListener('click', () => { + authMode = 'forgot'; + applyAuthMode(); +}); +// Block default form navigation; the existing em-btn click handler +// fires for both clicks and Enter-to-submit. +$('auth-form').addEventListener('submit', e => e.preventDefault()); + +$('em-btn').addEventListener('click', () => + tryCall('em-btn', async () => { + const email = $('em-email').value.trim(); + const password = $('em-pass').value; + if (authMode === 'signup') { + await window.auth.signup({ + email, + password, + name: $('em-name').value.trim() || undefined, + }); + showToast('ok', 'account created'); + } else if (authMode === 'forgot') { + await window.auth.forgotPassword(email); + showToast( + 'ok', + 'If the account exists, a reset link was sent. (Check STDB log in dev.)', + 7000 + ); + authMode = 'login'; + applyAuthMode(); + } else if (authMode === 'reset') { + if (!resetToken) throw new Error('missing_token'); + await window.auth.resetPassword(resetToken, password); + showToast('ok', 'password reset; sign in below'); + resetToken = null; + authMode = 'login'; + window.history.replaceState({}, '', '/'); + applyAuthMode(); + } else { + await window.auth.login({ email, password }); + showToast('ok', 'signed in'); + } + }) +); + +// Detect reset-password landing +(function checkResetToken() { + if (window.location.pathname === '/auth/password/reset') { + const params = new URLSearchParams(window.location.search); + const token = params.get('token'); + if (token) { + resetToken = token; + authMode = 'reset'; + } + } +})(); +// Detect verify-success redirect from STDB module +(function checkVerifyOk() { + const params = new URLSearchParams(window.location.search); + if (params.get('verified') === '1') { + showToast('ok', 'email verified', 5000); + params.delete('verified'); + const qs = params.toString(); + window.history.replaceState( + {}, + '', + window.location.pathname + (qs ? '?' + qs : '') + ); + } +})(); +applyAuthMode(); +$('logout-btn').addEventListener('click', () => + tryCall('logout-btn', () => window.auth.logout(), 'signed out') +); +$('whoami-btn').addEventListener('click', () => + tryCall('whoami-btn', async () => { + const r = await window.auth.whoami(); + showToast( + 'ok', + `userId=${r.userId ?? 'null'} sender=${r.senderIdentityHex.slice(0, 16)}…`, + 6000 + ); + }) +); +$('nt-btn').addEventListener('click', () => + tryCall( + 'nt-btn', + async () => { + const title = ntTitle.value.trim(); + const body = ntBody.value; + if (!title && !body.trim()) return; + await window.auth.createNote({ title: title || '', body }); + collapseCompose(); + }, + 'note saved' + ) +); +$('oauth-google').addEventListener('click', () => { + if ($('oauth-google').disabled) { + showToast('err', 'Google OAuth is not configured', 4000); + return; + } + window.auth.oauthStart('google'); +}); +$('oauth-github').addEventListener('click', () => { + if ($('oauth-github').disabled) { + showToast('err', 'GitHub OAuth is not configured', 4000); + return; + } + window.auth.oauthStart('github'); +}); diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts new file mode 100644 index 00000000000..16ff8d0a8b0 --- /dev/null +++ b/spacetime-auth-ts/example/server.ts @@ -0,0 +1,201 @@ +// Serves the frontend and proxies /auth/* to the STDB module's HTTP routes +// so cookies are same-origin. Browser connects to STDB over WS directly. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared/root env supplies secrets; example-local env supplies app defaults. +// Blank placeholders in the example .env should not erase shared secrets. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8791', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-auth-example'; +const AUTH_ISSUER_URL = + process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; +const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME ?? 'stdb_auth'; +const AUTH_SESSION_TTL_SECONDS = Number.parseInt( + process.env.AUTH_SESSION_TTL_SECONDS ?? `${60 * 60 * 24 * 7}`, + 10 +); +if ( + !Number.isInteger(AUTH_SESSION_TTL_SECONDS) || + AUTH_SESSION_TTL_SECONDS <= 0 +) { + throw new Error('AUTH_SESSION_TTL_SECONDS must be a positive integer'); +} +const GOOGLE_OAUTH_ENABLED = Boolean( + process.env.GOOGLE_CLIENT_ID?.trim() && + process.env.GOOGLE_CLIENT_SECRET?.trim() +); +const GITHUB_OAUTH_ENABLED = Boolean( + process.env.GITHUB_CLIENT_ID?.trim() && + process.env.GITHUB_CLIENT_SECRET?.trim() +); +const STDB_SERVER = process.env.STDB_SERVER ?? STDB_HTTP; +const SPACETIME_BIN = 'spacetime'; + +function configuredValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function configuredPem(value: string | undefined): string | undefined { + return configuredValue(value)?.replace(/\\n/g, '\n'); +} + +const opt = (value: string | undefined) => + value === undefined ? JSON.stringify([1, []]) : JSON.stringify([0, value]); + +function configureAuthFromEnv(): void { + const args = [ + JSON.stringify(AUTH_ISSUER_URL), + opt(AUTH_BASE_URL), + opt(AUTH_COOKIE_NAME), + JSON.stringify([0, AUTH_SESSION_TTL_SECONDS]), + opt(configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM)), + opt(configuredValue(process.env.GOOGLE_CLIENT_ID)), + opt(configuredValue(process.env.GOOGLE_CLIENT_SECRET)), + opt(configuredValue(process.env.GITHUB_CLIENT_ID)), + opt(configuredValue(process.env.GITHUB_CLIENT_SECRET)), + ]; + + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, STDB_APP_DB, 'set_auth_config', ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`auth config bootstrap failed (exit ${result.status})`); + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +// Reset-password email link. Serve the SPA so the frontend can read ?token=... +// and show the reset form. Must be registered BEFORE the /auth proxy below. +app.get('/auth/password/reset', (_req: Request, res: Response) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// Proxy /auth/* to STDB module HTTP handlers. Cookie + Set-Cookie pass through. +// Using app.use as middleware since Express 4's `app.all('/auth/*', ...)` does +// not match nested paths reliably. +app.use('/auth', async (req, res) => { + const fullPath = `/auth${req.url}`; // req.url here is relative to /auth mount + const qIdx = fullPath.indexOf('?'); + const path = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${path}${query}`; + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + // Host must point at STDB or some setups 404. + delete headers.host; + delete headers['content-length']; + headers['x-forwarded-proto'] = headers['x-forwarded-proto'] ?? req.protocol; + + // redirect:manual so upstream 302s (e.g. OAuth start) pass through to the browser. + const init: RequestInit = { method: req.method, headers, redirect: 'manual' }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = JSON.stringify(req.body); + headers['content-type'] = 'application/json'; + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, val); + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + res.send(buf); + } catch (err) { + res + .status(502) + .json({ error: 'upstream_unreachable', detail: (err as Error).message }); + } +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + stdbUri: STDB_URI, + appDatabase: STDB_APP_DB, + auth: { + issuerUrl: AUTH_ISSUER_URL, + baseUrl: AUTH_BASE_URL, + cookieName: AUTH_COOKIE_NAME, + sessionTtlSeconds: AUTH_SESSION_TTL_SECONDS, + hasEs256PrivateKeyPem: Boolean( + configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM) + ), + }, + oauth: { + google: GOOGLE_OAUTH_ENABLED, + github: GITHUB_OAUTH_ENABLED, + }, + }); +}); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, app: STDB_APP_DB }); +}); + +app.use(express.static(path.join(__dirname, 'public'))); + +try { + console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); + configureAuthFromEnv(); + console.log(`[auth] bootstrapped env config issuer=${AUTH_ISSUER_URL}`); +} catch (err) { + console.error( + `[auth] env config bootstrap failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[auth] is the SpacetimeDB host running and the auth example module published?' + ); + process.exit(1); +} + +app.listen(PORT, HOST, () => { + console.log(`Notes test app running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*)`); + console.log(` Database -> ${STDB_APP_DB}`); +}); diff --git a/spacetime-auth-ts/example/spacetimedb/package.json b/spacetime-auth-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..1587465fb39 --- /dev/null +++ b/spacetime-auth-ts/example/spacetimedb/package.json @@ -0,0 +1,20 @@ +{ + "name": "spacetime-auth-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-auth-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-auth-example" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-auth-ts/example/spacetimedb/src/index.ts b/spacetime-auth-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..2ce7964869e --- /dev/null +++ b/spacetime-auth-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,301 @@ +import { schema, t, table, Router, SenderError } from 'spacetimedb/server'; +import type { Timestamp } from 'spacetimedb'; +import * as auth from '@spacetimedb/auth/submodule'; +import { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + unlinkConnectionParams, + updateProfileParams, + revokeSessionParams, + listMySessionsParams, + revokeMySessionParams, + passwordSignupHandler, + passwordLoginHandler, + meHandler, + logoutHandler, + refreshHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + makeEmailVerifyRequestHandler, + makeEmailVerifyHandler, + getCallerUserId, + type SendMailFn, + type MailParams, +} from '@spacetimedb/auth/submodule'; + +// Development mailer that logs messages. +const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { + console.log( + `[mail] to=${params.to} subject=${params.subject}\n${params.text}` + ); +}; + +const authUserViewRow = t.object('ExampleAuthUser', { + userId: t.string(), + email: t.string(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +const note = table( + { name: 'note', public: false }, + { + noteId: t.string().primaryKey(), + authorId: t.string().index(), + title: t.string(), + body: t.string(), + createdAt: t.timestamp().index(), + } +); + +const spacetimedb = schema({ + auth, + note, +}); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + auth.installAuth(ctx.as.auth); +}); + +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.set_auth_config(ctx.as.auth, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.get_auth_public_key(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +export const link_connection = spacetimedb.reducer( + linkConnectionParams, + (ctx, args) => { + auth.link_connection(ctx.as.auth, args); + } +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlink_connection(ctx.as.auth, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.update_profile(ctx.as.auth, args); + } +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revoke_session(ctx.as.auth, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + (ctx, args) => + auth.list_my_sessions(ctx.as.auth, args) as { + sessions: Array<{ + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; + }>; + } +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revoke_my_session(ctx.as.auth, args); + } +); + +export const myNotes = spacetimedb.view( + { name: 'my_notes', public: true }, + t.array(note.rowType), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + return [...ctx.db.note.authorId.filter(binding.userId)]; + } +); + +export const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUserViewRow), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + const row = ctx.db.auth.authUser.userId.find(binding.userId); + return row ? [row] : []; + } +); + +export const create_note = spacetimedb.reducer( + { title: t.string(), body: t.string() }, + (ctx, args) => { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throw new SenderError('auth.not_authenticated'); + const noteId = ctx.newUuidV7().toString(); + ctx.db.note.insert({ + noteId, + authorId: userId, + title: args.title, + body: args.body, + createdAt: ctx.timestamp, + }); + } +); + +export const delete_note = spacetimedb.reducer( + { noteId: t.string() }, + (ctx, args) => { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throw new SenderError('auth.not_authenticated'); + const row = ctx.db.note.noteId.find(args.noteId); + if (!row) throw new SenderError('note.not_found'); + if (row.authorId !== userId) throw new SenderError('note.not_owner'); + ctx.db.note.delete(row); + } +); + +export const update_note = spacetimedb.reducer( + { noteId: t.string(), title: t.string(), body: t.string() }, + (ctx, args) => { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throw new SenderError('auth.not_authenticated'); + const row = ctx.db.note.noteId.find(args.noteId); + if (!row) throw new SenderError('note.not_found'); + if (row.authorId !== userId) throw new SenderError('note.not_owner'); + ctx.db.note.noteId.update({ ...row, title: args.title, body: args.body }); + } +); + +export const whoami = spacetimedb.procedure( + {}, + t.object('WhoAmI', { + userId: t.option(t.string()), + senderIdentityHex: t.string(), + }), + (ctx, _args) => { + const userId = getCallerUserId(ctx.as.auth); + return { + userId: userId ?? undefined, + senderIdentityHex: ctx.sender.toHexString(), + }; + } +); + +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + passwordSignupHandler(ctx.as.auth, req) +); +export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => + passwordLoginHandler(ctx.as.auth, req) +); +export const authMe = spacetimedb.httpHandler((ctx, req) => + meHandler(ctx.as.auth, req) +); +export const authLogout = spacetimedb.httpHandler((ctx, req) => + logoutHandler(ctx.as.auth, req) +); +export const authRefresh = spacetimedb.httpHandler((ctx, req) => + refreshHandler(ctx.as.auth, req) +); +export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => + googleStartHandler(ctx.as.auth, req) +); +export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => + googleCallbackHandler(ctx.as.auth, req) +); +export const authGithubStart = spacetimedb.httpHandler((ctx, req) => + githubStartHandler(ctx.as.auth, req) +); +export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => + githubCallbackHandler(ctx.as.auth, req) +); + +const forgotHandler = makeForgotPasswordHandler({ + sendMail: consoleSendMail, + appName: 'Notes', +}); +const verifyRequestHandler = makeEmailVerifyRequestHandler({ + sendMail: consoleSendMail, + appName: 'Notes', +}); +const verifyHandler = makeEmailVerifyHandler({ + successRedirect: '/?verified=1', +}); + +export const authPasswordForgot = spacetimedb.httpHandler((ctx, req) => + forgotHandler(ctx.as.auth, req) +); +export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => + resetPasswordHandler(ctx.as.auth, req) +); +export const authEmailVerifyRequest = spacetimedb.httpHandler((ctx, req) => + verifyRequestHandler(ctx.as.auth, req) +); +export const authEmailVerify = spacetimedb.httpHandler((ctx, req) => + verifyHandler(ctx.as.auth, req) +); + +export const router = spacetimedb.httpRouter( + new Router() + .post('/auth/password/signup', authPasswordSignup) + .post('/auth/password/login', authPasswordLogin) + .post('/auth/session/refresh', authRefresh) + .get('/auth/me', authMe) + .post('/auth/logout', authLogout) + .get('/auth/google/start', authGoogleStart) + .get('/auth/google/callback', authGoogleCallback) + .get('/auth/github/start', authGithubStart) + .get('/auth/github/callback', authGithubCallback) + .post('/auth/password/forgot', authPasswordForgot) + .post('/auth/password/reset', authPasswordReset) + .post('/auth/email/verify-request', authEmailVerifyRequest) + .get('/auth/email/verify', authEmailVerify) +); diff --git a/spacetime-auth-ts/example/spacetimedb/tsconfig.json b/spacetime-auth-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..c18065b7cb8 --- /dev/null +++ b/spacetime-auth-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-auth-ts/example/src/app.ts b/spacetime-auth-ts/example/src/app.ts new file mode 100644 index 00000000000..e2cb5a39014 --- /dev/null +++ b/spacetime-auth-ts/example/src/app.ts @@ -0,0 +1,395 @@ +// STDB connection + auth flow. Exposes window.auth for the inline UI. + +import { + DbConnection, + type EventContext, + type ErrorContext, +} from './codegen/app'; + +interface AuthUserRow { + userId: string; + email: string; + emailVerified: boolean; + name?: string; + image?: string; + createdAt: unknown; + updatedAt: unknown; +} + +declare global { + interface Window { + auth?: { + signup: (args: { + email: string; + password: string; + name?: string; + }) => Promise; + login: (args: { email: string; password: string }) => Promise; + logout: () => Promise; + createNote: (args: { title: string; body: string }) => void; + updateNote: (args: { + noteId: string; + title: string; + body: string; + }) => void; + deleteNote: (noteId: string) => void; + whoami: () => Promise<{ + userId: string | undefined; + senderIdentityHex: string; + }>; + oauthStart: (provider: 'google' | 'github') => void; + listMySessions: () => Promise<{ sessions: unknown[] }>; + revokeMySession: (sessionId: string) => void; + forgotPassword: (email: string) => Promise; + resetPassword: (token: string, newPassword: string) => Promise; + requestEmailVerify: () => Promise; + setProfile: (args: { name?: string; image?: string }) => void; + }; + } +} + +interface AuthMe { + user: { + userId: string; + email: string; + emailVerified: boolean; + name?: string; + image?: string; + }; + sessionExpiresAt: number; +} + +interface ServerConfig { + stdbUri: string; + appDatabase: string; + oauth?: { + google?: boolean; + github?: boolean; + }; +} + +let conn: DbConnection | null = null; +let serverCfg: ServerConfig | null = null; +let currentUser: AuthMe['user'] | null = null; +let currentExp: number | undefined; +let currentSenderHex: string | undefined; + +function dispatch(name: string, detail: unknown) { + window.dispatchEvent(new CustomEvent(name, { detail })); +} +function broadcastAuth() { + dispatch('auth:state', { + user: currentUser, + senderHex: currentSenderHex, + sessionExpiresAt: currentExp, + }); +} +let lastConnState: string = ''; +let lastConnDetail: string = ''; +function broadcastConn( + state: 'idle' | 'connecting' | 'connected' | 'error', + detail?: string +) { + const d = detail ?? ''; + if (state === lastConnState && d === lastConnDetail) return; + lastConnState = state; + lastConnDetail = d; + dispatch('auth:conn', { state, detail }); +} +function broadcastNotes() { + const sorted = conn + ? [...conn.db.myNotes.iter()].sort((a, b) => + Number( + b.createdAt.microsSinceUnixEpoch - a.createdAt.microsSinceUnixEpoch + ) + ) + : []; + dispatch('auth:notes', { notes: sorted }); +} + +async function callJson(path: string, body?: unknown): Promise { + const r = await fetch(path, { + method: body !== undefined ? 'POST' : 'GET', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'same-origin', + }); + let data: unknown = null; + try { + data = await r.json(); + } catch { + /* empty or non-JSON response */ + } + if (!r.ok) { + const error = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${r.status}`; + throw new Error(error); + } + return data as T; +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config', { credentials: 'same-origin' }); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + const cfg = (await r.json()) as ServerConfig; + dispatch('auth:server-config', cfg); + return cfg; +} + +// Persist the STDB identity token so refresh reuses the same identity. +const STDB_TOKEN_KEY = 'notes:stdb_token'; +function loadStdbToken(): string | undefined { + try { + return localStorage.getItem(STDB_TOKEN_KEY) ?? undefined; + } catch { + return undefined; + } +} +function saveStdbToken(token: string): void { + try { + localStorage.setItem(STDB_TOKEN_KEY, token); + } catch { + /* Storage can be unavailable. */ + } +} + +function connectStdb(): Promise { + if (!serverCfg) throw new Error('missing_server_config'); + const config = serverCfg; + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(config.stdbUri) + .withDatabaseName(config.appDatabase) + .withToken(loadStdbToken()) + .onConnect((c, _identity, token) => { + if (token) saveStdbToken(token); + resolve(c); + }) + .onDisconnect((_ctx, err) => { + broadcastConn('error', err?.message ?? 'disconnected'); + conn = null; + if (currentUser) scheduleReconnect(); + }) + .onConnectError((_ctx, err) => { + broadcastConn('error', 'connect failed'); + reject(err); + }) + .build(); + }); +} + +let reconnectAttempts = 0; +let reconnectTimer: number | null = null; +function scheduleReconnect() { + if (reconnectTimer != null) return; + const delay = Math.min(30000, 500 * Math.pow(2, reconnectAttempts)); + reconnectAttempts++; + reconnectTimer = window.setTimeout(async () => { + reconnectTimer = null; + if (!currentUser) return; + try { + const r = await callJson<{ + user: AuthMe['user']; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + reconnectAttempts = 0; + } catch { + scheduleReconnect(); + } + }, delay); +} + +async function bindSession(token: string, user: AuthMe['user'], exp: number) { + currentUser = user; + currentExp = exp; + + if (!conn) { + broadcastConn('connecting'); + try { + conn = await connectStdb(); + wireSubscriptions(conn); + broadcastConn('connected'); + } catch (err) { + broadcastConn('error', (err as Error).message); + return; + } + } + + try { + conn.reducers.linkConnection({ sessionToken: token }); + const w = await conn.procedures.whoami({}); + currentSenderHex = w.senderIdentityHex; + } catch (err) { + console.warn('link_connection failed', err); + } + broadcastAuth(); +} + +function syncUserFromRow(row: AuthUserRow) { + if (!currentUser || row.userId !== currentUser.userId) return; + currentUser = { + userId: row.userId, + email: row.email, + emailVerified: row.emailVerified, + name: row.name ?? undefined, + image: row.image ?? undefined, + }; + broadcastAuth(); +} + +function wireSubscriptions(c: DbConnection) { + c.subscriptionBuilder() + .onApplied(() => broadcastNotes()) + .onError((ctx: ErrorContext) => console.error('sub error', ctx.event)) + .subscribe(['SELECT * FROM my_notes', 'SELECT * FROM my_auth_user']); + + c.db.myNotes.onInsert(() => broadcastNotes()); + c.db.myNotes.onUpdate(() => broadcastNotes()); + c.db.myNotes.onDelete(() => broadcastNotes()); + + c.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) => + syncUserFromRow(row) + ); + c.db.myAuthUser.onUpdate( + (_ctx: EventContext, _o: AuthUserRow, n: AuthUserRow) => syncUserFromRow(n) + ); + c.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => { + if (!currentUser || row.userId !== currentUser.userId) return; + currentUser = null; + currentExp = undefined; + broadcastAuth(); + }); +} + +async function signup(args: { + email: string; + password: string; + name?: string; +}) { + const r = await callJson<{ token: string }>('/auth/password/signup', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} + +async function login(args: { email: string; password: string }) { + const r = await callJson<{ token: string }>('/auth/password/login', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} + +async function restoreSession(): Promise { + try { + const r = await callJson<{ + user: AuthMe['user']; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + return true; + } catch { + return false; + } +} + +async function logout() { + if (conn) { + try { + conn.reducers.unlinkConnection({}); + } catch { + /* best-effort disconnect cleanup */ + } + } + await callJson('/auth/logout', {}); + currentUser = null; + currentExp = undefined; + currentSenderHex = undefined; + broadcastAuth(); + broadcastNotes(); +} + +function createNote(args: { title: string; body: string }) { + if (!conn) throw new Error('not_connected'); + conn.reducers.createNote(args); +} + +function deleteNote(noteId: string) { + if (!conn) throw new Error('not_connected'); + conn.reducers.deleteNote({ noteId }); +} + +function updateNote(args: { noteId: string; title: string; body: string }) { + if (!conn) throw new Error('not_connected'); + conn.reducers.updateNote(args); +} + +async function whoami() { + if (!conn) throw new Error('not_connected'); + const r = await conn.procedures.whoami({}); + currentSenderHex = r.senderIdentityHex; + broadcastAuth(); + return r; +} + +async function listMySessions() { + if (!conn) throw new Error('not_connected'); + return await conn.procedures.listMySessions({}); +} + +function revokeMySession(sessionId: string) { + if (!conn) throw new Error('not_connected'); + conn.reducers.revokeMySession({ sessionId }); +} + +async function forgotPassword(email: string) { + await callJson('/auth/password/forgot', { email }); +} + +async function resetPassword(token: string, newPassword: string) { + await callJson('/auth/password/reset', { token, newPassword }); +} + +async function requestEmailVerify() { + await callJson('/auth/email/verify-request', {}); +} + +function oauthStart(provider: 'google' | 'github') { + window.location.href = `/auth/${provider}/start?redirectTo=/`; +} + +function setProfile(args: { name?: string; image?: string }) { + if (!conn) throw new Error('not_connected'); + conn.reducers.updateProfile({ name: args.name, image: args.image }); +} + +window.auth = { + signup, + login, + logout, + createNote, + updateNote, + deleteNote, + whoami, + oauthStart, + listMySessions, + revokeMySession, + forgotPassword, + resetPassword, + requestEmailVerify, + setProfile, +}; + +(async () => { + broadcastConn('idle'); + try { + serverCfg = await loadServerConfig(); + } catch (err) { + broadcastConn('error', (err as Error).message); + return; + } + await restoreSession(); + dispatch('auth:ready', {}); +})(); diff --git a/spacetime-auth-ts/example/tsconfig.json b/spacetime-auth-ts/example/tsconfig.json new file mode 100644 index 00000000000..9b159ac1913 --- /dev/null +++ b/spacetime-auth-ts/example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-auth-ts/package.json b/spacetime-auth-ts/package.json new file mode 100644 index 00000000000..cf03fdb56c9 --- /dev/null +++ b/spacetime-auth-ts/package.json @@ -0,0 +1,85 @@ +{ + "name": "@spacetimedb/auth", + "description": "Password, OAuth, session, JWT, and profile primitives for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./tables": { + "types": "./src/tables.ts", + "default": "./src/tables.ts" + }, + "./handlers": { + "types": "./src/handlers/index.ts", + "default": "./src/handlers/index.ts" + }, + "./crypto": { + "types": "./src/crypto.ts", + "default": "./src/crypto.ts" + }, + "./jwt": { + "types": "./src/jwt.ts", + "default": "./src/jwt.ts" + }, + "./keys": { + "types": "./src/keys.ts", + "default": "./src/keys.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-auth-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-auth-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "authentication", + "oauth", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts" + }, + "dependencies": { + "@spacetimedb/rate-limit": "workspace:^", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^1.4.0" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-auth-ts/scripts/test.ts b/spacetime-auth-ts/scripts/test.ts new file mode 100644 index 00000000000..1c5705d840d --- /dev/null +++ b/spacetime-auth-ts/scripts/test.ts @@ -0,0 +1,345 @@ +// Pure-Node sanity tests. No STDB needed. Run: pnpm run test +// Covers: keys, jwt, crypto. + +import { p256 } from '@noble/curves/nist.js'; +import type { Request } from 'spacetimedb/server'; + +import { + generateEs256Keypair, + fromPrivateKeyBytes, + privateKeyFromPem, + publicKeyFromPem, +} from '../src/keys.ts'; +import { signJwt, verifyJwt, decodeJwtPayloadUnsafe } from '../src/jwt.ts'; +import { + hashPassword, + verifyPassword, + randomToken, + randomBytes, + uuidV7, + pkceChallenge, + newPkceVerifier, +} from '../src/crypto.ts'; +import { + clientKey, + safeRedirectPath, + shouldUseSecureCookies, + userAgent, +} from '../src/request-trust.ts'; + +let pass = 0; +let fail = 0; + +function ok(name: string): void { + pass++; + process.stdout.write(` ok ${name}\n`); +} + +function err(name: string, detail: string): void { + fail++; + process.stdout.write(` FAIL ${name}\n ${detail}\n`); +} + +function assert(cond: boolean, name: string, detail = ''): void { + if (cond) ok(name); + else err(name, detail); +} + +function assertEq(actual: unknown, expected: unknown, name: string): void { + if (actual === expected) ok(name); + else err(name, `expected ${String(expected)}, got ${String(actual)}`); +} + +function bytesEq(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +// Test-only RandomSource compatible with STDB Random. + +const TEST_RNG: { fill(a: T): T } = { + fill(a: T): T { + for (let i = 0; i < a.length; i++) a[i] = Math.floor(Math.random() * 256); + return a; + }, +}; + +process.stdout.write('\nhttp trust\n'); + +{ + const headers = new Map([ + ['x-forwarded-for', '203.0.113.10, 10.0.0.2'], + ['x-real-ip', '198.51.100.4'], + ]); + const req = { + headers: { + get(name: string) { + return headers.get(name.toLowerCase()) ?? null; + }, + }, + } as unknown as Request; + assertEq( + clientKey(req), + undefined, + 'proxy headers ignored unless configured' + ); + assertEq( + clientKey(req, 'x-forwarded-for'), + '203.0.113.10', + 'trusted forwarded header uses first address' + ); + assertEq( + clientKey(req, 'x-real-ip'), + '198.51.100.4', + 'trusted real IP is accepted' + ); + assertEq(userAgent(req), undefined, 'missing user agent is omitted'); + const longUserAgentReq = { + headers: { + get: (name: string) => (name === 'user-agent' ? 'x'.repeat(513) : null), + }, + } as unknown as Request; + assertEq( + userAgent(longUserAgentReq), + undefined, + 'oversized user agent is omitted' + ); + assertEq(shouldUseSecureCookies(), true, 'cookies are secure by default'); + assertEq( + shouldUseSecureCookies(false), + false, + 'local HTTP can opt out explicitly' + ); + assertEq( + safeRedirectPath('/dashboard?tab=billing'), + '/dashboard?tab=billing', + 'relative redirect accepted' + ); + for (const redirect of [ + 'https://attacker.example', + '//attacker.example', + '/%2f%2fattacker.example', + '/\\attacker.example', + '/%5cattacker.example', + '/ok%0d%0alocation:%20https://attacker.example', + '/ok\u007fblocked', + '/path#fragment', + ]) { + assertEq( + safeRedirectPath(redirect), + undefined, + `unsafe redirect rejected: ${redirect}` + ); + } +} + +// keys + +process.stdout.write('\nkeys\n'); + +{ + const kp = generateEs256Keypair(); + assertEq(kp.privateKey.length, 32, 'private key is 32 bytes'); + assertEq(kp.publicKey.length, 65, 'public key is 65 bytes uncompressed'); + assertEq(kp.publicKey[0], 0x04, 'public key starts with 0x04'); + assert( + kp.publicKeyPem.includes('BEGIN PUBLIC KEY'), + 'public PEM has BEGIN PUBLIC KEY' + ); + assert( + kp.privateKeyPem.includes('BEGIN PRIVATE KEY'), + 'private PEM has BEGIN PRIVATE KEY' + ); + assert(kp.kid.length > 0, 'kid (JWK thumbprint) is non-empty'); + assertEq(kp.publicKeyJwk.kty, 'EC', 'JWK kty is EC'); + assertEq(kp.publicKeyJwk.crv, 'P-256', 'JWK crv is P-256'); + assertEq(kp.publicKeyJwk.alg, 'ES256', 'JWK alg is ES256'); + + // Re-derive public from stored private. + const kp2 = fromPrivateKeyBytes(kp.privateKey); + assert( + bytesEq(kp.publicKey, kp2.publicKey), + 'public key re-derives from private' + ); + assertEq(kp.kid, kp2.kid, 'kid is stable across re-derivation'); + + // PEM round-trip private. + const decodedPriv = privateKeyFromPem(kp.privateKeyPem); + assert( + bytesEq(kp.privateKey, decodedPriv), + 'private key round-trips through PEM' + ); + + // PEM round-trip public. + const decodedPub = publicKeyFromPem(kp.publicKeyPem); + assert( + bytesEq(kp.publicKey, decodedPub), + 'public key round-trips through PEM' + ); +} + +// jwt + +process.stdout.write('\njwt\n'); + +{ + const kp = generateEs256Keypair(); + const now = Math.floor(Date.now() / 1000); + + const token = signJwt( + kp.privateKey, + { + iss: 'https://auth.example.com', + sub: 'user-1234', + aud: 'https://auth.example.com', + iat: now, + exp: now + 3600, + jti: 'session-1', + }, + kp.kid + ); + assertEq(token.split('.').length, 3, 'JWT has three parts'); + + // Header decodes correctly. + const header = JSON.parse( + new TextDecoder().decode(base64urlDecode(token.split('.')[0])) + ); + assertEq(header.alg, 'ES256', 'header alg is ES256'); + assertEq(header.typ, 'JWT', 'header typ is JWT'); + assertEq(header.kid, kp.kid, 'header kid matches'); + + // Roundtrip verify. + const v = verifyJwt(kp.publicKey, token, { + issuer: 'https://auth.example.com', + audience: 'https://auth.example.com', + }); + assert(v.ok, 'sign+verify roundtrip with same key'); + if (v.ok) { + assertEq(v.claims.sub, 'user-1234', 'verified claims.sub'); + assertEq(v.claims.iss, 'https://auth.example.com', 'verified claims.iss'); + } + + // Wrong key fails. + const otherKp = generateEs256Keypair(); + const v2 = verifyJwt(otherKp.publicKey, token); + assert( + !v2.ok && v2.reason === 'bad-signature', + 'wrong key fails with bad-signature' + ); + + // Expired token fails. + const expired = signJwt(kp.privateKey, { + iss: 'x', + sub: 'y', + iat: now - 7200, + exp: now - 3600, + }); + const v3 = verifyJwt(kp.publicKey, expired); + assert(!v3.ok && v3.reason === 'expired', 'expired token fails with expired'); + + // Wrong issuer fails. + const v4 = verifyJwt(kp.publicKey, token, { + issuer: 'https://wrong.example.com', + }); + assert( + !v4.ok && v4.reason === 'bad-issuer', + 'wrong issuer fails with bad-issuer' + ); + + // Wrong audience fails. + const v5 = verifyJwt(kp.publicKey, token, { + audience: 'https://wrong.example.com', + }); + assert( + !v5.ok && v5.reason === 'bad-audience', + 'wrong audience fails with bad-audience' + ); + + // Malformed token fails. + const v6 = verifyJwt(kp.publicKey, 'not.a.jwt'); + assert( + !v6.ok && v6.reason === 'malformed', + 'malformed token fails with malformed' + ); + + // Decode-unsafe extracts payload. + const payload = decodeJwtPayloadUnsafe(token); + assertEq(payload?.sub, 'user-1234', 'decodeJwtPayloadUnsafe returns sub'); + + // Signature is 64 bytes (compact r||s). + const sigBytes = base64urlDecode(token.split('.')[2]); + assertEq(sigBytes.length, 64, 'ES256 signature is 64 bytes (r||s)'); + + // Verify with noble directly to cross-check. + const signingInput = `${token.split('.')[0]}.${token.split('.')[1]}`; + const directOk = p256.verify( + sigBytes, + new TextEncoder().encode(signingInput), + kp.publicKey + ); + assert(directOk, 'noble verifies the produced signature directly'); +} + +// crypto + +process.stdout.write('\ncrypto\n'); + +{ + // Password hash + verify roundtrip. scrypt is SLOW, this takes ~1s. + const password = 'correct horse battery staple'; + // Low N so tests don't take forever. + const hash = hashPassword(TEST_RNG, password, { N: 1 << 10 }); + assert(hash.startsWith('scrypt$1024$'), 'hashPassword encodes scrypt params'); + assert( + verifyPassword(password, hash), + 'verifyPassword accepts correct password' + ); + assert( + !verifyPassword('wrong', hash), + 'verifyPassword rejects wrong password' + ); + + // Random token shape. + const token = randomToken(TEST_RNG, 32); + assert(/^[A-Za-z0-9_-]+$/.test(token), 'randomToken is base64url-safe'); + assert(token.length >= 40, 'randomToken has enough entropy bits'); + + // Random bytes length. + const bytes = randomBytes(TEST_RNG, 16); + assertEq(bytes.length, 16, 'randomBytes returns requested length'); + + // UUIDv7 shape. + const id = uuidV7(TEST_RNG, Date.now()); + assert( + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + id + ), + 'uuidV7 matches v7 regex' + ); + + // PKCE challenge. + const verifier = newPkceVerifier(TEST_RNG); + const challenge = pkceChallenge(verifier); + assert(/^[A-Za-z0-9_-]+$/.test(challenge), 'pkceChallenge is base64url-safe'); + assertEq( + challenge.length, + 43, + 'pkceChallenge is 43 chars (SHA-256 base64url, no pad)' + ); +} + +// summary + +process.stdout.write(`\n${pass} passed, ${fail} failed\n`); +process.exit(fail === 0 ? 0 : 1); + +// helpers + +function base64urlDecode(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)); + const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/spacetime-auth-ts/spacetimedb/package.json b/spacetime-auth-ts/spacetimedb/package.json new file mode 100644 index 00000000000..d616f0f1996 --- /dev/null +++ b/spacetime-auth-ts/spacetimedb/package.json @@ -0,0 +1,20 @@ +{ + "name": "spacetime-auth-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-auth", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-auth" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-auth-ts/spacetimedb/src/index.ts b/spacetime-auth-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..ddd02a77d65 --- /dev/null +++ b/spacetime-auth-ts/spacetimedb/src/index.ts @@ -0,0 +1,2 @@ +export { default } from '../../src/mounted/index'; +export * from '../../src/mounted/index'; diff --git a/spacetime-auth-ts/spacetimedb/tsconfig.json b/spacetime-auth-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..c18065b7cb8 --- /dev/null +++ b/spacetime-auth-ts/spacetimedb/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-auth-ts/src/admin.ts b/spacetime-auth-ts/src/admin.ts new file mode 100644 index 00000000000..cbed5bad178 --- /dev/null +++ b/spacetime-auth-ts/src/admin.ts @@ -0,0 +1,34 @@ +import type { Identity, Timestamp } from 'spacetimedb'; +import { SenderError } from 'spacetimedb/server'; +import type { AuthTransactionCtx } from './context.ts'; + +export type AdminVerdict = 'admin' | 'denied'; + +// Non-throwing read so callers can compute the verdict inside a tx and throw +// outside it. A SenderError thrown inside ctx.withTx surfaces as a fatal +// instance error, not a recoverable rejection. +export function authAdminVerdict( + tx: AuthTransactionCtx, + sender: Identity +): AdminVerdict { + return tx.db.authAdminIdentity.identity.find(sender) != null + ? 'admin' + : 'denied'; +} + +export function denyIfNotAdmin(verdict: AdminVerdict): void { + if (verdict === 'denied') throw new SenderError('auth.not_authorized'); +} + +// For owner-gated setup code only. Do not call from a public bootstrap path. +export function seedAuthAdmin( + tx: AuthTransactionCtx, + sender: Identity, + timestamp: Timestamp +): void { + if (tx.db.authAdminIdentity.identity.find(sender) != null) return; + tx.db.authAdminIdentity.insert({ + identity: sender, + addedAtMicros: timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-auth-ts/src/caller.ts b/spacetime-auth-ts/src/caller.ts new file mode 100644 index 00000000000..b4adc6741c5 --- /dev/null +++ b/spacetime-auth-ts/src/caller.ts @@ -0,0 +1,48 @@ +// Caller identity helpers. Browser must call link_connection after STDB connect. + +import { SenderError } from 'spacetimedb/server'; +import type { + AuthProcedureCtx, + AuthReducerCtx, + AuthViewCtx, +} from './context.ts'; +import type { AuthUser } from './types.ts'; + +type CallerContext = AuthReducerCtx | AuthProcedureCtx | AuthViewCtx; + +function hasDirectDb(ctx: CallerContext): ctx is AuthReducerCtx | AuthViewCtx { + return 'db' in ctx; +} + +/** Returns the userId bound to ctx.sender, or null if not linked. */ +export function getCallerUserId(ctx: CallerContext): string | null { + if (hasDirectDb(ctx)) { + const binding = ctx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + return binding?.userId ?? null; + } + return ctx.withTx(tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + return binding?.userId ?? null; + }); +} + +/** Look up the caller's auth_user row, or null. */ +export function findCallerUser(ctx: CallerContext): AuthUser | null { + if (hasDirectDb(ctx)) { + const binding = ctx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) return null; + return ctx.db.authUser.userId.find(binding.userId) ?? null; + } + return ctx.withTx(tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) return null; + return tx.db.authUser.userId.find(binding.userId) ?? null; + }); +} + +/** Returns userId. Throws SenderError('auth.not_authenticated') if no binding. */ +export function requireCallerUserId(ctx: CallerContext): string { + const userId = getCallerUserId(ctx); + if (!userId) throw new SenderError('auth.not_authenticated'); + return userId; +} diff --git a/spacetime-auth-ts/src/context.ts b/spacetime-auth-ts/src/context.ts new file mode 100644 index 00000000000..4abb09b4978 --- /dev/null +++ b/spacetime-auth-ts/src/context.ts @@ -0,0 +1,37 @@ +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { + schema, + table, + t, + type HandlerContext, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { authTables } from './tables.ts'; + +const authSweeperTick = table( + { name: 'auth_sweeper_tick' }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +// This schema exists only to derive the context types shared by the package's +// reducer, procedure, view, and HTTP implementations. Runtime modules mount the +// same auth tables and the rate-limit submodule under their own schema. +const _authContextSchema = schema({ + ...authTables, + authSweeperTick, + rateLimit, +}); + +export type AuthSchema = InferSchema; +export type AuthReducerCtx = ReducerCtx; +export type AuthProcedureCtx = ProcedureCtx; +export type AuthTransactionCtx = TransactionCtx; +export type AuthViewCtx = ViewCtx; +export type AuthHandlerCtx = HandlerContext; diff --git a/spacetime-auth-ts/src/crypto.ts b/spacetime-auth-ts/src/crypto.ts new file mode 100644 index 00000000000..ca06833ef96 --- /dev/null +++ b/spacetime-auth-ts/src/crypto.ts @@ -0,0 +1,169 @@ +import { scrypt } from '@noble/hashes/scrypt'; +import { sha256 } from '@noble/hashes/sha2'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder('utf-8'); + +// N=2^14 keeps single-hash under ~300ms in STDB's V8 isolate. +export interface ScryptParams { + N: number; + r: number; + p: number; + dkLen: number; +} +const DEFAULT_SCRYPT: ScryptParams = { N: 1 << 14, r: 8, p: 1, dkLen: 32 }; + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const B64_REV = (() => { + const m = new Int8Array(256).fill(-1); + for (let i = 0; i < B64.length; i++) m[B64.charCodeAt(i)] = i; + return m; +})(); + +function b64encode(bytes: Uint8Array): string { + let out = ''; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + out += B64[bytes[i] >> 2]; + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + out += B64[bytes[i + 2] & 63]; + } + if (i < bytes.length) { + out += B64[bytes[i] >> 2]; + if (i + 1 === bytes.length) { + out += B64[(bytes[i] & 3) << 4]; + out += '=='; + } else { + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[(bytes[i + 1] & 15) << 2]; + out += '='; + } + } + return out; +} + +function b64decode(str: string): Uint8Array { + let s = ''; + for (let i = 0; i < str.length; i++) { + if (B64_REV[str.charCodeAt(i)] >= 0) s += str[i]; + } + const out = new Uint8Array((s.length * 3) >> 2); + let oi = 0; + for (let i = 0; i + 3 < s.length; i += 4) { + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + const c = B64_REV[s.charCodeAt(i + 2)]; + const d = B64_REV[s.charCodeAt(i + 3)]; + out[oi++] = (a << 2) | (b >> 4); + out[oi++] = ((b & 15) << 4) | (c >> 2); + out[oi++] = ((c & 3) << 6) | d; + } + const tail = s.length & 3; + if (tail >= 2) { + const i = s.length - tail; + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + out[oi++] = (a << 2) | (b >> 4); + if (tail === 3) { + const c = B64_REV[s.charCodeAt(i + 2)]; + out[oi++] = ((b & 15) << 4) | (c >> 2); + } + } + return out.subarray(0, oi); +} + +/** Subset of STDB Random. */ +export interface RandomSource { + fill(array: T): T; +} + +export function randomBytes(source: RandomSource, n: number): Uint8Array { + return source.fill(new Uint8Array(n)); +} + +export function randomToken(source: RandomSource, byteLen = 32): string { + return b64encode(randomBytes(source, byteLen)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** Prefer ctx.newUuidV7() if available. */ +export function uuidV7(source: RandomSource, nowMs: number): string { + const rand = randomBytes(source, 10); + const ts = BigInt(nowMs); + const bytes = new Uint8Array(16); + bytes[0] = Number((ts >> 40n) & 0xffn); + bytes[1] = Number((ts >> 32n) & 0xffn); + bytes[2] = Number((ts >> 24n) & 0xffn); + bytes[3] = Number((ts >> 16n) & 0xffn); + bytes[4] = Number((ts >> 8n) & 0xffn); + bytes[5] = Number(ts & 0xffn); + for (let i = 0; i < 10; i++) bytes[6 + i] = rand[i]; + bytes[6] = (bytes[6] & 0x0f) | 0x70; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** Encoded as `scrypt$N$r$p$saltB64$hashB64`. */ +export function hashPassword( + source: RandomSource, + password: string, + params: Partial = {} +): string { + const p = { ...DEFAULT_SCRYPT, ...params }; + const salt = randomBytes(source, 16); + const hash = scrypt(textEncoder.encode(password), salt, { + N: p.N, + r: p.r, + p: p.p, + dkLen: p.dkLen, + }); + return `scrypt$${p.N}$${p.r}$${p.p}$${b64encode(salt)}$${b64encode(hash)}`; +} + +export function verifyPassword(password: string, encoded: string): boolean { + const parts = encoded.split('$'); + if (parts.length !== 6 || parts[0] !== 'scrypt') return false; + const N = parseInt(parts[1], 10); + const r = parseInt(parts[2], 10); + const p = parseInt(parts[3], 10); + if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) + return false; + const salt = b64decode(parts[4]); + const expected = b64decode(parts[5]); + const actual = scrypt(textEncoder.encode(password), salt, { + N, + r, + p, + dkLen: expected.length, + }); + return constantTimeEqual(expected, actual); +} + +export function newSessionToken(source: RandomSource): string { + return randomToken(source, 32); +} + +export function newPkceVerifier(source: RandomSource): string { + return randomToken(source, 32); +} + +export function pkceChallenge(verifier: string): string { + const hash = sha256(textEncoder.encode(verifier)); + return b64encode(hash) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +export { textEncoder as utf8Encoder, textDecoder as utf8Decoder }; diff --git a/spacetime-auth-ts/src/handlers/_helpers.ts b/spacetime-auth-ts/src/handlers/_helpers.ts new file mode 100644 index 00000000000..7378c595a8d --- /dev/null +++ b/spacetime-auth-ts/src/handlers/_helpers.ts @@ -0,0 +1,165 @@ +import { SyncResponse, type Request } from 'spacetimedb/server'; +import { verifyJwt, type JwtClaims } from '../jwt.ts'; +import { privateKeyFromPem, publicKeyFromPem } from '../keys.ts'; +import type { AuthConfig } from '../types.ts'; +import type { AuthHandlerCtx, AuthTransactionCtx } from '../context.ts'; + +export type { AuthHandlerCtx, AuthTransactionCtx }; + +export interface CookieOpts { + maxAgeSeconds?: number; + path?: string; + domain?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: 'Strict' | 'Lax' | 'None'; +} + +export function makeCookie( + name: string, + value: string, + opts: CookieOpts = {} +): string { + const parts = [`${name}=${value}`]; + parts.push(`Path=${opts.path ?? '/'}`); + if (opts.maxAgeSeconds != null) parts.push(`Max-Age=${opts.maxAgeSeconds}`); + if (opts.domain) parts.push(`Domain=${opts.domain}`); + if (opts.httpOnly !== false) parts.push('HttpOnly'); + if (opts.secure !== false) parts.push('Secure'); + parts.push(`SameSite=${opts.sameSite ?? 'Lax'}`); + return parts.join('; '); +} + +export function clearCookie(name: string, opts: CookieOpts = {}): string { + return makeCookie(name, '', { ...opts, maxAgeSeconds: 0 }); +} + +export { shouldUseSecureCookies, userAgent } from '../request-trust.ts'; + +export function parseCookies( + header: string | null | undefined +): Record { + const out: Record = {}; + if (!header) return out; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + const k = part.slice(0, eq).trim(); + const v = part.slice(eq + 1).trim(); + if (k) out[k] = v; + } + return out; +} + +export function jsonResponse( + body: unknown, + status = 200, + extraHeaders: Record = {} +): SyncResponse { + return new SyncResponse(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...extraHeaders }, + }); +} + +export function errorResponse( + code: string, + status: number, + extraHeaders: Record = {} +): SyncResponse { + return jsonResponse({ error: code }, status, extraHeaders); +} + +export function redirectResponse( + location: string, + extraHeaders: Record = {} +): SyncResponse { + return new SyncResponse('', { + status: 302, + headers: { location, ...extraHeaders }, + }); +} + +export function requireConfig(tx: AuthTransactionCtx): AuthConfig { + const cfg = tx.db.authConfig.singleton.find(true); + if (!cfg) throw new ConfigMissingError(); + return cfg; +} + +export class ConfigMissingError extends Error { + constructor() { + super('auth_config singleton missing; call setAuthConfig first'); + } +} + +export function microsToSeconds(t: { microsSinceUnixEpoch: bigint }): number { + return Number(t.microsSinceUnixEpoch / 1_000_000n); +} + +export function secondsToTimestamp(seconds: number | bigint): { + microsSinceUnixEpoch: bigint; +} { + return { microsSinceUnixEpoch: BigInt(seconds) * 1_000_000n }; +} + +export function readBearer(req: Request, cookieName: string): string | null { + const auth = req.headers.get('authorization'); + if (auth && auth.toLowerCase().startsWith('bearer ')) { + return auth.slice(7).trim(); + } + const cookies = parseCookies(req.headers.get('cookie')); + return cookies[cookieName] ?? null; +} + +export function readSession( + req: Request, + cookieName: string, + publicKey: Uint8Array, + issuer?: string +): JwtClaims | null { + const token = readBearer(req, cookieName); + if (!token) return null; + const r = verifyJwt(publicKey, token, { issuer }); + return r.ok ? r.claims : null; +} + +export function configKeys(cfg: AuthConfig): { + privateKey: Uint8Array; + publicKey: Uint8Array; +} { + return { + privateKey: privateKeyFromPem(cfg.es256PrivateKeyPem), + publicKey: publicKeyFromPem(cfg.es256PublicKeyPem), + }; +} + +export function safeJson(req: Request): T | null { + try { + return req.json() as T; + } catch { + return null; + } +} + +/** STDB V8 isolate has no globalThis.URL. */ +export function parseQueryString(uri: string): Record { + const q = uri.indexOf('?'); + if (q < 0) return {}; + const out: Record = {}; + for (const pair of uri.slice(q + 1).split('&')) { + const eq = pair.indexOf('='); + try { + if (eq < 0) { + out[decodeURIComponent(pair)] = ''; + } else { + out[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent( + pair.slice(eq + 1) + ); + } + } catch { + // Ignore malformed percent-encoding. Callers will treat the missing + // parameter as a controlled bad request and keep the handler available. + } + } + return out; +} diff --git a/spacetime-auth-ts/src/handlers/email_verify.ts b/spacetime-auth-ts/src/handlers/email_verify.ts new file mode 100644 index 00000000000..990df87e47e --- /dev/null +++ b/spacetime-auth-ts/src/handlers/email_verify.ts @@ -0,0 +1,178 @@ +import type { SyncResponse, Request } from 'spacetimedb/server'; +import { uuidV7, randomToken } from '../crypto.ts'; +import { + buildVerifyEmail, + MailerNotConfiguredError, + type SendMailFn, +} from '../mailer.ts'; +import { + type AuthHandlerCtx, + ConfigMissingError, + errorResponse, + jsonResponse, + parseCookies, + parseQueryString, + redirectResponse, + requireConfig, +} from './_helpers.ts'; +import { verifyJwt } from '../jwt.ts'; +import { publicKeyFromPem } from '../keys.ts'; +import { Timestamp } from 'spacetimedb'; +import { + AUTH_RATE_LIMITS, + type AuthHttpOptions, + enforceIpRateLimit, +} from '../rate_limit.ts'; + +const PURPOSE = 'email_verify'; +const TOKEN_TTL_SECONDS = 60n * 60n * 24n; + +export interface VerifyRequestOpts extends AuthHttpOptions { + sendMail: SendMailFn; + appName?: string; + /** Default '/'. */ + successRedirect?: string; +} + +export function makeEmailVerifyRequestHandler(opts: VerifyRequestOpts) { + return function emailVerifyRequest( + ctx: AuthHandlerCtx, + req: Request + ): SyncResponse { + if (!opts.sendMail) throw new MailerNotConfiguredError(); + const limited = enforceIpRateLimit( + ctx, + req, + AUTH_RATE_LIMITS.emailVerifyRequest, + opts.trustedProxyHeader + ); + if (limited) return limited; + + const verificationId = uuidV7( + ctx.random, + Number(ctx.timestamp.microsSinceUnixEpoch / 1000n) + ); + const token = randomToken(ctx.random, 32); + + let userEmail: string; + let baseUrl: string; + try { + ({ userEmail, baseUrl } = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const cookies = parseCookies(req.headers.get('cookie')); + const bearer = req.headers.get('authorization'); + const sessionToken = + bearer && bearer.toLowerCase().startsWith('bearer ') + ? bearer.slice(7).trim() + : cookies[cfg.cookieName]; + if (!sessionToken) throw new UnauthenticatedError(); + + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const nowMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + const v = verifyJwt(pub, sessionToken, { + issuer: cfg.issuerUrl, + nowSeconds: Number(nowMicros / 1_000_000n), + }); + if (!v.ok) throw new UnauthenticatedError(); + if (!v.claims.jti) throw new UnauthenticatedError(); + + const session = tx.db.authSession.sessionId.find(v.claims.jti); + if (!session || session.userId !== v.claims.sub) + throw new UnauthenticatedError(); + if ((session.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + throw new UnauthenticatedError(); + } + + const user = tx.db.authUser.userId.find(v.claims.sub); + if (!user) throw new UnauthenticatedError(); + if (user.emailVerified) throw new AlreadyVerifiedError(); + + for (const row of tx.db.authVerification.identifier.filter( + user.email + )) { + if (row.purpose === PURPOSE) tx.db.authVerification.delete(row); + } + + tx.db.authVerification.insert({ + verificationId, + identifier: user.email, + value: token, + purpose: PURPOSE, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + TOKEN_TTL_SECONDS * 1_000_000n + ), + createdAt: ctx.timestamp, + }); + return { userEmail: user.email, baseUrl: cfg.baseUrl }; + })); + } catch (e) { + if (e instanceof UnauthenticatedError) + return errorResponse('unauthenticated', 401); + if (e instanceof AlreadyVerifiedError) + return jsonResponse({ ok: true, alreadyVerified: true }); + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } + + const mail = buildVerifyEmail({ baseUrl, token, appName: opts.appName }); + mail.to = userEmail; + opts.sendMail(ctx, mail); + return jsonResponse({ ok: true }); + }; +} + +export function makeEmailVerifyHandler( + opts: { successRedirect?: string } = {} +) { + return function emailVerify(ctx: AuthHandlerCtx, req: Request): SyncResponse { + const q = parseQueryString(req.uri); + const token = q['token']; + if (!token) return errorResponse('missing_token', 400); + + try { + ctx.withTx(tx => { + const row = tx.db.authVerification.value.find(token); + if (!row || row.purpose !== PURPOSE) throw new BadTokenError(); + if ( + row.expiresAt.microsSinceUnixEpoch < + ctx.timestamp.microsSinceUnixEpoch + ) { + tx.db.authVerification.delete(row); + throw new BadTokenError(); + } + + const user = tx.db.authUser.email.find(row.identifier); + tx.db.authVerification.delete(row); + if (!user) throw new BadTokenError(); + + tx.db.authUser.userId.update({ + ...user, + emailVerified: true, + updatedAt: ctx.timestamp, + }); + }); + } catch (e) { + if (e instanceof BadTokenError) return errorResponse('bad_token', 400); + throw e; + } + + return redirectResponse(opts.successRedirect ?? '/'); + }; +} + +class UnauthenticatedError extends Error { + constructor() { + super('unauthenticated'); + } +} +class AlreadyVerifiedError extends Error { + constructor() { + super('already_verified'); + } +} +class BadTokenError extends Error { + constructor() { + super('bad_token'); + } +} diff --git a/spacetime-auth-ts/src/handlers/github.ts b/spacetime-auth-ts/src/handlers/github.ts new file mode 100644 index 00000000000..58cd1e7076c --- /dev/null +++ b/spacetime-auth-ts/src/handlers/github.ts @@ -0,0 +1,97 @@ +import { + makeOAuthCallbackHandler, + makeOAuthStartHandler, + type OAuthProfile, + type OAuthProviderSpec, +} from './oauth.ts'; +import type { AuthHandlerCtx } from './_helpers.ts'; + +const githubHeaders = { + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'user-agent': 'spacetimedb-auth-submodule', +}; + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null + ? (value as Record) + : {}; +} + +function pickGithubEmail(rows: unknown): string { + if (!Array.isArray(rows)) return ''; + const primaryVerified = rows.find(value => { + const row = record(value); + return ( + row.primary === true && + row.verified === true && + typeof row.email === 'string' + ); + }); + if (primaryVerified) return String(record(primaryVerified).email); + const verified = rows.find(value => { + const row = record(value); + return row.verified === true && typeof row.email === 'string'; + }); + return verified ? String(record(verified).email) : ''; +} + +function resolveGithubProfile( + ctx: AuthHandlerCtx, + accessToken: string +): OAuthProfile | { error: string } { + const headers = { + ...githubHeaders, + authorization: `Bearer ${accessToken}`, + }; + + const userRes = ctx.http.fetch('https://api.github.com/user', { + method: 'GET', + headers, + }); + if (!userRes.ok) return { error: `userinfo_failed:${userRes.status}` }; + + const user = record(userRes.json()); + const emailRes = ctx.http.fetch('https://api.github.com/user/emails', { + method: 'GET', + headers, + }); + if (!emailRes.ok) return { error: `github_email_failed:${emailRes.status}` }; + const email = pickGithubEmail(emailRes.json()); + + return { + sub: String(user.id ?? ''), + email, + emailVerified: email.length > 0, + name: typeof user.name === 'string' ? user.name : String(user.login ?? ''), + image: typeof user.avatar_url === 'string' ? user.avatar_url : undefined, + }; +} + +const github: OAuthProviderSpec = { + id: 'github', + authorizeUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + scope: 'read:user user:email', + oidc: false, + userInfoUrl: 'https://api.github.com/user', + userInfoHeaders: githubHeaders, + getClientId: cfg => cfg.githubClientId ?? '', + getClientSecret: cfg => cfg.githubClientSecret ?? '', + resolveProfile: resolveGithubProfile, + parseProfile: data => { + const user = record(data); + return { + sub: String(user.id ?? ''), + email: String(user.email ?? ''), + emailVerified: true, + name: + typeof user.name === 'string' ? user.name : String(user.login ?? ''), + image: typeof user.avatar_url === 'string' ? user.avatar_url : undefined, + }; + }, + usePkce: false, +}; + +export const githubStartHandler = makeOAuthStartHandler(github); +export const githubCallbackHandler = makeOAuthCallbackHandler(github); diff --git a/spacetime-auth-ts/src/handlers/google.ts b/spacetime-auth-ts/src/handlers/google.ts new file mode 100644 index 00000000000..f6ac74c7c55 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/google.ts @@ -0,0 +1,36 @@ +import { + makeOAuthCallbackHandler, + makeOAuthStartHandler, + type OAuthProviderSpec, +} from './oauth.ts'; + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null + ? (value as Record) + : {}; +} + +const google: OAuthProviderSpec = { + id: 'google', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'openid email profile', + oidc: false, + userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', + getClientId: cfg => cfg.googleClientId ?? '', + getClientSecret: cfg => cfg.googleClientSecret ?? '', + parseProfile: data => { + const claims = record(data); + return { + sub: String(claims.sub ?? ''), + email: String(claims.email ?? ''), + emailVerified: claims.email_verified === true, + name: typeof claims.name === 'string' ? claims.name : undefined, + image: typeof claims.picture === 'string' ? claims.picture : undefined, + }; + }, + authorizeExtras: { access_type: 'offline', prompt: 'consent' }, +}; + +export const googleStartHandler = makeOAuthStartHandler(google); +export const googleCallbackHandler = makeOAuthCallbackHandler(google); diff --git a/spacetime-auth-ts/src/handlers/index.ts b/spacetime-auth-ts/src/handlers/index.ts new file mode 100644 index 00000000000..0e99deda275 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/index.ts @@ -0,0 +1,33 @@ +export { passwordLoginHandler, passwordSignupHandler } from './password.ts'; +export { googleStartHandler, googleCallbackHandler } from './google.ts'; +export { githubStartHandler, githubCallbackHandler } from './github.ts'; +export { meHandler, logoutHandler, refreshHandler } from './session.ts'; +export { + makeOAuthCallbackHandler, + makeOAuthStartHandler, + type OAuthProviderSpec, + type OAuthProfile, +} from './oauth.ts'; +export { + makeEmailVerifyHandler, + makeEmailVerifyRequestHandler, + type VerifyRequestOpts, +} from './email_verify.ts'; +export { + makeForgotPasswordHandler, + resetPasswordHandler, + type ForgotPasswordOpts, +} from './password_reset.ts'; +export { + clearCookie, + makeCookie, + parseCookies, + jsonResponse, + errorResponse, + redirectResponse, + readBearer, + readSession, + shouldUseSecureCookies, + type CookieOpts, +} from './_helpers.ts'; +export type { AuthHttpOptions, TrustedProxyHeader } from '../rate_limit.ts'; diff --git a/spacetime-auth-ts/src/handlers/oauth.ts b/spacetime-auth-ts/src/handlers/oauth.ts new file mode 100644 index 00000000000..a420c7859ee --- /dev/null +++ b/spacetime-auth-ts/src/handlers/oauth.ts @@ -0,0 +1,451 @@ +import { SyncResponse, type Request } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import { + newSessionToken, + newPkceVerifier, + pkceChallenge, + randomToken, + uuidV7, +} from '../crypto.ts'; +import { signJwt } from '../jwt.ts'; +import { privateKeyFromPem } from '../keys.ts'; +import { + type AuthHandlerCtx, + shouldUseSecureCookies, + userAgent, + ConfigMissingError, + errorResponse, + makeCookie, + parseQueryString, + redirectResponse, + requireConfig, +} from './_helpers.ts'; +import { + AUTH_RATE_LIMITS, + type AuthHttpOptions, + clientKey, + enforceRateLimits, +} from '../rate_limit.ts'; +import { safeRedirectPath } from '../request-trust.ts'; +import type { AuthAccount, AuthConfig } from '../types.ts'; + +const OAUTH_STATE_TTL_SECONDS = 600n; +const MAX_OAUTH_CODE_LENGTH = 4096; +const MAX_OAUTH_STATE_LENGTH = 256; +const MAX_PROFILE_SUB_LENGTH = 512; +const MAX_PROFILE_EMAIL_LENGTH = 320; +const MAX_PROFILE_NAME_LENGTH = 256; +const MAX_PROFILE_IMAGE_LENGTH = 2048; + +export interface OAuthProviderSpec { + id: string; + authorizeUrl: string; + tokenUrl: string; + scope: string; + getClientId: (cfg: AuthConfig) => string; + getClientSecret: (cfg: AuthConfig) => string; + /** Reserved for verified OIDC id_token support. Prefer userInfoUrl. */ + oidc: boolean; + userInfoUrl?: string; + userInfoHeaders?: Record; + parseProfile: (data: unknown) => OAuthProfile; + resolveProfile?: ( + ctx: AuthHandlerCtx, + accessToken: string + ) => OAuthProfile | OAuthProfileError; + authorizeExtras?: Record; + /** Default true. */ + usePkce?: boolean; +} + +export interface OAuthProfile { + sub: string; + email: string; + emailVerified?: boolean; + name?: string; + image?: string; +} + +export interface OAuthProfileError { + error: string; +} + +function isProfileError( + value: OAuthProfile | OAuthProfileError +): value is OAuthProfileError { + return typeof (value as OAuthProfileError).error === 'string'; +} + +export function makeOAuthStartHandler( + provider: OAuthProviderSpec, + defaultOptions: AuthHttpOptions = {} +) { + return function start( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = defaultOptions + ): SyncResponse { + const q = parseQueryString(req.uri); + const requestedRedirect = q['redirectTo']; + const redirectTo = + requestedRedirect === undefined + ? '/' + : safeRedirectPath(requestedRedirect); + if (redirectTo === undefined) return errorResponse('invalid_redirect', 400); + const ipKey = clientKey(req, options.trustedProxyHeader); + const limited = ipKey + ? enforceRateLimits(ctx, req, [ + { + policy: AUTH_RATE_LIMITS.oauthStart, + actor: `ip:${ipKey}:${provider.id}`, + }, + ]) + : null; + if (limited) return limited; + + const state = randomToken(ctx.random, 32); + const verifier = + (provider.usePkce ?? true) ? newPkceVerifier(ctx.random) : ''; + + let baseUrl: string; + let clientId: string; + try { + ({ baseUrl, clientId } = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const cid = provider.getClientId(cfg); + if (!cid) throw new ProviderNotConfiguredError(provider.id); + tx.db.authOauthState.insert({ + state, + provider: provider.id, + codeVerifier: verifier, + redirectTo, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + OAUTH_STATE_TTL_SECONDS * 1_000_000n + ), + createdAt: ctx.timestamp, + }); + return { baseUrl: cfg.baseUrl, clientId: cid }; + })); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + if (e instanceof ProviderNotConfiguredError) + return errorResponse(`provider_not_configured:${e.provider}`, 500); + throw e; + } + + const params: Record = { + client_id: clientId, + redirect_uri: `${baseUrl}/auth/${provider.id}/callback`, + response_type: 'code', + scope: provider.scope, + state, + }; + if (provider.usePkce ?? true) { + params['code_challenge'] = pkceChallenge(verifier); + params['code_challenge_method'] = 'S256'; + } + for (const [k, v] of Object.entries(provider.authorizeExtras ?? {})) { + params[k] = v; + } + const qs = Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + const sep = provider.authorizeUrl.includes('?') ? '&' : '?'; + return redirectResponse(`${provider.authorizeUrl}${sep}${qs}`); + }; +} + +export function makeOAuthCallbackHandler( + provider: OAuthProviderSpec, + defaultOptions: AuthHttpOptions = {} +) { + return function callback( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = defaultOptions + ): SyncResponse { + const q = parseQueryString(req.uri); + const code = q['code']; + const state = q['state']; + if (!code || !state) return errorResponse('missing_code_or_state', 400); + if ( + code.length > MAX_OAUTH_CODE_LENGTH || + state.length > MAX_OAUTH_STATE_LENGTH + ) { + return errorResponse('invalid_code_or_state', 400); + } + + let baseUrl: string; + let clientId: string; + let clientSecret: string; + let codeVerifier: string; + let redirectTo: string; + try { + ({ baseUrl, clientId, clientSecret, codeVerifier, redirectTo } = + ctx.withTx(tx => { + const cfg = requireConfig(tx); + const row = tx.db.authOauthState.state.find(state); + if (!row) throw new BadStateError(); + if (row.provider !== provider.id) throw new BadStateError(); + if ( + row.expiresAt.microsSinceUnixEpoch < + ctx.timestamp.microsSinceUnixEpoch + ) { + tx.db.authOauthState.delete(row); + throw new BadStateError(); + } + const out = { + baseUrl: cfg.baseUrl, + clientId: provider.getClientId(cfg), + clientSecret: provider.getClientSecret(cfg), + codeVerifier: row.codeVerifier, + redirectTo: row.redirectTo, + }; + tx.db.authOauthState.delete(row); + return out; + })); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + if (e instanceof BadStateError) return errorResponse('bad_state', 400); + throw e; + } + + const formParams: Record = { + grant_type: 'authorization_code', + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: `${baseUrl}/auth/${provider.id}/callback`, + }; + if (codeVerifier) formParams['code_verifier'] = codeVerifier; + const tokenForm = Object.entries(formParams) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + + const tokRes = ctx.http.fetch(provider.tokenUrl, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + }, + body: tokenForm, + }); + if (!tokRes.ok) return errorResponse('token_exchange_failed', 502); + const tokens = tokRes.json() as unknown; + const tokenRecord = + typeof tokens === 'object' && tokens !== null + ? (tokens as Record) + : {}; + const accessToken = + typeof tokenRecord.access_token === 'string' + ? tokenRecord.access_token + : undefined; + const refreshToken = + typeof tokenRecord.refresh_token === 'string' + ? tokenRecord.refresh_token + : undefined; + const idToken = + typeof tokenRecord.id_token === 'string' + ? tokenRecord.id_token + : undefined; + const expiresIn = + typeof tokenRecord.expires_in === 'number' && + Number.isSafeInteger(tokenRecord.expires_in) && + tokenRecord.expires_in > 0 + ? tokenRecord.expires_in + : undefined; + if (!accessToken && !idToken) + return errorResponse('no_token_in_response', 502); + + let profile: OAuthProfile; + if (provider.resolveProfile && accessToken) { + const resolved = provider.resolveProfile(ctx, accessToken); + if (isProfileError(resolved)) return errorResponse(resolved.error, 502); + profile = resolved; + } else if (provider.userInfoUrl && accessToken) { + const uRes = ctx.http.fetch(provider.userInfoUrl, { + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + accept: 'application/json', + 'user-agent': 'spacetimedb-auth-submodule', + ...(provider.userInfoHeaders ?? {}), + }, + }); + if (!uRes.ok) return errorResponse(`userinfo_failed:${uRes.status}`, 502); + profile = provider.parseProfile(uRes.json()); + } else if (provider.oidc && idToken) { + return errorResponse('id_token_verification_unsupported', 502); + } else { + return errorResponse('cannot_resolve_profile', 502); + } + + if ( + !profile.email || + !profile.sub || + profile.email.length > MAX_PROFILE_EMAIL_LENGTH || + profile.sub.length > MAX_PROFILE_SUB_LENGTH || + (profile.name?.length ?? 0) > MAX_PROFILE_NAME_LENGTH || + (profile.image?.length ?? 0) > MAX_PROFILE_IMAGE_LENGTH + ) + return errorResponse('incomplete_profile', 502); + + const nowMs = Number(ctx.timestamp.microsSinceUnixEpoch / 1000n); + const newUserId = uuidV7(ctx.random, nowMs); + const newAccountId = uuidV7(ctx.random, nowMs); + const sessionId = uuidV7(ctx.random, nowMs); + const sessionToken = newSessionToken(ctx.random); + + let authResult: { + issuerUrl: string; + cookieName: string; + sessionTtlSeconds: bigint; + privateKeyPem: string; + keyId: string; + userId: string; + }; + try { + authResult = ctx.withTx(tx => { + const cfg = requireConfig(tx); + + let existing: AuthAccount | undefined; + for (const a of tx.db.authAccount.providerAccountId.filter( + profile.sub + )) { + if (a.providerId === provider.id) { + existing = a; + break; + } + } + + let resolvedUserId: string; + if (existing) { + resolvedUserId = existing.userId; + tx.db.authAccount.accountId.update({ + ...existing, + passwordHash: existing.passwordHash, + accessToken: accessToken ?? existing.accessToken, + refreshToken: refreshToken ?? existing.refreshToken, + accessTokenExpiresAt: expiresIn + ? new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(expiresIn) * 1_000_000n + ) + : existing.accessTokenExpiresAt, + updatedAt: ctx.timestamp, + }); + } else { + const byEmail = tx.db.authUser.email.find( + profile.email.toLowerCase() + ); + if (byEmail) throw new AccountLinkRequiredError(); + resolvedUserId = newUserId; + tx.db.authUser.insert({ + userId: newUserId, + email: profile.email.toLowerCase(), + emailVerified: profile.emailVerified ?? false, + name: profile.name, + image: profile.image, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + tx.db.authAccount.insert({ + accountId: newAccountId, + userId: resolvedUserId, + providerId: provider.id, + providerAccountId: profile.sub, + passwordHash: undefined, + accessToken, + refreshToken, + accessTokenExpiresAt: expiresIn + ? new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(expiresIn) * 1_000_000n + ) + : undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + + tx.db.authSession.insert({ + sessionId, + userId: resolvedUserId, + token: sessionToken, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(cfg.sessionTtlSeconds) * 1_000_000n + ), + ipAddress: clientKey(req, options.trustedProxyHeader), + userAgent: userAgent(req), + createdAt: ctx.timestamp, + }); + + return { + issuerUrl: cfg.issuerUrl, + cookieName: cfg.cookieName, + sessionTtlSeconds: cfg.sessionTtlSeconds, + privateKeyPem: cfg.es256PrivateKeyPem, + keyId: cfg.keyId, + userId: resolvedUserId, + }; + }); + } catch (error) { + if (error instanceof AccountLinkRequiredError) { + return errorResponse('account_link_required', 409); + } + throw error; + } + + const { + issuerUrl, + cookieName, + sessionTtlSeconds, + privateKeyPem, + keyId, + userId, + } = authResult; + + const nowSec = Math.floor(nowMs / 1000); + const ttlSec = Number(sessionTtlSeconds); + const privateKey = privateKeyFromPem(privateKeyPem); + const jwt = signJwt( + privateKey, + { + iss: issuerUrl, + sub: userId, + aud: issuerUrl, + iat: nowSec, + exp: nowSec + ttlSec, + jti: sessionId, + }, + keyId + ); + + return redirectResponse(redirectTo, { + 'set-cookie': makeCookie(cookieName, jwt, { + maxAgeSeconds: ttlSec, + secure: shouldUseSecureCookies(options.secureCookies), + }), + }); + }; +} + +class BadStateError extends Error { + constructor() { + super('bad_state'); + } +} +class AccountLinkRequiredError extends Error { + constructor() { + super('account_link_required'); + } +} +class ProviderNotConfiguredError extends Error { + constructor(public provider: string) { + super(`provider_not_configured:${provider}`); + } +} diff --git a/spacetime-auth-ts/src/handlers/password.ts b/spacetime-auth-ts/src/handlers/password.ts new file mode 100644 index 00000000000..6bd5013021e --- /dev/null +++ b/spacetime-auth-ts/src/handlers/password.ts @@ -0,0 +1,285 @@ +import type { SyncResponse, Request } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import { + hashPassword, + verifyPassword, + newSessionToken, + uuidV7, +} from '../crypto.ts'; +import { signJwt } from '../jwt.ts'; +import { privateKeyFromPem } from '../keys.ts'; +import { + type AuthHandlerCtx, + shouldUseSecureCookies, + userAgent, + ConfigMissingError, + errorResponse, + jsonResponse, + makeCookie, + requireConfig, + safeJson, +} from './_helpers.ts'; +import { + AUTH_RATE_LIMITS, + type AuthHttpOptions, + clientKey, + enforceRateLimits, +} from '../rate_limit.ts'; +import type { AuthAccount } from '../types.ts'; + +interface SignupBody { + email: string; + password: string; + name?: string; +} + +interface LoginBody { + email: string; + password: string; +} + +const MIN_PASSWORD_LEN = 8; +const MAX_PASSWORD_LEN = 1024; +const MAX_EMAIL_LEN = 320; +const MAX_NAME_LEN = 128; + +export function passwordSignupHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + const body = safeJson(req); + if (!body?.email || !body?.password) + return errorResponse('invalid_request', 400); + if (body.password.length < MIN_PASSWORD_LEN) + return errorResponse('password_too_short', 400); + if (body.password.length > MAX_PASSWORD_LEN) + return errorResponse('password_too_long', 400); + const email = body.email.toLowerCase().trim(); + if (email.length === 0 || email.length > MAX_EMAIL_LEN) + return errorResponse('invalid_email', 400); + if (body.name !== undefined && body.name.length > MAX_NAME_LEN) + return errorResponse('name_too_long', 400); + const ipKey = clientKey(req, options.trustedProxyHeader); + const limited = enforceRateLimits(ctx, req, [ + { policy: AUTH_RATE_LIMITS.passwordSignup, actor: `email:${email}` }, + ...(ipKey + ? [{ policy: AUTH_RATE_LIMITS.passwordSignup, actor: `ip:${ipKey}` }] + : []), + ]); + if (limited) return limited; + + const hash = hashPassword(ctx.random, body.password); + const nowMs = Number(ctx.timestamp.microsSinceUnixEpoch / 1000n); + const userId = uuidV7(ctx.random, nowMs); + const accountId = uuidV7(ctx.random, nowMs); + const sessionId = uuidV7(ctx.random, nowMs); + const sessionToken = newSessionToken(ctx.random); + + let issuerUrl: string; + let cookieName: string; + let sessionTtlSeconds: bigint; + let privateKeyPem: string; + let keyId: string; + + try { + ({ issuerUrl, cookieName, sessionTtlSeconds, privateKeyPem, keyId } = + ctx.withTx(tx => { + const cfg = requireConfig(tx); + if (tx.db.authUser.email.find(email) != null) + throw new EmailTakenError(); + + tx.db.authUser.insert({ + userId, + email, + emailVerified: false, + name: body.name, + image: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + tx.db.authAccount.insert({ + accountId, + userId, + providerId: 'password', + providerAccountId: email, + passwordHash: hash, + accessToken: undefined, + refreshToken: undefined, + accessTokenExpiresAt: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + const ttlMicros = BigInt(cfg.sessionTtlSeconds) * 1_000_000n; + tx.db.authSession.insert({ + sessionId, + userId, + token: sessionToken, + expiresAt: new Timestamp( + (ctx.timestamp.microsSinceUnixEpoch as bigint) + ttlMicros + ), + ipAddress: clientKey(req, options.trustedProxyHeader), + userAgent: userAgent(req), + createdAt: ctx.timestamp, + }); + + return { + issuerUrl: cfg.issuerUrl, + cookieName: cfg.cookieName, + sessionTtlSeconds: BigInt(cfg.sessionTtlSeconds), + privateKeyPem: cfg.es256PrivateKeyPem, + keyId: cfg.keyId, + }; + })); + } catch (e) { + if (e instanceof EmailTakenError) return errorResponse('email_taken', 409); + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } + + const nowSec = Math.floor(nowMs / 1000); + const ttlSec = Number(sessionTtlSeconds); + const privateKey = privateKeyFromPem(privateKeyPem); + const jwt = signJwt( + privateKey, + { + iss: issuerUrl, + sub: userId, + aud: issuerUrl, + iat: nowSec, + exp: nowSec + ttlSec, + jti: sessionId, + }, + keyId + ); + + return jsonResponse({ user: { userId, email }, token: jwt }, 200, { + 'set-cookie': makeCookie(cookieName, jwt, { + maxAgeSeconds: ttlSec, + secure: shouldUseSecureCookies(options.secureCookies), + }), + }); +} + +export function passwordLoginHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + const body = safeJson(req); + if (!body?.email || !body?.password) + return errorResponse('invalid_request', 400); + if (body.password.length > MAX_PASSWORD_LEN) + return errorResponse('invalid_credentials', 401); + const email = body.email.toLowerCase().trim(); + if (email.length === 0 || email.length > MAX_EMAIL_LEN) + return errorResponse('invalid_credentials', 401); + const ipKey = clientKey(req, options.trustedProxyHeader); + const limited = enforceRateLimits(ctx, req, [ + ...(ipKey + ? [{ policy: AUTH_RATE_LIMITS.passwordLoginIp, actor: `ip:${ipKey}` }] + : []), + { policy: AUTH_RATE_LIMITS.passwordLoginEmail, actor: `email:${email}` }, + ]); + if (limited) return limited; + + const nowMs = Number(ctx.timestamp.microsSinceUnixEpoch / 1000n); + const sessionId = uuidV7(ctx.random, nowMs); + const sessionToken = newSessionToken(ctx.random); + + let issuerUrl: string; + let cookieName: string; + let sessionTtlSeconds: bigint; + let privateKeyPem: string; + let keyId: string; + let loggedInUserId: string; + + try { + ({ + issuerUrl, + cookieName, + sessionTtlSeconds, + privateKeyPem, + keyId, + loggedInUserId, + } = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const user = tx.db.authUser.email.find(email); + if (!user) throw new InvalidCredentialsError(); + + let acct: AuthAccount | undefined; + for (const a of tx.db.authAccount.providerAccountId.filter(email)) { + if (a.providerId === 'password' && a.userId === user.userId) { + acct = a; + break; + } + } + if (!acct?.passwordHash) throw new InvalidCredentialsError(); + if (!verifyPassword(body.password, acct.passwordHash)) + throw new InvalidCredentialsError(); + + tx.db.authSession.insert({ + sessionId, + userId: user.userId, + token: sessionToken, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(cfg.sessionTtlSeconds) * 1_000_000n + ), + ipAddress: clientKey(req, options.trustedProxyHeader), + userAgent: userAgent(req), + createdAt: ctx.timestamp, + }); + + return { + issuerUrl: cfg.issuerUrl, + cookieName: cfg.cookieName, + sessionTtlSeconds: BigInt(cfg.sessionTtlSeconds), + privateKeyPem: cfg.es256PrivateKeyPem, + keyId: cfg.keyId, + loggedInUserId: user.userId, + }; + })); + } catch (e) { + if (e instanceof InvalidCredentialsError) + return errorResponse('invalid_credentials', 401); + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } + + const nowSec = Math.floor(nowMs / 1000); + const ttlSec = Number(sessionTtlSeconds); + const privateKey = privateKeyFromPem(privateKeyPem); + const jwt = signJwt( + privateKey, + { + iss: issuerUrl, + sub: loggedInUserId, + aud: issuerUrl, + iat: nowSec, + exp: nowSec + ttlSec, + jti: sessionId, + }, + keyId + ); + + return jsonResponse({ userId: loggedInUserId, token: jwt }, 200, { + 'set-cookie': makeCookie(cookieName, jwt, { + maxAgeSeconds: ttlSec, + secure: shouldUseSecureCookies(options.secureCookies), + }), + }); +} + +class EmailTakenError extends Error { + constructor() { + super('email_taken'); + } +} +class InvalidCredentialsError extends Error { + constructor() { + super('invalid_credentials'); + } +} diff --git a/spacetime-auth-ts/src/handlers/password_reset.ts b/spacetime-auth-ts/src/handlers/password_reset.ts new file mode 100644 index 00000000000..e1458d4c0a6 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/password_reset.ts @@ -0,0 +1,196 @@ +import type { SyncResponse, Request } from 'spacetimedb/server'; +import { hashPassword, randomToken, uuidV7 } from '../crypto.ts'; +import { + buildPasswordResetEmail, + MailerNotConfiguredError, + type SendMailFn, +} from '../mailer.ts'; +import { + type AuthHandlerCtx, + ConfigMissingError, + errorResponse, + jsonResponse, + requireConfig, + safeJson, +} from './_helpers.ts'; +import { Timestamp } from 'spacetimedb'; +import { + AUTH_RATE_LIMITS, + type AuthHttpOptions, + clientKey, + enforceIpRateLimit, + enforceRateLimits, +} from '../rate_limit.ts'; + +const PURPOSE = 'password_reset'; +const TOKEN_TTL_SECONDS = 60n * 60n; +const MIN_PASSWORD_LEN = 8; +const MAX_PASSWORD_LEN = 1024; +const MAX_EMAIL_LEN = 320; +const MAX_TOKEN_LEN = 256; + +interface ForgotBody { + email: string; +} +interface ResetBody { + token: string; + newPassword: string; +} + +export interface ForgotPasswordOpts extends AuthHttpOptions { + sendMail: SendMailFn; + appName?: string; +} + +// Always return 200 to keep account existence private. +export function makeForgotPasswordHandler(opts: ForgotPasswordOpts) { + return function forgot(ctx: AuthHandlerCtx, req: Request): SyncResponse { + if (!opts.sendMail) throw new MailerNotConfiguredError(); + + const body = safeJson(req); + if (!body?.email) return errorResponse('invalid_request', 400); + const email = body.email.toLowerCase().trim(); + if (email.length === 0 || email.length > MAX_EMAIL_LEN) + return errorResponse('invalid_request', 400); + const ipKey = clientKey(req, opts.trustedProxyHeader); + const limited = enforceRateLimits(ctx, req, [ + ...(ipKey + ? [{ policy: AUTH_RATE_LIMITS.passwordForgotIp, actor: `ip:${ipKey}` }] + : []), + { policy: AUTH_RATE_LIMITS.passwordForgotEmail, actor: `email:${email}` }, + ]); + if (limited) return limited; + + const verificationId = uuidV7( + ctx.random, + Number(ctx.timestamp.microsSinceUnixEpoch / 1000n) + ); + const token = randomToken(ctx.random, 32); + + let recipient: string | null = null; + let baseUrl: string; + try { + ({ recipient, baseUrl } = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const user = tx.db.authUser.email.find(email); + if (!user) return { recipient: null, baseUrl: cfg.baseUrl }; + + for (const row of tx.db.authVerification.identifier.filter(email)) { + if (row.purpose === PURPOSE) tx.db.authVerification.delete(row); + } + tx.db.authVerification.insert({ + verificationId, + identifier: email, + value: token, + purpose: PURPOSE, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + TOKEN_TTL_SECONDS * 1_000_000n + ), + createdAt: ctx.timestamp, + }); + return { recipient: email, baseUrl: cfg.baseUrl }; + })); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } + + if (recipient) { + const mail = buildPasswordResetEmail({ + baseUrl, + token, + appName: opts.appName, + }); + mail.to = recipient; + opts.sendMail(ctx, mail); + } + return jsonResponse({ ok: true }); + }; +} + +export function resetPasswordHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + const body = safeJson(req); + if (!body?.token || !body?.newPassword) + return errorResponse('invalid_request', 400); + if (body.newPassword.length < MIN_PASSWORD_LEN) + return errorResponse('password_too_short', 400); + if (body.newPassword.length > MAX_PASSWORD_LEN) + return errorResponse('password_too_long', 400); + if (body.token.length > MAX_TOKEN_LEN) return errorResponse('bad_token', 400); + const limited = enforceIpRateLimit( + ctx, + req, + AUTH_RATE_LIMITS.passwordReset, + options.trustedProxyHeader + ); + if (limited) return limited; + + const newHash = hashPassword(ctx.random, body.newPassword); + + try { + ctx.withTx(tx => { + const row = tx.db.authVerification.value.find(body.token); + if (!row || row.purpose !== PURPOSE) throw new BadTokenError(); + if ( + row.expiresAt.microsSinceUnixEpoch < ctx.timestamp.microsSinceUnixEpoch + ) { + tx.db.authVerification.delete(row); + throw new BadTokenError(); + } + + const user = tx.db.authUser.email.find(row.identifier); + tx.db.authVerification.delete(row); + if (!user) throw new BadTokenError(); + + let updated = false; + for (const acct of tx.db.authAccount.userId.filter(user.userId)) { + if (acct.providerId === 'password') { + tx.db.authAccount.accountId.update({ + ...acct, + passwordHash: newHash, + updatedAt: ctx.timestamp, + }); + updated = true; + } + } + if (!updated) { + const accountId = uuidV7( + ctx.random, + Number(ctx.timestamp.microsSinceUnixEpoch / 1000n) + ); + tx.db.authAccount.insert({ + accountId, + userId: user.userId, + providerId: 'password', + providerAccountId: user.email, + passwordHash: newHash, + accessToken: undefined, + refreshToken: undefined, + accessTokenExpiresAt: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + + for (const s of tx.db.authSession.userId.filter(user.userId)) { + tx.db.authSession.delete(s); + } + }); + } catch (e) { + if (e instanceof BadTokenError) return errorResponse('bad_token', 400); + throw e; + } + + return jsonResponse({ ok: true }); +} + +class BadTokenError extends Error { + constructor() { + super('bad_token'); + } +} diff --git a/spacetime-auth-ts/src/handlers/session.ts b/spacetime-auth-ts/src/handlers/session.ts new file mode 100644 index 00000000000..206ca2f3a02 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/session.ts @@ -0,0 +1,229 @@ +import type { SyncResponse, Request } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import { + type AuthHandlerCtx, + type AuthTransactionCtx, + shouldUseSecureCookies, + clearCookie, + errorResponse, + jsonResponse, + makeCookie, + parseCookies, + requireConfig, + ConfigMissingError, + userAgent, +} from './_helpers.ts'; +import { signJwt, verifyJwt } from '../jwt.ts'; +import { privateKeyFromPem, publicKeyFromPem } from '../keys.ts'; +import { newSessionToken, uuidV7 } from '../crypto.ts'; +import { type AuthHttpOptions, clientKey } from '../rate_limit.ts'; +type StoredAuthSession = NonNullable< + ReturnType +>; + +function readToken(req: Request, cookieName: string): string | null { + const auth = req.headers.get('authorization'); + if (auth && auth.toLowerCase().startsWith('bearer ')) + return auth.slice(7).trim(); + const cookies = parseCookies(req.headers.get('cookie')); + return cookies[cookieName] ?? null; +} + +function nowSeconds(ctx: AuthHandlerCtx): number { + return Number((ctx.timestamp.microsSinceUnixEpoch as bigint) / 1_000_000n); +} + +function findLiveSession( + tx: AuthTransactionCtx, + claims: { sub?: string; jti?: string }, + nowMicros: bigint +): StoredAuthSession | null { + if (!claims.sub || !claims.jti) return null; + const session = tx.db.authSession.sessionId.find(claims.jti); + if (!session) return null; + if (session.userId !== claims.sub) return null; + if ((session.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) + return null; + return session; +} + +export function meHandler(ctx: AuthHandlerCtx, req: Request): SyncResponse { + try { + const result = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const token = readToken(req, cfg.cookieName); + if (!token) return { status: 401 as const }; + + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const v = verifyJwt(pub, token, { + issuer: cfg.issuerUrl, + nowSeconds: nowSeconds(ctx), + }); + if (!v.ok) return { status: 401 as const }; + if ( + !findLiveSession( + tx, + v.claims, + ctx.timestamp.microsSinceUnixEpoch as bigint + ) + ) { + return { status: 401 as const }; + } + + const user = tx.db.authUser.userId.find(v.claims.sub); + if (!user) return { status: 401 as const }; + + return { + status: 200 as const, + body: { + user: { + userId: user.userId, + email: user.email, + emailVerified: user.emailVerified, + name: user.name, + image: user.image, + }, + sessionExpiresAt: v.claims.exp, + }, + }; + }); + + if (result.status === 401) return errorResponse('unauthenticated', 401); + return jsonResponse(result.body); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } +} + +export function refreshHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + const nowMs = Number(ctx.timestamp.microsSinceUnixEpoch / 1000n); + const sessionId = uuidV7(ctx.random, nowMs); + const sessionToken = newSessionToken(ctx.random); + + try { + const out = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const token = readToken(req, cfg.cookieName); + if (!token) return { status: 401 as const }; + + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const v = verifyJwt(pub, token, { + issuer: cfg.issuerUrl, + nowSeconds: nowSeconds(ctx), + }); + if (!v.ok) return { status: 401 as const }; + const existingSession = findLiveSession( + tx, + v.claims, + ctx.timestamp.microsSinceUnixEpoch as bigint + ); + if (!existingSession) return { status: 401 as const }; + + const user = tx.db.authUser.userId.find(v.claims.sub); + if (!user) return { status: 401 as const }; + tx.db.authSession.delete(existingSession); + + const ttlMicros = BigInt(cfg.sessionTtlSeconds) * 1_000_000n; + tx.db.authSession.insert({ + sessionId, + userId: user.userId, + token: sessionToken, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + ttlMicros + ), + ipAddress: clientKey(req, options.trustedProxyHeader), + userAgent: userAgent(req), + createdAt: ctx.timestamp, + }); + + return { + status: 200 as const, + privateKeyPem: cfg.es256PrivateKeyPem, + keyId: cfg.keyId, + issuerUrl: cfg.issuerUrl, + cookieName: cfg.cookieName, + sessionTtlSeconds: BigInt(cfg.sessionTtlSeconds), + user: { + userId: user.userId, + email: user.email, + emailVerified: user.emailVerified, + name: user.name, + image: user.image, + }, + }; + }); + + if (out.status === 401) return errorResponse('unauthenticated', 401); + + const nowSec = Math.floor(nowMs / 1000); + const ttlSec = Number(out.sessionTtlSeconds); + const priv = privateKeyFromPem(out.privateKeyPem); + const jwt = signJwt( + priv, + { + iss: out.issuerUrl, + sub: out.user.userId, + aud: out.issuerUrl, + iat: nowSec, + exp: nowSec + ttlSec, + jti: sessionId, + }, + out.keyId + ); + + return jsonResponse( + { user: out.user, token: jwt, sessionExpiresAt: nowSec + ttlSec }, + 200, + { + 'set-cookie': makeCookie(out.cookieName, jwt, { + maxAgeSeconds: ttlSec, + secure: shouldUseSecureCookies(options.secureCookies), + }), + } + ); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } +} + +export function logoutHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + try { + const cookieName = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const token = readToken(req, cfg.cookieName); + if (token) { + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const v = verifyJwt(pub, token, { + issuer: cfg.issuerUrl, + nowSeconds: nowSeconds(ctx), + }); + if (v.ok && v.claims.jti) { + const session = tx.db.authSession.sessionId.find(v.claims.jti); + if (session) tx.db.authSession.delete(session); + } + } + return cfg.cookieName; + }); + return jsonResponse({ ok: true }, 200, { + 'set-cookie': clearCookie(cookieName, { + secure: shouldUseSecureCookies(options.secureCookies), + }), + }); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } +} diff --git a/spacetime-auth-ts/src/index.ts b/spacetime-auth-ts/src/index.ts new file mode 100644 index 00000000000..e407cd06735 --- /dev/null +++ b/spacetime-auth-ts/src/index.ts @@ -0,0 +1,156 @@ +export { + authTables, + authUserTable, + authSessionTable, + authAccountTable, + authVerificationTable, + authOauthStateTable, + authConfigTable, + authConnectionBindingTable, + authAdminIdentityTable, + authUserRow, + authSessionRow, + authAccountRow, + authVerificationRow, + authOauthStateRow, + authConfigRow, + authConnectionBindingRow, + authAdminIdentityRow, +} from './tables.ts'; + +export { + authAdminVerdict, + denyIfNotAdmin, + seedAuthAdmin, + type AdminVerdict, +} from './admin.ts'; + +export { + passwordLoginHandler, + passwordSignupHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + meHandler, + logoutHandler, + refreshHandler, + makeOAuthStartHandler, + makeOAuthCallbackHandler, + makeEmailVerifyHandler, + makeEmailVerifyRequestHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + type OAuthProviderSpec, + type OAuthProfile, + type VerifyRequestOpts, + type ForgotPasswordOpts, +} from './handlers/index.ts'; + +export { + clearCookie, + makeCookie, + parseCookies, + jsonResponse, + errorResponse, + redirectResponse, + readBearer, + readSession, + configKeys, + type CookieOpts, +} from './handlers/_helpers.ts'; + +export { + setAuthConfigParams, + setAuthConfigImpl, + authSweepImpl, + revokeSessionParams, + revokeSessionImpl, + listMySessionsParams, + listMySessionsImpl, + revokeMySessionParams, + revokeMySessionImpl, + getPublicKeyPemParams, + getPublicKeyPemImpl, + linkConnectionParams, + linkConnectionImpl, + unlinkConnectionParams, + unlinkConnectionImpl, + updateProfileParams, + updateProfileImpl, +} from './procedures.ts'; + +export { + signJwt, + verifyJwt, + decodeJwtPayloadUnsafe, + type JwtClaims, + type JwtHeader, + type VerifyResult, + type VerifyJwtOptions, +} from './jwt.ts'; + +export { + hashPassword, + verifyPassword, + newSessionToken, + newPkceVerifier, + pkceChallenge, + randomToken, + randomBytes, + uuidV7, + type RandomSource, + type ScryptParams, +} from './crypto.ts'; + +export { + generateEs256Keypair, + fromPrivateKeyBytes, + privateKeyFromPem, + publicKeyFromPem, + type Es256Keypair, + type PublicKeyJwk, +} from './keys.ts'; + +export { + getCallerUserId, + findCallerUser, + requireCallerUserId, +} from './caller.ts'; + +export { + consumeRateLimit, + sweepRateLimits, + type ConsumeRateLimitOpts, + type RateLimitResult, +} from '@spacetimedb/rate-limit/submodule'; + +export { + AUTH_RATE_LIMITS, + clientKey, + enforceIpRateLimit, + enforceRateLimits, + rateLimitKey, + rateLimitResponse, + type AuthHttpOptions, + type AuthRateLimitPolicy, + type TrustedProxyHeader, +} from './rate_limit.ts'; + +export { + MailerNotConfiguredError, + buildVerifyEmail, + buildPasswordResetEmail, + type SendMailFn, + type MailParams, +} from './mailer.ts'; + +export type { + AuthUser, + AuthSession, + AuthAccount, + AuthVerification, + AuthOauthState, + AuthConfig, + AuthConnectionBinding, +} from './types.ts'; diff --git a/spacetime-auth-ts/src/jwt.ts b/spacetime-auth-ts/src/jwt.ts new file mode 100644 index 00000000000..1c5e6147c3d --- /dev/null +++ b/spacetime-auth-ts/src/jwt.ts @@ -0,0 +1,193 @@ +import { p256 } from '@noble/curves/nist.js'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder('utf-8'); + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const B64_REV = (() => { + const m = new Int8Array(256).fill(-1); + for (let i = 0; i < B64.length; i++) m[B64.charCodeAt(i)] = i; + return m; +})(); + +function b64uEncode(bytes: Uint8Array): string { + let out = ''; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + out += B64[bytes[i] >> 2]; + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + out += B64[bytes[i + 2] & 63]; + } + if (i < bytes.length) { + out += B64[bytes[i] >> 2]; + if (i + 1 === bytes.length) { + out += B64[(bytes[i] & 3) << 4]; + } else { + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[(bytes[i + 1] & 15) << 2]; + } + } + return out.replace(/\+/g, '-').replace(/\//g, '_'); +} + +function b64uDecode(str: string): Uint8Array { + let s = ''; + for (let i = 0; i < str.length; i++) { + const c = str[i] === '-' ? '+' : str[i] === '_' ? '/' : str[i]; + if (B64_REV[c.charCodeAt(0)] >= 0) s += c; + } + const out = new Uint8Array((s.length * 3) >> 2); + let oi = 0; + for (let i = 0; i + 3 < s.length; i += 4) { + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + const c = B64_REV[s.charCodeAt(i + 2)]; + const d = B64_REV[s.charCodeAt(i + 3)]; + out[oi++] = (a << 2) | (b >> 4); + out[oi++] = ((b & 15) << 4) | (c >> 2); + out[oi++] = ((c & 3) << 6) | d; + } + const tail = s.length & 3; + if (tail >= 2) { + const i = s.length - tail; + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + out[oi++] = (a << 2) | (b >> 4); + if (tail === 3) { + const c = B64_REV[s.charCodeAt(i + 2)]; + out[oi++] = ((b & 15) << 4) | (c >> 2); + } + } + return out.subarray(0, oi); +} + +function b64uJson(obj: unknown): string { + return b64uEncode(textEncoder.encode(JSON.stringify(obj))); +} + +export interface JwtHeader { + alg: 'ES256'; + typ: 'JWT'; + kid?: string; +} + +export interface JwtClaims { + iss: string; + sub: string; + aud?: string | string[]; + iat: number; + exp: number; + nbf?: number; + jti?: string; + [k: string]: unknown; +} + +/** Sign a JWT with ES256. privateKey is 32 raw bytes. */ +export function signJwt( + privateKey: Uint8Array, + claims: JwtClaims, + kid?: string +): string { + const header: JwtHeader = { alg: 'ES256', typ: 'JWT' }; + if (kid) header.kid = kid; + const headPart = b64uJson(header); + const payloadPart = b64uJson(claims); + const signingInput = `${headPart}.${payloadPart}`; + const sig = p256.sign(textEncoder.encode(signingInput), privateKey); + return `${signingInput}.${b64uEncode(sig)}`; +} + +export interface VerifyJwtOptions { + issuer?: string; + audience?: string; + /** Default 60. */ + clockToleranceSeconds?: number; + /** Default Date.now()/1000. */ + nowSeconds?: number; +} + +export type VerifyResult = + | { ok: true; claims: JwtClaims; header: JwtHeader } + | { + ok: false; + reason: + | 'malformed' + | 'bad-signature' + | 'expired' + | 'not-yet-valid' + | 'bad-issuer' + | 'bad-audience'; + }; + +/** publicKey: 65-byte uncompressed P-256 key. */ +export function verifyJwt( + publicKey: Uint8Array, + token: string, + opts: VerifyJwtOptions = {} +): VerifyResult { + const parts = token.split('.'); + if (parts.length !== 3) return { ok: false, reason: 'malformed' }; + const [headPart, payloadPart, sigPart] = parts; + + let header: JwtHeader; + let claims: JwtClaims; + try { + header = JSON.parse(textDecoder.decode(b64uDecode(headPart))); + claims = JSON.parse(textDecoder.decode(b64uDecode(payloadPart))); + } catch { + return { ok: false, reason: 'malformed' }; + } + if (header.alg !== 'ES256') return { ok: false, reason: 'bad-signature' }; + + let sig: Uint8Array; + try { + sig = b64uDecode(sigPart); + } catch { + return { ok: false, reason: 'malformed' }; + } + if (sig.length !== 64) return { ok: false, reason: 'bad-signature' }; + + let ok = false; + try { + ok = p256.verify( + sig, + textEncoder.encode(`${headPart}.${payloadPart}`), + publicKey + ); + } catch { + ok = false; + } + if (!ok) return { ok: false, reason: 'bad-signature' }; + + const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000); + const skew = opts.clockToleranceSeconds ?? 60; + if (typeof claims.exp === 'number' && claims.exp + skew < now) { + return { ok: false, reason: 'expired' }; + } + if (typeof claims.nbf === 'number' && claims.nbf - skew > now) { + return { ok: false, reason: 'not-yet-valid' }; + } + if (opts.issuer != null && claims.iss !== opts.issuer) { + return { ok: false, reason: 'bad-issuer' }; + } + if (opts.audience != null) { + const aud = claims.aud; + const matches = Array.isArray(aud) + ? aud.includes(opts.audience) + : aud === opts.audience; + if (!matches) return { ok: false, reason: 'bad-audience' }; + } + return { ok: true, claims, header }; +} + +/** Unsafe: no verification. Use only on trusted input. */ +export function decodeJwtPayloadUnsafe(token: string): JwtClaims | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + return JSON.parse(textDecoder.decode(b64uDecode(parts[1]))); + } catch { + return null; + } +} diff --git a/spacetime-auth-ts/src/keys.ts b/spacetime-auth-ts/src/keys.ts new file mode 100644 index 00000000000..ab118469522 --- /dev/null +++ b/spacetime-auth-ts/src/keys.ts @@ -0,0 +1,281 @@ +// ES256 (P-256) keypair generation. PEM (SPKI + PKCS#8) and JWK encoders. + +import { p256 } from '@noble/curves/nist.js'; +import { sha256 } from '@noble/hashes/sha2'; + +const PRIV_LEN = 32; +const COORD_LEN = 32; + +export interface Es256Keypair { + privateKey: Uint8Array; + publicKey: Uint8Array; + privateKeyPem: string; + publicKeyPem: string; + publicKeyJwk: PublicKeyJwk; + kid: string; +} + +export interface PublicKeyJwk { + kty: 'EC'; + crv: 'P-256'; + x: string; + y: string; + alg: 'ES256'; + use: 'sig'; + kid?: string; +} + +export interface RandomSource { + fill(array: T): T; +} + +/** + * SECURITY: When called inside STDB modules with ctx.random, the resulting key + * is DETERMINISTIC w.r.t. ctx.timestamp. Generate outside the module for prod. + */ +export function generateEs256Keypair(rng?: RandomSource): Es256Keypair { + const seed = rng ? rng.fill(new Uint8Array(48)) : undefined; + const { secretKey } = p256.keygen(seed); + const publicKey = p256.getPublicKey(secretKey, false); + return assemble(secretKey, publicKey); +} + +export function fromPrivateKeyBytes(privateKey: Uint8Array): Es256Keypair { + if (privateKey.length !== PRIV_LEN) { + throw new TypeError(`ES256 private key must be ${PRIV_LEN} bytes`); + } + const publicKey = p256.getPublicKey(privateKey, false); + return assemble(privateKey, publicKey); +} + +function assemble(privateKey: Uint8Array, publicKey: Uint8Array): Es256Keypair { + const { x, y } = splitUncompressedPublicKey(publicKey); + const publicKeyJwk: PublicKeyJwk = { + kty: 'EC', + crv: 'P-256', + alg: 'ES256', + use: 'sig', + x: b64uEncode(x), + y: b64uEncode(y), + }; + const kid = jwkThumbprint(publicKeyJwk); + publicKeyJwk.kid = kid; + return { + privateKey, + publicKey, + privateKeyPem: encodePrivateKeyPem(privateKey, publicKey), + publicKeyPem: encodePublicKeyPem(publicKey), + publicKeyJwk, + kid, + }; +} + +function splitUncompressedPublicKey(pub: Uint8Array): { + x: Uint8Array; + y: Uint8Array; +} { + if (pub.length !== 1 + COORD_LEN * 2 || pub[0] !== 0x04) { + throw new TypeError('expected uncompressed P-256 public key'); + } + return { x: pub.slice(1, 1 + COORD_LEN), y: pub.slice(1 + COORD_LEN) }; +} + +/** RFC 7638. */ +function jwkThumbprint(jwk: PublicKeyJwk): string { + const canonical = JSON.stringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y, + }); + const hash = sha256(new TextEncoder().encode(canonical)); + return b64uEncode(hash); +} + +// SPKI ECDSA P-256 algorithm OID prefix. +const SPKI_ALG_DER = new Uint8Array([ + 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, + 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, +]); + +function encodePublicKeyPem(publicKey65: Uint8Array): string { + const bitString = concat([ + new Uint8Array([0x03, publicKey65.length + 1, 0x00]), + publicKey65, + ]); + const body = concat([SPKI_ALG_DER, bitString]); + const der = wrapSequence(body); + return pemWrap('PUBLIC KEY', der); +} + +function encodePrivateKeyPem( + privateKey: Uint8Array, + publicKey65: Uint8Array +): string { + // RFC 5915 ECPrivateKey wrapped in PKCS#8 PrivateKeyInfo. + const version = new Uint8Array([0x02, 0x01, 0x01]); + const privOctet = concat([ + new Uint8Array([0x04, privateKey.length]), + privateKey, + ]); + const namedCurveBody = new Uint8Array([ + 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, + ]); + const namedCurveTagged = concat([ + new Uint8Array([0xa0, namedCurveBody.length]), + namedCurveBody, + ]); + const pubBitString = concat([ + new Uint8Array([0x03, publicKey65.length + 1, 0x00]), + publicKey65, + ]); + const pubTagged = concat([ + new Uint8Array([0xa1, pubBitString.length]), + pubBitString, + ]); + const ecPrivBody = concat([version, privOctet, namedCurveTagged, pubTagged]); + const ecPrivDer = wrapSequence(ecPrivBody); + + const p8version = new Uint8Array([0x02, 0x01, 0x00]); + const p8alg = SPKI_ALG_DER; + const p8privOctet = concat([ + encodeLengthPrefix(0x04, ecPrivDer.length), + ecPrivDer, + ]); + const p8body = concat([p8version, p8alg, p8privOctet]); + const p8der = wrapSequence(p8body); + return pemWrap('PRIVATE KEY', p8der); +} + +function wrapSequence(body: Uint8Array): Uint8Array { + return concat([encodeLengthPrefix(0x30, body.length), body]); +} + +function encodeLengthPrefix(tag: number, len: number): Uint8Array { + if (len < 0x80) return new Uint8Array([tag, len]); + if (len < 0x100) return new Uint8Array([tag, 0x81, len]); + if (len < 0x10000) + return new Uint8Array([tag, 0x82, (len >> 8) & 0xff, len & 0xff]); + throw new RangeError('DER length too large for this encoder'); +} + +function concat(arrays: Uint8Array[]): Uint8Array { + let n = 0; + for (const a of arrays) n += a.length; + const out = new Uint8Array(n); + let off = 0; + for (const a of arrays) { + out.set(a, off); + off += a.length; + } + return out; +} + +function pemWrap(label: string, der: Uint8Array): string { + const b64 = btoaBytes(der); + const lines: string[] = []; + for (let i = 0; i < b64.length; i += 64) lines.push(b64.slice(i, i + 64)); + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; +} + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +function btoaBytes(bytes: Uint8Array): string { + let out = ''; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + out += B64[bytes[i] >> 2]; + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + out += B64[bytes[i + 2] & 63]; + } + if (i < bytes.length) { + out += B64[bytes[i] >> 2]; + if (i + 1 === bytes.length) { + out += B64[(bytes[i] & 3) << 4]; + out += '=='; + } else { + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[(bytes[i + 1] & 15) << 2]; + out += '='; + } + } + return out; +} + +const B64_REV = (() => { + const m = new Int8Array(256).fill(-1); + for (let i = 0; i < B64.length; i++) m[B64.charCodeAt(i)] = i; + return m; +})(); +function atobBytes(b64: string): Uint8Array { + let s = ''; + for (let i = 0; i < b64.length; i++) { + const c = b64.charCodeAt(i); + if (B64_REV[c] >= 0) s += b64[i]; + } + const out = new Uint8Array((s.length * 3) >> 2); + let oi = 0; + for (let i = 0; i + 3 < s.length; i += 4) { + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + const c = B64_REV[s.charCodeAt(i + 2)]; + const d = B64_REV[s.charCodeAt(i + 3)]; + out[oi++] = (a << 2) | (b >> 4); + out[oi++] = ((b & 15) << 4) | (c >> 2); + out[oi++] = ((c & 3) << 6) | d; + } + const tail = s.length & 3; + if (tail >= 2) { + const i = s.length - tail; + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + out[oi++] = (a << 2) | (b >> 4); + if (tail === 3) { + const c = B64_REV[s.charCodeAt(i + 2)]; + out[oi++] = ((b & 15) << 4) | (c >> 2); + } + } + return out.subarray(0, oi); +} + +function b64uEncode(bytes: Uint8Array): string { + return btoaBytes(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function pemUnwrap(label: string, pem: string): Uint8Array { + const prefix = `-----BEGIN ${label}-----`; + const suffix = `-----END ${label}-----`; + const start = pem.indexOf(prefix); + const end = pem.indexOf(suffix); + if (start < 0 || end < 0) throw new TypeError(`PEM ${label} block not found`); + const inner = pem.slice(start + prefix.length, end).replace(/\s+/g, ''); + return atobBytes(inner); +} + +export function privateKeyFromPem(pem: string): Uint8Array { + const der = pemUnwrap('PRIVATE KEY', pem); + for (let i = 0; i < der.length - 33; i++) { + if (der[i] === 0x04 && der[i + 1] === 0x20) { + return der.slice(i + 2, i + 2 + 32); + } + } + throw new TypeError('could not extract raw private key from PKCS#8 PEM'); +} + +export function publicKeyFromPem(pem: string): Uint8Array { + const der = pemUnwrap('PUBLIC KEY', pem); + for (let i = 0; i < der.length - 67; i++) { + if ( + der[i] === 0x03 && + der[i + 1] === 0x42 && + der[i + 2] === 0x00 && + der[i + 3] === 0x04 + ) { + return der.slice(i + 3, i + 68); + } + } + throw new TypeError('could not extract raw public key from SPKI PEM'); +} diff --git a/spacetime-auth-ts/src/mailer.ts b/spacetime-auth-ts/src/mailer.ts new file mode 100644 index 00000000000..38a57da4f58 --- /dev/null +++ b/spacetime-auth-ts/src/mailer.ts @@ -0,0 +1,46 @@ +import type { AuthHandlerCtx } from './context.ts'; + +export interface MailParams { + to: string; + subject: string; + text: string; + html?: string; +} + +export type SendMailFn = (ctx: AuthHandlerCtx, params: MailParams) => void; + +export class MailerNotConfiguredError extends Error { + constructor() { + super('auth.mailer_not_configured'); + } +} + +export function buildVerifyEmail(opts: { + baseUrl: string; + token: string; + appName?: string; +}): MailParams { + const url = `${opts.baseUrl}/auth/email/verify?token=${encodeURIComponent(opts.token)}`; + const app = opts.appName ?? 'this app'; + return { + to: '', + subject: `Verify your email for ${app}`, + text: `Click to verify your email:\n\n${url}\n\nThis link expires in 24 hours.`, + html: `

Click to verify your email:

${url}

This link expires in 24 hours.

`, + }; +} + +export function buildPasswordResetEmail(opts: { + baseUrl: string; + token: string; + appName?: string; +}): MailParams { + const url = `${opts.baseUrl}/auth/password/reset?token=${encodeURIComponent(opts.token)}`; + const app = opts.appName ?? 'this app'; + return { + to: '', + subject: `Reset your password for ${app}`, + text: `Click to reset your password:\n\n${url}\n\nThis link expires in 1 hour. Ignore this email if the request was unexpected.`, + html: `

Click to reset your password:

${url}

This link expires in 1 hour. Ignore this email if the request was unexpected.

`, + }; +} diff --git a/spacetime-auth-ts/src/mounted/index.ts b/spacetime-auth-ts/src/mounted/index.ts new file mode 100644 index 00000000000..60d2a7c118f --- /dev/null +++ b/spacetime-auth-ts/src/mounted/index.ts @@ -0,0 +1,340 @@ +import { schema, t, table, Router } from 'spacetimedb/server'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { installAuth } from './install'; +import { + setAuthConfigParams, + setAuthConfigImpl, + authSweepImpl, + getPublicKeyPemParams, + getPublicKeyPemImpl, + linkConnectionParams, + linkConnectionImpl, + unlinkConnectionParams, + unlinkConnectionImpl, + updateProfileParams, + updateProfileImpl, + revokeSessionParams, + revokeSessionImpl, + listMySessionsParams, + listMySessionsImpl, + revokeMySessionParams, + revokeMySessionImpl, + passwordSignupHandler, + passwordLoginHandler, + meHandler, + logoutHandler, + refreshHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + makeEmailVerifyRequestHandler, + makeEmailVerifyHandler, + getCallerUserId, + type SendMailFn, + type MailParams, +} from '../index'; + +// Development mailer that logs messages to the SpacetimeDB console. +const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { + console.log( + `[mail] to=${params.to} subject=${params.subject}\n${params.text}` + ); +}; + +// STDB requires submodule tables declared inline at the module's literal site, not imported as values. +const authUser = table( + { name: 'auth_user', public: false }, + { + userId: t.string().primaryKey(), + email: t.string().unique(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +const authSession = table( + { name: 'auth_session', public: false }, + { + sessionId: t.string().primaryKey(), + userId: t.string().index(), + token: t.string().unique(), + expiresAt: t.timestamp().index(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + createdAt: t.timestamp(), + } +); + +const authAccount = table( + { name: 'auth_account', public: false }, + { + accountId: t.string().primaryKey(), + userId: t.string().index(), + providerId: t.string().index(), + providerAccountId: t.string().index(), + passwordHash: t.option(t.string()), + accessToken: t.option(t.string()), + refreshToken: t.option(t.string()), + accessTokenExpiresAt: t.option(t.timestamp()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +const authVerification = table( + { name: 'auth_verification', public: false }, + { + verificationId: t.string().primaryKey(), + identifier: t.string().index(), + value: t.string().unique(), + purpose: t.string(), + expiresAt: t.timestamp().index(), + createdAt: t.timestamp(), + } +); + +const authOauthState = table( + { name: 'auth_oauth_state', public: false }, + { + state: t.string().primaryKey(), + provider: t.string(), + codeVerifier: t.string(), + redirectTo: t.string(), + expiresAt: t.timestamp().index(), + createdAt: t.timestamp(), + } +); + +const authConfig = table( + { name: 'auth_config', public: false }, + { + singleton: t.bool().primaryKey(), + issuerUrl: t.string(), + baseUrl: t.string(), + cookieName: t.string(), + sessionTtlSeconds: t.u64(), + es256PrivateKeyPem: t.string(), + es256PublicKeyPem: t.string(), + keyId: t.string(), + googleClientId: t.option(t.string()), + googleClientSecret: t.option(t.string()), + githubClientId: t.option(t.string()), + githubClientSecret: t.option(t.string()), + updatedAt: t.timestamp(), + } +); + +const authConnectionBinding = table( + { name: 'auth_connection_binding', public: false }, + { + stdbIdentity: t.identity().primaryKey(), + userId: t.string().index(), + linkedAt: t.timestamp(), + } +); + +const authAdminIdentity = table( + { name: 'auth_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +const authSweeperTick = table( + { name: 'auth_sweeper_tick', scheduled: (): any => auth_sweep }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + rateLimit, + authUser, + authSession, + authAccount, + authVerification, + authOauthState, + authConfig, + authConnectionBinding, + authAdminIdentity, + authSweeperTick, +}); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + installAuth(ctx); +}); + +// On the first set_auth_config call (no PEM supplied), setAuthConfigImpl generates a fresh ES256 keypair in-module. +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + setAuthConfigImpl(ctx, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + getPublicKeyPemImpl +); + +export const link_connection = spacetimedb.reducer( + linkConnectionParams, + (ctx, args) => { + linkConnectionImpl(ctx, args); + } +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + unlinkConnectionImpl(ctx, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + updateProfileImpl +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + revokeSessionImpl(ctx, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + listMySessionsImpl +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + revokeMySessionImpl(ctx, args); + } +); + +export const auth_sweep = spacetimedb.reducer( + { arg: authSweeperTick.rowType }, + (ctx, _arg) => { + authSweepImpl(ctx); + } +); + +export const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUser.rowType), + ctx => { + const binding = ctx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) return []; + const row = ctx.db.authUser.userId.find(binding.userId); + return row ? [row] : []; + } +); + +export const whoami = spacetimedb.procedure( + {}, + t.object('WhoAmI', { + userId: t.option(t.string()), + senderIdentityHex: t.string(), + }), + (ctx, _args) => { + const userId = getCallerUserId(ctx); + return { + userId: userId ?? undefined, + senderIdentityHex: ctx.sender.toHexString(), + }; + } +); + +const localAuthHttp = { secureCookies: false } as const; + +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + passwordSignupHandler(ctx, req, localAuthHttp) +); +export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => + passwordLoginHandler(ctx, req, localAuthHttp) +); +export const authMe = spacetimedb.httpHandler(meHandler); +export const authLogout = spacetimedb.httpHandler((ctx, req) => + logoutHandler(ctx, req, localAuthHttp) +); +export const authRefresh = spacetimedb.httpHandler((ctx, req) => + refreshHandler(ctx, req, localAuthHttp) +); +export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => + googleStartHandler(ctx, req, localAuthHttp) +); +export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => + googleCallbackHandler(ctx, req, localAuthHttp) +); +export const authGithubStart = spacetimedb.httpHandler((ctx, req) => + githubStartHandler(ctx, req, localAuthHttp) +); +export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => + githubCallbackHandler(ctx, req, localAuthHttp) +); + +const forgotHandler = makeForgotPasswordHandler({ + sendMail: consoleSendMail, + appName: 'auth-ts', +}); +const verifyRequestHandler = makeEmailVerifyRequestHandler({ + sendMail: consoleSendMail, + appName: 'auth-ts', +}); +const verifyHandler = makeEmailVerifyHandler({ + successRedirect: '/?verified=1', +}); + +export const authPasswordForgot = spacetimedb.httpHandler(forgotHandler); +export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => + resetPasswordHandler(ctx, req, localAuthHttp) +); +export const authEmailVerifyRequest = + spacetimedb.httpHandler(verifyRequestHandler); +export const authEmailVerify = spacetimedb.httpHandler(verifyHandler); + +export const router = spacetimedb.httpRouter( + new Router() + .post('/auth/password/signup', authPasswordSignup) + .post('/auth/password/login', authPasswordLogin) + .post('/auth/session/refresh', authRefresh) + .get('/auth/me', authMe) + .post('/auth/logout', authLogout) + .get('/auth/google/start', authGoogleStart) + .get('/auth/google/callback', authGoogleCallback) + .get('/auth/github/start', authGithubStart) + .get('/auth/github/callback', authGithubCallback) + .post('/auth/password/forgot', authPasswordForgot) + .post('/auth/password/reset', authPasswordReset) + .post('/auth/email/verify-request', authEmailVerifyRequest) + .get('/auth/email/verify', authEmailVerify) +); diff --git a/spacetime-auth-ts/src/mounted/install.ts b/spacetime-auth-ts/src/mounted/install.ts new file mode 100644 index 00000000000..52011b606ae --- /dev/null +++ b/spacetime-auth-ts/src/mounted/install.ts @@ -0,0 +1,26 @@ +import { ScheduleAt } from 'spacetimedb'; +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import type spacetimedb from './index'; + +const ONE_SECOND_MICROS = 1_000_000n; + +type Schema = InferSchema; +type InstallCtx = ReducerCtx; + +export function installAuth(ctx: InstallCtx) { + rateLimit.installRateLimit(ctx.as.rateLimit); + + if (ctx.db.authAdminIdentity.identity.find(ctx.sender) == null) { + ctx.db.authAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + if (ctx.db.authSweeperTick.count() === 0n) { + ctx.db.authSweeperTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(60n * ONE_SECOND_MICROS), + }); + } +} diff --git a/spacetime-auth-ts/src/procedures.ts b/spacetime-auth-ts/src/procedures.ts new file mode 100644 index 00000000000..bf0c7883149 --- /dev/null +++ b/spacetime-auth-ts/src/procedures.ts @@ -0,0 +1,375 @@ +// Each impl supports both reducer ctx and procedure ctx. +import type { Timestamp } from 'spacetimedb'; +import { + Range, + t, + SenderError, + type InferTypeOfParams, +} from 'spacetimedb/server'; +import { + generateEs256Keypair, + fromPrivateKeyBytes, + privateKeyFromPem, + publicKeyFromPem, +} from './keys.ts'; +import { verifyJwt } from './jwt.ts'; +import { authAdminVerdict, denyIfNotAdmin } from './admin.ts'; +import type { + AuthProcedureCtx, + AuthReducerCtx, + AuthTransactionCtx, +} from './context.ts'; + +type AuthWriteCtx = AuthReducerCtx | AuthProcedureCtx; + +function withCtx(ctx: AuthWriteCtx, fn: (tx: AuthTransactionCtx) => T): T { + return 'withTx' in ctx ? ctx.withTx(fn) : fn(ctx); +} + +export const setAuthConfigParams = { + issuerUrl: t.string(), + baseUrl: t.option(t.string()), + cookieName: t.option(t.string()), + sessionTtlSeconds: t.option(t.u64()), + /** If omitted on first call, a fresh keypair is generated. */ + es256PrivateKeyPem: t.option(t.string()), + googleClientId: t.option(t.string()), + googleClientSecret: t.option(t.string()), + githubClientId: t.option(t.string()), + githubClientSecret: t.option(t.string()), +}; + +const DEFAULT_COOKIE_NAME = 'stdb_auth'; +const DEFAULT_SESSION_TTL_SECONDS = 60n * 60n * 24n * 7n; + +export function setAuthConfigImpl( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): void { + // Requires an admin row seeded by the database owner. Without this, any + // client could rotate the signing keys / overwrite OAuth secrets and forge + // sessions. The verdict is read inside a tx but the denial is thrown outside + // it (a SenderError thrown inside ctx.withTx surfaces as a fatal instance + // error, not a clean rejection). + const verdict = withCtx(ctx, tx => authAdminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + + withCtx(ctx, tx => { + const existing = tx.db.authConfig.singleton.find(true); + + let privateKeyPem: string; + let publicKeyPem: string; + let keyId: string; + + if (args.es256PrivateKeyPem) { + let raw: Uint8Array; + try { + raw = privateKeyFromPem(args.es256PrivateKeyPem); + } catch (e) { + throw new SenderError( + `auth.invalid_private_key_pem:${(e as Error).message}` + ); + } + const kp = fromPrivateKeyBytes(raw); + privateKeyPem = kp.privateKeyPem; + publicKeyPem = kp.publicKeyPem; + keyId = kp.kid; + } else if (existing) { + privateKeyPem = existing.es256PrivateKeyPem; + publicKeyPem = existing.es256PublicKeyPem; + keyId = existing.keyId; + } else { + const kp = generateEs256Keypair(ctx.random); + privateKeyPem = kp.privateKeyPem; + publicKeyPem = kp.publicKeyPem; + keyId = kp.kid; + } + + if (existing) { + tx.db.authConfig.singleton.update({ + ...existing, + issuerUrl: args.issuerUrl, + baseUrl: args.baseUrl ?? existing.baseUrl, + cookieName: args.cookieName ?? existing.cookieName, + sessionTtlSeconds: args.sessionTtlSeconds ?? existing.sessionTtlSeconds, + es256PrivateKeyPem: privateKeyPem, + es256PublicKeyPem: publicKeyPem, + keyId, + googleClientId: args.googleClientId ?? existing.googleClientId, + googleClientSecret: + args.googleClientSecret ?? existing.googleClientSecret, + githubClientId: args.githubClientId ?? existing.githubClientId, + githubClientSecret: + args.githubClientSecret ?? existing.githubClientSecret, + updatedAt: ctx.timestamp, + }); + return; + } + + tx.db.authConfig.insert({ + singleton: true, + issuerUrl: args.issuerUrl, + baseUrl: args.baseUrl ?? args.issuerUrl, + cookieName: args.cookieName ?? DEFAULT_COOKIE_NAME, + sessionTtlSeconds: args.sessionTtlSeconds ?? DEFAULT_SESSION_TTL_SECONDS, + es256PrivateKeyPem: privateKeyPem, + es256PublicKeyPem: publicKeyPem, + keyId, + googleClientId: args.googleClientId, + googleClientSecret: args.googleClientSecret, + githubClientId: args.githubClientId, + githubClientSecret: args.githubClientSecret, + updatedAt: ctx.timestamp, + }); + }); +} + +const SWEEP_BATCH = 500; + +export function authSweepImpl(ctx: AuthWriteCtx): void { + const nowMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + withCtx(ctx, tx => { + let n = 0; + for (const row of tx.db.authSession.expiresAt.filter( + new Range(undefined, { tag: 'excluded', value: ctx.timestamp }) + )) { + if (n >= SWEEP_BATCH) break; + if ((row.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + tx.db.authSession.delete(row); + n++; + } + } + for (const row of tx.db.authVerification.expiresAt.filter( + new Range(undefined, { tag: 'excluded', value: ctx.timestamp }) + )) { + if (n >= SWEEP_BATCH) break; + if ((row.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + tx.db.authVerification.delete(row); + n++; + } + } + for (const row of tx.db.authOauthState.expiresAt.filter( + new Range(undefined, { tag: 'excluded', value: ctx.timestamp }) + )) { + if (n >= SWEEP_BATCH) break; + if ((row.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + tx.db.authOauthState.delete(row); + n++; + } + } + }); +} + +export const revokeSessionParams = { sessionId: t.string() }; + +export function revokeSessionImpl( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): void { + // Admin action for revoking any user's session. Self-service revocation is + // revokeMySessionImpl (caller-scoped). Verdict inside tx, deny outside. + const verdict = withCtx(ctx, tx => authAdminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + + withCtx(ctx, tx => { + const s = tx.db.authSession.sessionId.find(args.sessionId); + if (s) tx.db.authSession.delete(s); + }); +} + +export const listMySessionsParams = {}; + +export interface MySessionSummary { + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; +} + +export function listMySessionsImpl( + ctx: AuthWriteCtx, + _args: Record +): { sessions: MySessionSummary[] } { + return withCtx(ctx, tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) return { sessions: [] }; + const userId = binding.userId; + const nowMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + const sessions: MySessionSummary[] = []; + for (const s of tx.db.authSession.userId.filter(userId)) { + if ((s.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) continue; + sessions.push({ + sessionId: s.sessionId, + expiresAt: s.expiresAt, + createdAt: s.createdAt, + ipAddress: s.ipAddress, + userAgent: s.userAgent, + isCurrent: false, + }); + } + sessions.sort((a, b) => + Number( + (b.createdAt.microsSinceUnixEpoch as bigint) - + (a.createdAt.microsSinceUnixEpoch as bigint) + ) + ); + return { sessions }; + }); +} + +export const revokeMySessionParams = { sessionId: t.string() }; + +export function revokeMySessionImpl( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): void { + withCtx(ctx, tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) throw new SenderError('auth.not_authenticated'); + const s = tx.db.authSession.sessionId.find(args.sessionId); + if (!s) return; + if (s.userId !== binding.userId) + throw new SenderError('auth.session_not_owned'); + tx.db.authSession.delete(s); + }); +} + +export const getPublicKeyPemParams = {}; + +export function getPublicKeyPemImpl( + ctx: AuthWriteCtx, + _args: Record +): { publicKeyPem: string; keyId: string; issuerUrl: string } { + return withCtx(ctx, tx => { + const cfg = tx.db.authConfig.singleton.find(true); + if (!cfg) throw new SenderError('auth.config_missing'); + return { + publicKeyPem: cfg.es256PublicKeyPem, + keyId: cfg.keyId, + issuerUrl: cfg.issuerUrl, + }; + }); +} + +/** Call once after each STDB connect. Idempotent. */ +export const linkConnectionParams = { sessionToken: t.string() }; + +const RETRY_FAILED_MSG = 'transaction retry failed again'; + +export function linkConnectionImpl( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): { userId: string } { + try { + return withCtx(ctx, tx => { + const cfg = tx.db.authConfig.singleton.find(true); + if (!cfg) throw new SenderError('auth.config_missing'); + + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const nowMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + const nowSec = Number(nowMicros / 1_000_000n); + const v = verifyJwt(pub, args.sessionToken, { + issuer: cfg.issuerUrl, + nowSeconds: nowSec, + }); + if (!v.ok) throw new SenderError(`auth.invalid_token:${v.reason}`); + + const userId = v.claims.sub; + if (!userId) throw new SenderError('auth.token_missing_sub'); + const sessionId = v.claims.jti; + if (!sessionId) throw new SenderError('auth.token_missing_session'); + + const session = tx.db.authSession.sessionId.find(sessionId); + if (!session || session.userId !== userId) + throw new SenderError('auth.session_not_found'); + if ((session.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + throw new SenderError('auth.session_expired'); + } + + const existing = tx.db.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (existing) { + tx.db.authConnectionBinding.stdbIdentity.update({ + ...existing, + userId, + linkedAt: ctx.timestamp, + }); + } else { + tx.db.authConnectionBinding.insert({ + stdbIdentity: ctx.sender, + userId, + linkedAt: ctx.timestamp, + }); + } + return { userId }; + }); + } catch (e: unknown) { + if (e instanceof Error && e.message.includes(RETRY_FAILED_MSG)) { + throw new SenderError('auth.link_busy_retry'); + } + throw e; + } +} + +export const unlinkConnectionParams = {}; + +export function unlinkConnectionImpl( + ctx: AuthWriteCtx, + _args: Record +): void { + withCtx(ctx, tx => { + const existing = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (existing) tx.db.authConnectionBinding.delete(existing); + }); +} + +const MAX_NAME_LEN = 64; +const MAX_IMAGE_LEN = 2048; + +/** Caller updates their own display name / image. Either field, when present, + * sets the row's value; pass an empty string to clear it (becomes none). */ +export const updateProfileParams = { + name: t.option(t.string()), + image: t.option(t.string()), +}; + +export function updateProfileImpl( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): void { + withCtx(ctx, tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) throw new SenderError('auth.not_authenticated'); + const user = tx.db.authUser.userId.find(binding.userId); + if (!user) throw new SenderError('auth.user_not_found'); + + const nextName = + args.name === undefined + ? user.name + : args.name.length === 0 + ? undefined + : args.name.trim(); + const nextImage = + args.image === undefined + ? user.image + : args.image.length === 0 + ? undefined + : args.image.trim(); + if (nextName !== undefined && nextName.length > MAX_NAME_LEN) { + throw new SenderError(`auth.name_too_long:max=${MAX_NAME_LEN}`); + } + if (nextImage !== undefined && nextImage.length > MAX_IMAGE_LEN) { + throw new SenderError(`auth.image_too_long:max=${MAX_IMAGE_LEN}`); + } + + tx.db.authUser.userId.update({ + ...user, + name: nextName, + image: nextImage, + updatedAt: ctx.timestamp, + }); + }); +} diff --git a/spacetime-auth-ts/src/rate_limit.ts b/spacetime-auth-ts/src/rate_limit.ts new file mode 100644 index 00000000000..34984674a87 --- /dev/null +++ b/spacetime-auth-ts/src/rate_limit.ts @@ -0,0 +1,110 @@ +import type { Request, SyncResponse } from 'spacetimedb/server'; +import { + consumeRateLimit, + type RateLimitResult, +} from '@spacetimedb/rate-limit/submodule'; +import { errorResponse } from './handlers/_helpers.ts'; +import { clientKey, type TrustedProxyHeader } from './request-trust.ts'; +import type { AuthHandlerCtx } from './context.ts'; +export { + clientKey, + type AuthHttpOptions, + type TrustedProxyHeader, +} from './request-trust.ts'; + +export interface AuthRateLimitPolicy { + scope: string; + limit: number; + windowSeconds: number; +} + +export const AUTH_RATE_LIMITS = { + passwordSignup: { + scope: 'auth.password.signup', + limit: 5, + windowSeconds: 3600, + }, + passwordLoginIp: { + scope: 'auth.password.login.ip', + limit: 30, + windowSeconds: 300, + }, + passwordLoginEmail: { + scope: 'auth.password.login.email', + limit: 10, + windowSeconds: 300, + }, + passwordForgotIp: { + scope: 'auth.password.forgot.ip', + limit: 5, + windowSeconds: 3600, + }, + passwordForgotEmail: { + scope: 'auth.password.forgot.email', + limit: 3, + windowSeconds: 3600, + }, + passwordReset: { scope: 'auth.password.reset', limit: 5, windowSeconds: 900 }, + oauthStart: { scope: 'auth.oauth.start', limit: 30, windowSeconds: 300 }, + emailVerifyRequest: { + scope: 'auth.email.verify_request', + limit: 5, + windowSeconds: 3600, + }, +} satisfies Record; + +function normalizePart(value: string): string { + return value.toLowerCase().trim().slice(0, 256); +} + +export function rateLimitKey( + policy: AuthRateLimitPolicy, + actor: string +): string { + return `${policy.scope}:${normalizePart(actor)}`; +} + +export function rateLimitResponse(result: RateLimitResult): SyncResponse { + return errorResponse('rate_limited', 429, { + 'retry-after': String(result.retryAfterSeconds), + 'x-ratelimit-limit': String(result.limit), + 'x-ratelimit-remaining': String(result.remaining), + 'x-ratelimit-reset': String( + Number((result.resetAt.microsSinceUnixEpoch as bigint) / 1_000_000n) + ), + }); +} + +export function enforceRateLimits( + ctx: AuthHandlerCtx, + _req: Request, + checks: Array<{ policy: AuthRateLimitPolicy; actor: string }> +): SyncResponse | null { + let blocked: RateLimitResult | null = null; + for (const check of checks) { + const result = ctx.as.rateLimit.withTx(tx => + consumeRateLimit(tx, { + key: rateLimitKey(check.policy, check.actor), + scope: check.policy.scope, + limit: check.policy.limit, + windowSeconds: check.policy.windowSeconds, + }) + ); + if (!result.allowed) { + blocked = result; + break; + } + } + return blocked ? rateLimitResponse(blocked) : null; +} + +export function enforceIpRateLimit( + ctx: AuthHandlerCtx, + req: Request, + policy: AuthRateLimitPolicy, + trustedProxyHeader?: TrustedProxyHeader +): SyncResponse | null { + const key = clientKey(req, trustedProxyHeader); + if (!key) return null; + return enforceRateLimits(ctx, req, [{ policy, actor: `ip:${key}` }]); +} diff --git a/spacetime-auth-ts/src/request-trust.ts b/spacetime-auth-ts/src/request-trust.ts new file mode 100644 index 00000000000..dc507700adb --- /dev/null +++ b/spacetime-auth-ts/src/request-trust.ts @@ -0,0 +1,74 @@ +import type { Request } from 'spacetimedb/server'; + +export type TrustedProxyHeader = + | 'cf-connecting-ip' + | 'x-real-ip' + | 'x-forwarded-for'; + +export interface AuthHttpOptions { + /** Header set by a trusted proxy after it removes any client-supplied value. */ + trustedProxyHeader?: TrustedProxyHeader; + /** Defaults to true. Set false only for local HTTP development. */ + secureCookies?: boolean; +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function firstHeaderValue(value: string | null): string | undefined { + if (!value) return undefined; + const first = value.split(',')[0]?.trim(); + return first && first.length <= 128 ? first : undefined; +} + +export function clientKey( + req: Request, + trustedProxyHeader?: TrustedProxyHeader +): string | undefined { + if (!trustedProxyHeader) return undefined; + return firstHeaderValue(req.headers.get(trustedProxyHeader)); +} + +export function userAgent(req: Request): string | undefined { + const value = req.headers.get('user-agent')?.trim(); + return value && value.length <= 512 ? value : undefined; +} + +export function shouldUseSecureCookies(secureCookies?: boolean): boolean { + return secureCookies !== false; +} + +export function safeRedirectPath( + value: string | undefined +): string | undefined { + if (value === undefined || value.length === 0 || value.length > 2048) + return undefined; + if (!value.startsWith('/') || value.startsWith('//')) return undefined; + if ( + value.includes('\\') || + value.includes('#') || + hasControlCharacter(value) + ) { + return undefined; + } + try { + const decoded = decodeURIComponent(value); + if ( + !decoded.startsWith('/') || + decoded.startsWith('//') || + decoded.includes('\\') || + decoded.includes('#') || + hasControlCharacter(decoded) + ) { + return undefined; + } + } catch { + return undefined; + } + return value; +} diff --git a/spacetime-auth-ts/src/submodule.ts b/spacetime-auth-ts/src/submodule.ts new file mode 100644 index 00000000000..2db6bda5508 --- /dev/null +++ b/spacetime-auth-ts/src/submodule.ts @@ -0,0 +1,61 @@ +export { default } from './mounted/index'; +export { installAuth } from './mounted/install'; +export { + authEmailVerify, + authEmailVerifyRequest, + authGithubCallback, + authGithubStart, + authGoogleCallback, + authGoogleStart, + authLogout, + authMe, + authPasswordForgot, + authPasswordLogin, + authPasswordReset, + authPasswordSignup, + authRefresh, + auth_sweep, + get_auth_public_key, + link_connection, + list_my_sessions, + myAuthUser, + revoke_my_session, + revoke_session, + set_auth_config, + unlink_connection, + update_profile, + whoami, +} from './mounted/index'; + +export { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + linkConnectionImpl, + unlinkConnectionParams, + updateProfileParams, + revokeSessionParams, + listMySessionsParams, + revokeMySessionParams, + passwordSignupHandler, + parseCookies, + passwordLoginHandler, + meHandler, + logoutHandler, + refreshHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + makeEmailVerifyRequestHandler, + makeEmailVerifyHandler, + getCallerUserId, + publicKeyFromPem, + verifyJwt, + type AuthHttpOptions, + type TrustedProxyHeader, + type SendMailFn, + type MailParams, +} from './index'; diff --git a/spacetime-auth-ts/src/tables.ts b/spacetime-auth-ts/src/tables.ts new file mode 100644 index 00000000000..b7cad8aeda3 --- /dev/null +++ b/spacetime-auth-ts/src/tables.ts @@ -0,0 +1,137 @@ +import { table, t } from 'spacetimedb/server'; + +export const authUserRow = { + userId: t.string().primaryKey(), + email: t.string().unique(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const authSessionRow = { + sessionId: t.string().primaryKey(), + userId: t.string().index(), + token: t.string().unique(), + expiresAt: t.timestamp().index(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + createdAt: t.timestamp(), +}; + +// providerId: 'password' | 'google' | 'github'. providerAccountId: email or provider sub. +export const authAccountRow = { + accountId: t.string().primaryKey(), + userId: t.string().index(), + providerId: t.string().index(), + providerAccountId: t.string().index(), + passwordHash: t.option(t.string()), + accessToken: t.option(t.string()), + refreshToken: t.option(t.string()), + accessTokenExpiresAt: t.option(t.timestamp()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const authVerificationRow = { + verificationId: t.string().primaryKey(), + identifier: t.string().index(), + value: t.string().unique(), + purpose: t.string(), + expiresAt: t.timestamp().index(), + createdAt: t.timestamp(), +}; + +export const authOauthStateRow = { + state: t.string().primaryKey(), + provider: t.string(), + codeVerifier: t.string(), + redirectTo: t.string(), + expiresAt: t.timestamp().index(), + createdAt: t.timestamp(), +}; + +// Private singleton; populated by setAuthConfig. +export const authConfigRow = { + singleton: t.bool().primaryKey(), + issuerUrl: t.string(), + baseUrl: t.string(), + cookieName: t.string(), + sessionTtlSeconds: t.u64(), + es256PrivateKeyPem: t.string(), + es256PublicKeyPem: t.string(), + keyId: t.string(), + googleClientId: t.option(t.string()), + googleClientSecret: t.option(t.string()), + githubClientId: t.option(t.string()), + githubClientSecret: t.option(t.string()), + updatedAt: t.timestamp(), +}; + +// Maps STDB Identity to auth_user; populated by link_connection. +export const authConnectionBindingRow = { + stdbIdentity: t.identity().primaryKey(), + userId: t.string().index(), + linkedAt: t.timestamp(), +}; + +// Operator allowlist. Seeded by the database owner; privileged calls +// (re-config, revoke_session) must come from a seeded admin. +export const authAdminIdentityRow = { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), +}; + +// Scheduled-tick row: callers define their own scheduled table pointing to auth_sweep. + +export const authUserTable = table( + { name: 'auth_user', public: false }, + authUserRow +); + +export const authSessionTable = table( + { name: 'auth_session', public: false }, + authSessionRow +); + +export const authAccountTable = table( + { name: 'auth_account', public: false }, + authAccountRow +); + +export const authVerificationTable = table( + { name: 'auth_verification', public: false }, + authVerificationRow +); + +export const authOauthStateTable = table( + { name: 'auth_oauth_state', public: false }, + authOauthStateRow +); + +export const authConfigTable = table( + { name: 'auth_config', public: false }, + authConfigRow +); + +export const authConnectionBindingTable = table( + { name: 'auth_connection_binding', public: false }, + authConnectionBindingRow +); + +export const authAdminIdentityTable = table( + { name: 'auth_admin_identity', public: false }, + authAdminIdentityRow +); + +export const authTables = { + authUser: authUserTable, + authSession: authSessionTable, + authAccount: authAccountTable, + authVerification: authVerificationTable, + authOauthState: authOauthStateTable, + authConfig: authConfigTable, + authConnectionBinding: authConnectionBindingTable, + authAdminIdentity: authAdminIdentityTable, +}; diff --git a/spacetime-auth-ts/src/types.ts b/spacetime-auth-ts/src/types.ts new file mode 100644 index 00000000000..92a6c1e7abb --- /dev/null +++ b/spacetime-auth-ts/src/types.ts @@ -0,0 +1,18 @@ +import type { Infer } from 'spacetimedb/server'; +import type { + authUserRow, + authSessionRow, + authAccountRow, + authVerificationRow, + authOauthStateRow, + authConfigRow, + authConnectionBindingRow, +} from './tables.ts'; + +export type AuthUser = Infer; +export type AuthSession = Infer; +export type AuthAccount = Infer; +export type AuthVerification = Infer; +export type AuthOauthState = Infer; +export type AuthConfig = Infer; +export type AuthConnectionBinding = Infer; diff --git a/spacetime-auth-ts/tsconfig.json b/spacetime-auth-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-auth-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-cron-ts/DESIGN.md b/spacetime-cron-ts/DESIGN.md new file mode 100644 index 00000000000..fe463fd5a3a --- /dev/null +++ b/spacetime-cron-ts/DESIGN.md @@ -0,0 +1,351 @@ +# Cron architecture + +This document defines the runtime invariants and transaction behavior for +`@spacetimedb/cron`. + +## Design goals + +The package provides: + +1. Stable job identity in ordinary database state. +2. Calendar scheduling with time zones and daylight-saving transitions. +3. Native fixed intervals. +4. Statically registered reducer and procedure handlers. +5. Typed arguments stored with each configured job. +6. Rollback of partial reducer writes when a handler fails. +7. Generation-safe rescheduling and cancellation. +8. Detectable and repairable loss of volatile recovery work. +9. Bounded operational history. +10. A direct path to nested transactions when the platform supports them. + +The current reducer failure path uses +`volatile_nonatomic_schedule_immediate`. That host API is an unstable, +best-effort bridge for code that needs rollback plus follow-up work before +SpacetimeDB supports nested transactions. The invariant reconciler bounds its +crash limitation without adding another application-work hop. + +## Tables + +### `cron_job` + +One private row represents each configured job. The row contains the schedule, +typed arguments, enabled state, failure policy, generation, fire count, last +outcome time, next logical occurrence, and disable reason. + +The job name is the stable primary key. Scheduling, rescheduling, disabling, +and re-enabling preserve the row. + +The argument column is a tagged union generated from the static handles passed +to `createCron()`. Argumentless jobs use a unit payload. Rescheduling replaces +the schedule and argument value atomically. + +### `cron_jobs` view + +The optional public anonymous view projects operational fields from +`cron_job`. It omits the argument column so clients can subscribe to schedule +and health state without receiving private application payloads. Detailed +disablement errors also remain private. The view maps them to stable reason +codes for operator disablement, failure thresholds, lost-fire thresholds, +invalid schedule state, and otherwise unspecified disablement. + +### `_fire` + +Each statically declared job owns one schedule table bound directly to its +`_cron` reducer or procedure. + +An enabled job owns exactly one row in its fire table: + +- Calendar jobs use a chain of one-shot `ScheduleAt.time` rows. +- Fixed-rate jobs use one persistent native `ScheduleAt.interval` row. + +The row carries the job generation. Calendar rows also carry `targetAt`, the +logical occurrence represented by the trigger. The physical `scheduledAt` +may be an earlier checkpoint when the target exceeds the host timer horizon. +The optional `recovery` field is empty in stored rows. A volatile invocation +sets it to the failed sequence, logical occurrence, and bounded error. + +### `cron_run` + +The run table contains completed `Ok` and `Failed` outcomes. Each row carries +the stable invocation ID, job name, generation, sequence, logical scheduled +time, completion time, and bounded error text. + +History is pruned synchronously by per-job sequence. No sweeper or retention +schedule is required. + +### `cron_reconcile_tick` + +When `reconcileEverySeconds` is configured, this schedule table contains one +native interval row bound to `cron_reconcile`. The sweep scans the statically +registered jobs and repairs broken fire invariants. It is recovery machinery, +not run-history retention. + +## Registration model + +`cronTable()` creates a typed job handle. `createCron()` builds `cron_job`, +`cron_run`, and one fire table for each handle. `cronReducer()` or +`cronProcedure()` binds one application handler directly to that job's fire +table. The reducer wrapper also handles volatile recovery calls for that job. +`cron.reconcileReducer()` registers the optional interval reconciler. +`cron.publicViews()` registers the optional sanitized job-state view. + +An argument-bearing `cronTable()` carries its SpacetimeDB type builder. +`createCron()` combines those builders into the private `CronJobArgsValue` +union. Variants are ordered by job name so changing the order of handles passed +to `createCron()` does not change the generated schema. + +Every job must register exactly one handler. Scheduling rejects a core with any +missing handler. When `reconcileEverySeconds` is configured, the consumer also +exports `cron.reconcileReducer()`. Registration and construction reject missing +handlers, duplicate handlers, duplicate jobs, foreign handles, multiple cores, +invalid names, and table-key collisions. + +The package reserves: + +- `cron_job` +- `cron_run` +- `cron_jobs` +- `cron_reconcile` +- `cron_reconcile_tick` +- every `_fire` table +- every `_cron` scheduled function + +The factory receives `table`, `t`, `ScheduleAt`, `Timestamp`, and +`SenderError` from the consumer. Table builders contain SDK-private +registration symbols, so using the consumer's SDK values keeps all generated +tables on the same SDK instance as the host schema. + +## Scheduling and generations + +`schedule()` validates the schedule, time zone, interval, failure policy, and +argument presence before changing state. It ensures the optional reconciliation +interval exists, repairs broken fire invariants for configured jobs, and then +performs the requested state change in the same transaction: + +1. Delete the current fire row, if one exists. +2. Increment the job generation. +3. Upsert `cron_job` with the new schedule and typed arguments. +4. Insert one new fire row. +5. Store the logical next occurrence. + +`unschedule()` runs the same opportunistic reconciliation before deleting the +target fire row, incrementing its generation, disabling it, and retaining its +job state and history. + +Every fire row carries the generation that created it. Normal and recovery +invocations compare that generation with the current job row. Delayed work from +an earlier configuration cannot restore or modify a replacement schedule. + +## Reducer execution + +A reducer job executes in one scheduled transaction on the success path: + +1. Verify the job exists, is enabled, and matches the fire generation. +2. Verify the tagged argument variant matches the job. +3. For a calendar job, delete the consumed row and insert its successor. +4. Build the invocation metadata and read the typed argument value. +5. Execute the application handler. +6. Record `Ok`, update health, and prune history. + +The successor, application writes, job health, and run record commit together. +A successful calendar fire therefore advances atomically with its application +work. Native interval rows persist without rearming. + +Reducers must complete synchronously. Returning a thenable is treated as a +handler failure. + +## Reducer failure and volatile recovery + +SpacetimeDB reducers do not currently support nested transactions or +savepoints. If application work fails after making writes, those writes must be +rolled back. Rethrowing the error accomplishes that, but also rolls back the +calendar successor inserted earlier in the same transaction. + +The middleware uses this temporary recovery sequence: + +1. Catch the handler error. +2. Copy the fire argument and set its private `recovery` field to the sequence, + scheduled time, and bounded error. +3. Serialize that row with the fire table's SDK row serializer and submit it to + the same `_cron` reducer through + `volatile_nonatomic_schedule_immediate`. +4. Rethrow the original error. +5. Let the fire transaction roll back. +6. Run the same reducer in a fresh transaction. Its recovery branch does not + call the application handler. +7. Restore the calendar chain, record `Failed`, update failure state, and apply + automatic disablement. + +The recovery branch validates the sender, job name, generation, and expected +sequence. It ignores stale or duplicate work. The explicit payload also works +for native interval jobs, whose schedule row remains present after failure. + +The direct host ABI remains isolated behind the recovery adapter. The package +uses the SDK fire-row builder and `BinaryWriter` for BSATN serialization. A +future SDK wrapper can replace `sys-abi.d.ts` and the direct host import without +changing the public cron API. + +### Volatile crash gap + +The volatile call is best effort and is not stored in the commit log. A process +crash, uncatchable trap, or lost volatile message can prevent the recovery +invocation from executing. For a failed calendar reducer, that can temporarily +leave an enabled job without a pending fire. A native interval row persists +independently, although its failure outcome can still be lost. + +The stable `cron_job` row makes this state machine-detectable. Every scheduling +or cancellation operation opportunistically scans configured jobs. If +`reconcileEverySeconds` is configured, one native interval sweep performs the +same scan at a bounded cadence. An enabled job with no valid current-generation +fire is disarmed, rearmed from the current transaction time, and assigned one +`Failed` run with error `lost_fire`. Normal failure policy, history pruning, and +automatic disablement apply to that outcome. + +The reconciler also treats a current-generation fire with the wrong trigger +shape as lost. A calendar job requires `targetAt`; an interval job must not have +it. This prevents a malformed row from satisfying the invariant while being +unable to execute correctly. + +The interval sweep is optional. Without it, repair occurs on the next +`schedule()` or `unschedule()` call. With it, the crash gap is bounded by the +configured interval and scheduler availability. This remains a temporary +best-effort design rather than crash-proof execution. + +### Recovery dispatch + +The private `recovery` field determines which branch runs. Recovery does not +depend on schedule-row presence, sender heuristics, or cleanup timing. This is +required for native interval rows because they persist after a failed fire. + +Calendar recovery replaces the visible fire state before it inserts the next +occurrence. Generation and sequence guards make stale or duplicate recovery +messages no-ops. + +## Procedure execution + +Procedures are not single transactions, so they do not need volatile recovery +to roll back application database work. A scheduled procedure executes in +three phases: + +1. A `withTx` callback verifies the generation, advances a calendar chain, + snapshots the typed arguments, and reserves the invocation sequence. +2. The handler performs procedure work. +3. A second `withTx` callback records `Ok` or `Failed`, updates health, and + prunes history. + +The first phase commits before external work begins, so a process failure during +a procedure does not remove the next calendar fire. It can lose the current run +record or leave an external operation with an unknown outcome. Handlers should +use `CronInvocation.id` as an idempotency key when the external service +supports one. + +No mutable value captured outside a `withTx` callback influences that +transaction's database decisions. The callback returns the prepared invocation +and argument snapshot directly. + +## Calendar chains and checkpoints + +Cron expressions represent calendar occurrences and cannot be reduced to fixed +durations. After an actual calendar fire, the middleware computes the first +occurrence strictly after the transaction timestamp. + +SpacetimeDB 2.8 has a finite timer horizon. A valid expression such as February +29 can produce a gap beyond that horizon. The package schedules a checkpoint at +most 365 days away while retaining the logical target in `targetAt`. A +checkpoint that fires before the target inserts another bounded trigger without +executing application work. + +This repeats until the logical occurrence is within range. + +## Downtime and time zones + +An overdue calendar row fires once when the host resumes. The successor is +computed after the recovery timestamp, so intermediate missed occurrences are +skipped. + +Occurrence calculation uses `cron-parser` 5.x with an IANA time zone. Tests +cover spring-forward gaps, fall-back repetition, strictly-after behavior, +impossible dates, and date bounds. + +Native interval rows follow SpacetimeDB interval behavior. +`cron_job.nextRunAt` is an estimate updated after each interval fire or +failure recovery. + +## Failure policy + +`consecutiveFailures` counts recorded `Failed` outcomes for the active +generation. `Ok` resets the counter. When a positive `maxFailures` threshold +is reached, the package removes the fire row, disables the job, and stores a +bounded reason. + +If volatile recovery work is lost, the later reconciler records `lost_fire` as a +normal `Failed` outcome. It advances the failure counter and can trigger the +same automatic disable policy. + +## Storage bounds + +The default history cap is five completed runs per job. The configured range is +0 through 1,000. + +Errors and disable reasons are capped at 1,024 characters. Job names are +lowercase snake_case with a 48-character limit. Interval values are whole +seconds from 1 through 31,536,000. + +## Security + +Scheduling helpers perform state transitions and validation. Host reducers +remain responsible for application authorization. + +Every `_cron` function and the scheduled `cron_reconcile` reducer +accept calls only when the sender is the database identity. Generation and +sequence checks prevent stale or duplicate recovery messages from changing +current job state. + +`cron_job` is always private. Applications can register the public +`cron_jobs` view to expose operational state without arguments. +`publicTables: true` exposes `cron_run`, the per-job fire tables, and the +optional reconciliation tick, including run error strings. + +## Nested-transaction migration + +Nested transactions are the intended long-term replacement for the volatile +recovery path. With platform support, reducer execution can become: + +1. Advance the calendar chain in the parent transaction. +2. Run application work in a child transaction. +3. Commit the child on success or roll it back on failure. +4. Record the outcome in the parent transaction. +5. Commit the parent with the successor and outcome. + +That model removes the volatile ABI and its crash gap without reintroducing a +second schedule table or scheduler hop. The public `cronTable()`, +`schedule()`, and handler APIs do not need to change. + +## Verification + +The package test suite covers: + +- parser boundaries and time zones +- daylight-saving transitions +- one-catch-up behavior +- sparse-expression checkpointing +- input limits +- module schema builds +- typed reducer and procedure arguments +- argument replacement +- reducer rollback +- same-reducer volatile recovery and failure accounting +- calendar and native interval recovery +- opportunistic `lost_fire` repair +- interval-sweep `lost_fire` repair +- reducer and procedure automatic disablement +- generation changes +- rescheduling and cancellation +- bounded history +- scheduled-function authorization +- procedure calendar-chain continuity and at-most-one catch-up across host restart +- example module publication + +Release verification also runs repository lint, formatting, typechecking, +module builds, client generation, consumer installation, and package tarball +inspection. diff --git a/spacetime-cron-ts/LICENSE.txt b/spacetime-cron-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-cron-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-cron-ts/README.md b/spacetime-cron-ts/README.md new file mode 100644 index 00000000000..f1ca1bb0921 --- /dev/null +++ b/spacetime-cron-ts/README.md @@ -0,0 +1,389 @@ +# @spacetimedb/cron + +Calendar and interval scheduling for SpacetimeDB TypeScript modules. + +Each job has stable database state, one per-job schedule table, a statically +registered handler, typed arguments, and bounded run history. Calendar +schedules support IANA time zones and daylight-saving transitions. Reducer +failures use SpacetimeDB's temporary volatile recovery mechanism until nested +transactions are available. + +## Requirements + +- SpacetimeDB CLI 2.8.3 or later +- `spacetimedb` npm package 2.8.3 or later within the 2.x release line +- Node.js 20 or later for package tooling + +## Install + +```bash +npm install @spacetimedb/cron spacetimedb@^2.8.3 +``` + +`spacetimedb` is a peer dependency. Use the same SDK version for the application module and this package. + +For the complete install, build, and publish workflow, see the repository's +[Getting started guide](https://spacetimedb.com/docs/). + +## Usage + +### Integrate into an application + +```ts +import { + SenderError, + schema, + table, + t, + toCamelCase, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, +} from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { spacetimeCron } from '@spacetimedb/cron'; + +const { cronTable, createCron, schedule, unschedule } = spacetimeCron({ + table, + t, + toCamelCase, + ScheduleAt, + Timestamp, + SenderError, +}); + +const dailyReport = cronTable({ + name: 'daily_report', + args: t.object('DailyReportCronArgs', { + workspaceId: t.u64(), + format: t.string(), + }), +}); +const heartbeat = cronTable({ name: 'heartbeat' }); +const refreshCatalog = cronTable({ name: 'refresh_catalog' }); + +const cron = createCron([dailyReport, heartbeat, refreshCatalog], { + publicTables: true, + reconcileEverySeconds: 300, +}); + +const report = table( + { name: 'report', public: true }, + { + id: t.u64().primaryKey().autoInc(), + generatedAt: t.timestamp(), + } +); + +const spacetimedb = schema({ ...cron.tables, report }); +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx; +type Proc = ProcedureCtx; + +export const generateReport = dailyReport.cronReducer( + spacetimedb, + (ctx: Tx, args, invocation) => { + ctx.db.report.insert({ + id: 0n, + generatedAt: invocation.scheduledFor, + }); + console.log( + `Generating ${args.format} report for workspace ${args.workspaceId}` + ); + } +); + +export const beat = heartbeat.cronReducer(spacetimedb, (_ctx: Tx) => { + // Perform deterministic database work here. +}); + +export const refresh = refreshCatalog.cronProcedure( + spacetimedb, + (ctx: Proc, invocation) => { + // Use invocation.id as the idempotency key for an external request. + ctx.http.fetch('https://example.com/catalog'); + } +); + +export const cronReconcile = cron.reconcileReducer(spacetimedb); +export const { jobs: cronJobs } = cron.publicViews(spacetimedb); + +export const init = spacetimedb.init(ctx => { + schedule(ctx, dailyReport, '0 9 * * 1-5', { + timezone: 'America/New_York', + maxFailures: 3, + args: { workspaceId: 42n, format: 'summary' }, + }); + schedule(ctx, heartbeat, { everySeconds: 30 }); + schedule(ctx, refreshCatalog, '0 */15 * * * *'); +}); +``` + +`init` seeds schedules for a fresh database. Runtime schedule changes remain database state across module publishes. + +## API + +### Registering jobs + +Create one handle for each statically known job: + +```ts +const cleanup = cronTable({ name: 'cleanup' }); +``` + +Declare a SpacetimeDB type when a job needs durable arguments: + +```ts +const archiveWorkspace = cronTable({ + name: 'archive_workspace', + args: t.object('ArchiveWorkspaceCronArgs', { + workspaceId: t.u64(), + retainDays: t.u32(), + }), +}); +``` + +The handle carries the inferred argument type through `schedule()`, +`cronReducer()`, and `cronProcedure()`. The argument builder may be any +SpacetimeDB type, although a named `t.object()` gives most jobs the clearest +call site and database schema. + +Job names use lowercase snake_case and may contain up to 48 characters. Pass +every handle to one `createCron()` call, register exactly one reducer or +procedure for each handle. `schedule()` rejects a configuration with a missing +handler. When `reconcileEverySeconds` is configured, export +`cron.reconcileReducer()`. Applications that expose cron status export the +`jobs` view returned by `cron.publicViews()`. + +Each reducer job handles its normal fires and its internal recovery calls. A +handler failure schedules the same job reducer with a private recovery payload. +The second invocation records the failure and restores calendar scheduling in a +fresh transaction. It does not call the application handler. Procedure jobs use +their separate transaction flow and do not use volatile recovery. + +Use `cronReducer` for deterministic database work. The handler receives the consumer module's typed reducer context and a `CronInvocation`: + +```ts +export const runCleanup = cleanup.cronReducer( + spacetimedb, + (ctx: Tx, invocation) => { + console.log(invocation.id); + // Database writes commit together when the handler succeeds. + } +); +``` + +Use `cronProcedure` for HTTP requests and other procedure capabilities: + +```ts +export const syncRemote = remoteSync.cronProcedure( + spacetimedb, + (ctx: Proc, invocation) => { + sendRequest({ idempotencyKey: invocation.id }); + } +); +``` + +Handlers complete synchronously. SpacetimeDB procedure APIs, including `ctx.http.fetch` and `ctx.withTx`, expose synchronous module calls. + +Argument-bearing handlers receive their typed payload before the invocation +metadata: + +```ts +export const runArchive = archiveWorkspace.cronReducer( + spacetimedb, + (ctx: Tx, args, invocation) => { + archiveRows(ctx, args.workspaceId, args.retainDays); + console.log(invocation.id); + } +); +``` + +### Scheduling and cancellation + +`schedule()` first repairs any enabled jobs with missing triggers. It then +creates or replaces the requested schedule, clears failure state, increments +the job generation, and enables the job. + +```ts +schedule(ctx, cleanup, '30 2 * * *', { timezone: 'UTC' }); +schedule(ctx, cleanup, { everySeconds: 300 }); + +schedule(ctx, archiveWorkspace, '0 3 * * *', { + timezone: 'UTC', + args: { workspaceId: 42n, retainDays: 90 }, +}); +``` + +Arguments are required when scheduling an argument-bearing job. Rescheduling +replaces the schedule, arguments, generation, and pending fire atomically. A +handler receives the argument value read at the start of its fire. + +Cron expressions accept five fields or six fields when seconds are included. Fixed intervals accept whole seconds from 1 through 31,536,000. + +`unschedule()` runs the same opportunistic repair before removing the target +fire, incrementing its generation, and leaving job and history rows available +for inspection. + +```ts +unschedule(ctx, cleanup); +``` + +These helpers perform scheduling operations. Application reducers remain responsible for authorization. + +### Database state + +The package adds these shared tables: + +| Table | Purpose | +| ---------- | ------------------------------------------------------------------- | +| `cron_job` | Private schedule, typed arguments, generation, health, and next run | +| `cron_run` | Completed invocation identity, outcome, and bounded history | + +When `reconcileEverySeconds` is set, the package also adds +`cron_reconcile_tick`. It contains one native interval row that periodically +repairs enabled jobs with missing triggers. + +Each job receives one `_fire` schedule table bound directly to its +`_cron` reducer or procedure. An enabled job owns exactly one row in +that table. Calendar jobs replace one-shot rows after each fire. Fixed-rate +jobs retain one native interval row. + +The package owns the shared names above, the public view name `cron_jobs`, every +`_fire` table, and every `_cron` scheduled function. +Enabling periodic reconciliation also reserves `cron_reconcile` and +`cron_reconcile_tick`. Consumer modules should keep those database function, +table, and view names available for cron. + +`cron_job` is always private. Registering `cron.publicViews()` exposes +`cron_jobs`, a subscribable projection of job state that omits typed arguments +and detailed failure text. Its optional `disabledReason` is one of +`disabled_by_operator`, `failure_threshold_reached`, +`lost_fire_threshold_reached`, `invalid_schedule_state`, or `disabled`. +`publicTables` defaults to `false`; enabling it exposes each per-job fire table +and `cron_run`, including run error details. The fire-table `recovery` column is +internal. Stored schedule rows leave it empty. + +For calendar jobs, `cron_jobs.nextRunAt` and the job's fire-table `targetAt` +identify the logical next occurrence. For native interval jobs, `nextRunAt` is +an estimate based on the most recent fire. The database owner and module +reducers can inspect the private `cron_job` table directly. + +### Execution model + +Each fire table is bound directly to one statically registered handler. + +For reducer jobs, the middleware rearms a calendar schedule before calling the +handler. On success, the successor, application writes, job health, and run +record commit in one transaction. On failure, the middleware serializes the +fire row with an internal recovery payload, schedules the same +`_cron` reducer through +`volatile_nonatomic_schedule_immediate`, and rethrows. The fire transaction +rolls back, including partial application writes. The recovery invocation runs +in a fresh transaction. It validates the database caller, generation, and +sequence before it records the failure. Calendar recovery replaces the pending +fire. Native interval rows persist, so interval recovery keeps that row and +updates job health. + +The explicit recovery payload distinguishes a recovery call from a normal fire. +The implementation does not infer the call type from schedule-row presence. +This gives calendar and interval jobs the same failure path and prevents the +application handler from running again during recovery. + +The volatile call is best effort and is not persisted. A process crash, +uncatchable trap, or lost message can temporarily leave an enabled calendar job +without a fire. The package detects that broken invariant during every +`schedule()` and `unschedule()` operation. Set `reconcileEverySeconds` to add a +low-frequency native interval sweep: + +```ts +const cron = createCron(jobs, { reconcileEverySeconds: 300 }); + +// Export after registering the job handlers. +export const cronReconcile = cron.reconcileReducer(spacetimedb); +``` + +Repair removes any stale trigger, inserts a valid current-generation trigger, +and records one `Failed` run with error `lost_fire`. The normal failure counter, +history cap, and automatic disable policy apply. Without the optional sweep, +repair occurs on the next management operation. With it, detection is bounded +by the configured interval and scheduler availability. + +This remains a temporary best-effort design until nested transactions are +available. Native interval job rows remain scheduled independently. + +Procedure jobs secure the next calendar fire in a committed transaction, run +the procedure work, then record the outcome in another transaction. A process +failure during external work can lose the run record, but it does not remove +the next calendar fire. `CronInvocation.id` is stable and should be used as an +external idempotency key. + +`maxFailures` counts consecutive recorded failures. A positive threshold +disables the job and stores the reason. A successful invocation resets the +counter. + +### Argument schema changes + +Job names and argument builders are part of the module's database schema. +Adding a new job adds a new internal union variant. Changing the argument +builder for an existing job requires a SpacetimeDB schema migration. For an +incompatible payload change, a new job name provides a clean version boundary. + +### Scheduling behavior + +- The next calendar occurrence is computed strictly after the dispatch timestamp. +- An overdue one-shot trigger produces one catch-up invocation. Intermediate missed occurrences are skipped. +- Spring-forward and fall-back behavior follows `cron-parser` 5.x and is covered by tests. +- Sparse expressions use internal checkpoint triggers so valid occurrences beyond the host timer horizon remain scheduled. +- Fixed intervals use SpacetimeDB native `ScheduleAt.interval` rows. + +Scheduled functions execute through SpacetimeDB's scheduler. A long-running procedure delays other scheduled work in the same module, so procedure handlers should finish promptly. + +### Run history + +`historyCap` defaults to five completed records per job and accepts values from +0 through 1,000. + +Run statuses are: + +- `Ok`: work completed successfully +- `Failed`: the handler returned an error + +### Parser exports + +```ts +import { + isValidTimezone, + nextFireAfter, + parseCronExpression, +} from '@spacetimedb/cron/parser'; +``` + +## Testing + +```bash +pnpm test +pnpm lint +pnpm typecheck +pnpm run test:recovery +pnpm run test:module:local +``` + +The local integration suite requires `spacetime start` and validates module +publication, calendar chains, native intervals, typed reducer and procedure +arguments, same-reducer volatile recovery, reducer rollback, +opportunistic and interval-sweep lost-fire repair, automatic disablement, +procedure outcomes, generations, cancellation, history bounds, authorization, +and the example module. The recovery suite verifies that a procedure commits +its next calendar fire before external work, survives a host stop, and performs +at most one catch-up invocation after downtime. + +See the +[browser example](./example/) +for a complete integration and [`DESIGN.md`](./DESIGN.md) for the transaction model and invariants. + +## License + +BUSL-1.1. See [`LICENSE.txt`](./LICENSE.txt). diff --git a/spacetime-cron-ts/example/.env.example b/spacetime-cron-ts/example/.env.example new file mode 100644 index 00000000000..2b6085f7fc9 --- /dev/null +++ b/spacetime-cron-ts/example/.env.example @@ -0,0 +1,9 @@ +# Copy to .env. The example server loads this on startup. + +# ---------------- Static server ---------------- +HOST=127.0.0.1 +PORT=8788 + +# ---------------- SpacetimeDB ---------------- +STDB_URI=ws://127.0.0.1:3000 +STDB_APP_DATABASE=spacetime-cron-example diff --git a/spacetime-cron-ts/example/README.md b/spacetime-cron-ts/example/README.md new file mode 100644 index 00000000000..b284dafb8dc --- /dev/null +++ b/spacetime-cron-ts/example/README.md @@ -0,0 +1,202 @@ +# Cron example + +This example is a small browser dashboard backed directly by SpacetimeDB. It shows two jobs: + +- `digest`, a weekday calendar job in `America/New_York` +- `cleanup`, a five-minute native interval job with a typed `keep` argument + +The dashboard subscribes to the sanitized `cron_jobs` view, exact calendar targets, interval estimates, run outcomes, and application activity. The private job rows retain typed arguments without exposing them to browser subscriptions. Controls can reschedule or disable either job. Selecting `cleanup` also supplies its typed row-retention argument when scheduling. + +This is a local development example. Its scheduling reducers accept any connected caller so the browser can exercise the component. Add application authorization before deploying equivalent controls. The example also enables `publicTables`, which exposes run history and trigger state for the dashboard. Review that visibility before using the same option in an application. + +## What this demonstrates + +- Declaring static calendar and interval jobs. +- Attaching a typed argument to the `cleanup` job. +- Registering one handler and one schedule table for each job. +- Scheduling defaults during a fresh database initialization. +- Rescheduling and disabling jobs through application reducers. +- Subscribing to sanitized job state, run history, and application activity. +- Repairing an enabled job that loses its pending fire. +- Keeping browser-visible job state separate from private typed arguments. + +## Prerequisites + +- Node.js 20 or later +- pnpm 10 +- SpacetimeDB CLI 2.8.3 or later +- A local SpacetimeDB server + +Select the supported CLI release: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +``` + +## Quick start + +Start the server in a separate terminal: + +```bash +spacetime start +``` + +From this directory: + +```bash +pnpm install +pnpm --dir spacetimedb install +pnpm run build:module:fresh +pnpm run dev +``` + +Open . + +`build:module:fresh` performs four steps: + +1. Builds the TypeScript module. +2. Publishes `spacetime-cron-example` to the local server with fresh data. +3. Regenerates TypeScript client bindings. +4. Typechecks and bundles the browser application. + +After a module edit, preserve existing local data with: + +```bash +pnpm run build:module +``` + +## Use in your project + +This workspace tests the component source in this repository. Consumer +applications install the published release: + +```bash +npm install @spacetimedb/cron spacetimedb@^2.8.3 +``` + +The complete server integration is in [`spacetimedb/src/index.ts`](./spacetimedb/src/index.ts). The browser integration is in [`src/app.ts`](./src/app.ts). + +## Configuration + +The static server reads these optional variables from the process environment or `.env` files: + +| Variable | Default | Purpose | +| ------------------- | ------------------------ | ------------------------------------ | +| `PORT` | `8788` | Development web-server port. | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_APP_DATABASE` | `spacetime-cron-example` | Published database name. | + +The browser connects directly to SpacetimeDB. The Express process serves static files and `/api/config`. + +## Execution flow + +1. `cronTable()` declares `digest` and `cleanup` as static jobs. `cleanup` also + declares `{ keep: u32 }` as its durable argument. +2. `createCron()` creates the private shared job table, run history, one schedule + table for each job, and the optional five-minute reconciler. +3. `schema()` mounts the Cron tables with the example's `activity_log` table. +4. `cronReducer()` binds each job to its static handler. +5. `cron.reconcileReducer()` registers the low-frequency lost-fire repair sweep. +6. `init` schedules the weekday digest and five-minute cleanup defaults on a + fresh publish. +7. The browser subscribes to `cron_jobs`, `cron_run`, and `activity_log`. +8. The scheduling form calls application reducers. Those reducers validate the + selected job and typed cleanup argument before calling `schedule()`. + +The `digest` handler appends a summary row. The `cleanup` handler keeps the newest +configured number of activity rows and records how many older rows it removed. + +## Failure and recovery behavior + +Reducer jobs run in a transaction. A successful job commits its application +writes, run record, health update, and next calendar fire together. + +When a reducer handler throws, the fire transaction rolls back. Cron uses the +temporary SpacetimeDB volatile primitive to call the same job reducer with an +internal recovery payload, then rethrows the original error. The second +invocation restores the calendar schedule and records the failed run in a new +transaction. It does not run the application handler again. Native interval +rows remain present, and the same recovery path records their failure state. + +The volatile request is best effort. A host failure can leave an enabled job +without a pending fire. This example enables a five-minute reconciler. The +reconciler repairs that invariant and records a failed run with `lost_fire`. +Calls to `schedule()` and `unschedule()` also perform this repair +opportunistically. + +Typed arguments remain in the private `cron_job` row. The browser-visible +`cron_jobs` view omits them. This example exposes fire tables and detailed run +history through `publicTables` for demonstration. Applications should expose +only the status required by their users. + +## Authorization and deployment boundaries + +- `scheduleCron`, `scheduleEvery`, and `unscheduleJob` are open so a local browser + can exercise the component. Production modules must authorize these calls. +- Job names and argument types are part of the database schema. Changing an + existing argument type requires a schema migration or a new job name. +- Reducer handlers must remain deterministic. Use a Cron procedure for HTTP or + other procedure capabilities. +- Procedure handlers should use `CronInvocation.id` as an external idempotency + key. +- The included Express process is a local static server. Production needs TLS, + explicit origin policy, and process supervision. + +## Build and verification + +From `spacetime-cron-ts/example`: + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +From `spacetime-cron-ts`, run the package and local integration suites: + +```powershell +pnpm test +pnpm run test:module:local +pnpm run test:recovery +``` + +`test:module:local` requires a running local SpacetimeDB server and publishes a +temporary database. `test:recovery` starts and removes its own isolated server. +Together, they exercise reducer rollback, same-reducer volatile recovery, +calendar and interval failures, typed arguments, lost-fire reconciliation, +history limits, and host restart recovery. + +For a browser release check: + +1. Confirm both seeded jobs appear after a fresh publish. +2. Schedule `digest` as a short interval and confirm a run and activity row + appear. +3. Schedule `cleanup` with a new `keep` value and confirm the active schedule + remains after reload. +4. Unschedule a job and confirm it becomes disabled with no pending fire. +5. Reschedule the disabled job and confirm it fires again. +6. Confirm the browser console has no errors and every subscription applies. + +## Troubleshooting + +- **No jobs appear:** confirm `STDB_APP_DATABASE` matches + `spacetime-cron-example` and reload after the subscription applies. +- **The browser cannot connect:** confirm `STDB_URI` points to the server used by + `spacetime publish --server local`. +- **A local package edit is missing:** run + `pnpm --dir spacetimedb install --force`, then rebuild the module. +- **A schedule is rejected:** use a five-field or six-field Cron expression, a + valid IANA time zone, or an interval from 1 through 31,536,000 seconds. +- **A job disables itself:** inspect its recent failed runs and configured + failure threshold before rescheduling it. + +## Important files + +- `spacetimedb/src/index.ts` - job declarations, handlers, initialization, and + browser-facing management reducers. +- `src/app.ts` - typed connection, subscriptions, rendering, and controls. +- `public/index.html` - dashboard structure. +- `public/styles.css` - dashboard presentation. +- `server.ts` - static development server and browser-safe configuration. diff --git a/spacetime-cron-ts/example/package.json b/spacetime-cron-ts/example/package.json new file mode 100644 index 00000000000..262cab0f6a0 --- /dev/null +++ b/spacetime-cron-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-cron-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*", + "@spacetimedb/cron": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-cron-ts/example/public/assets/logo.svg b/spacetime-cron-ts/example/public/assets/logo.svg new file mode 100644 index 00000000000..be9ec6695c2 --- /dev/null +++ b/spacetime-cron-ts/example/public/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/spacetime-cron-ts/example/public/index.html b/spacetime-cron-ts/example/public/index.html new file mode 100644 index 00000000000..9326b444d62 --- /dev/null +++ b/spacetime-cron-ts/example/public/index.html @@ -0,0 +1,272 @@ + + + + + + + SpacetimeDB Cron + + + + +
+
+ + + SpacetimeDB + Cron + +
+ + + Connecting + + +
+
+ +
+
+ +
+ 0 + Configured jobs +
+
+
+ +
+ 0 + Healthy schedules +
+
+
+ +
+ 0 + Total dispatches +
+
+
+ +
+
+
+
+

Configured jobs

+

+ Schedules + 0 +

+
+ + + Live state + +
+
+
+ +

No jobs scheduled

+

Choose a preset or configure a schedule below.

+
+ +
+
+ +
+
+
+
+

Execution history

+

+ Recent runs + 0 +

+
+
+
+
+ +

Runs will appear after a job fires.

+
+ +
+
+ +
+
+
+

Application output

+

+ Activity + 0 +

+
+
+
+
+ +

Handler output will appear here.

+
+ +
+
+
+
+ +
+
+

Try the API

+

Schedule a job

+

+ Configure one of the example handlers with a calendar expression or + a fixed interval. Scheduling the same job again replaces its current + configuration. +

+
+ + + +
+
+ +
+
+ + + + + +
+
+

+ +
+
+
+ + +
+ + + diff --git a/spacetime-cron-ts/example/public/styles.css b/spacetime-cron-ts/example/public/styles.css new file mode 100644 index 00000000000..83580dfa79e --- /dev/null +++ b/spacetime-cron-ts/example/public/styles.css @@ -0,0 +1,982 @@ +:root { + color-scheme: dark; + --page: #070b0f; + --surface: #0d1419; + --surface-raised: #111b21; + --surface-soft: #152128; + --line: #22313b; + --line-strong: #324750; + --text: #f3f5f6; + --muted: #8a9aa5; + --muted-bright: #b9c4ca; + --accent: #49d4ba; + --accent-strong: #78ead4; + --blue: #52b8f4; + --violet: #a997ff; + --green: #4cf490; + --amber: #f4c95d; + --red: #ff8f8f; + --radius-lg: 18px; + --radius-md: 12px; + --radius-sm: 8px; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + margin: 0; + color: var(--text); + background: + radial-gradient( + circle at 15% -10%, + rgb(73 212 186 / 12%), + transparent 32rem + ), + radial-gradient(circle at 95% 8%, rgb(82 184 244 / 8%), transparent 28rem), + var(--page); +} + +button, +input, +select { + font: inherit; +} + +button, +a, +select { + -webkit-tap-highlight-color: transparent; +} + +button { + color: inherit; +} + +a { + color: inherit; +} + +[hidden] { + display: none !important; +} + +.shell { + width: min(1240px, calc(100% - 40px)); + margin: 0 auto; + padding: 20px 0 36px; +} + +.topbar { + position: relative; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 68px; + padding: 14px 16px 14px 20px; + border: 1px solid rgb(255 255 255 / 7%); + border-radius: var(--radius-lg); + background: rgb(13 20 25 / 82%); + box-shadow: 0 18px 60px rgb(0 0 0 / 18%); + backdrop-filter: blur(18px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 11px; + text-decoration: none; +} + +.brand img { + width: 31px; + height: 28px; +} + +.brand-name { + font-size: 1.02rem; + font-weight: 680; + letter-spacing: -0.025em; +} + +.brand-product { + padding: 4px 8px; + border: 1px solid var(--line-strong); + border-radius: 6px; + color: var(--muted-bright); + background: var(--surface-soft); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1; + text-transform: uppercase; +} + +.topbar-actions, +.form-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.connection { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted-bright); + background: rgb(7 11 15 / 55%); + font-size: 0.78rem; + font-weight: 600; +} + +.connection-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 0 4px rgb(244 201 93 / 10%); +} + +.connection[data-state='connected'] .connection-dot { + background: var(--green); + box-shadow: 0 0 0 4px rgb(76 244 144 / 10%); +} + +.connection[data-state='error'] .connection-dot, +.connection[data-state='disconnected'] .connection-dot { + background: var(--red); + box-shadow: 0 0 0 4px rgb(255 143 143 / 10%); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + padding: 0 15px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.82rem; + font-weight: 700; + transition: + border-color 150ms ease, + background 150ms ease, + color 150ms ease, + transform 150ms ease; +} + +.button:not(:disabled):active { + transform: translateY(1px); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.button-primary { + color: #04100d; + background: var(--accent); + box-shadow: 0 8px 28px rgb(73 212 186 / 13%); +} + +.button-primary:not(:disabled):hover { + background: var(--accent-strong); +} + +.button-secondary { + border-color: var(--line); + color: var(--muted-bright); + background: var(--surface-raised); +} + +.button-secondary:not(:disabled):hover { + border-color: var(--line-strong); + color: var(--text); + background: var(--surface-soft); +} + +.button-small { + min-height: 32px; + padding: 0 11px; + font-size: 0.74rem; +} + +.eyebrow, +.panel-kicker { + margin: 0 0 10px; + color: var(--accent); + font-size: 0.69rem; + font-weight: 800; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +.summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-top: 12px; +} + +.summary-card { + display: flex; + align-items: center; + gap: 14px; + min-height: 92px; + padding: 18px 20px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgb(13 20 25 / 75%); +} + +.summary-card strong { + display: block; +} + +.summary-card strong { + font-size: 1.55rem; + font-weight: 620; + letter-spacing: -0.04em; +} + +.summary-card div > span { + margin-top: 2px; + color: var(--muted); + font-size: 0.76rem; +} + +.summary-card .summary-icon { + display: grid; + width: 40px; + height: 40px; + flex: 0 0 auto; + border: 1px solid; + border-radius: 10px; + place-items: center; + line-height: 0; +} + +.summary-icon svg, +.empty-icon svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.summary-icon-blue { + border-color: rgb(82 184 244 / 22%); + color: var(--blue); + background: rgb(82 184 244 / 8%); +} + +.summary-icon-green { + border-color: rgb(76 244 144 / 22%); + color: var(--green); + background: rgb(76 244 144 / 8%); +} + +.summary-icon-violet { + border-color: rgb(169 151 255 / 22%); + color: var(--violet); + background: rgb(169 151 255 / 8%); +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr); + gap: 12px; + margin-top: 12px; +} + +.workspace-side { + display: grid; + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.panel { + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: rgb(13 20 25 / 82%); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 84px; + padding: 20px 22px; + border-bottom: 1px solid var(--line); +} + +.panel-header-compact { + min-height: 76px; + padding: 17px 20px; +} + +.panel-kicker { + margin-bottom: 6px; + color: var(--muted); + font-size: 0.61rem; +} + +.panel h2 { + display: flex; + align-items: center; + gap: 9px; + margin: 0; + font-size: 1rem; + font-weight: 650; + letter-spacing: -0.02em; +} + +.count { + display: inline-grid; + min-width: 22px; + height: 22px; + padding: 0 6px; + border: 1px solid var(--line); + border-radius: 999px; + place-items: center; + color: var(--muted); + background: var(--page); + font-size: 0.65rem; +} + +.live-label { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 0.69rem; + font-weight: 650; +} + +.live-label span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 9px rgb(73 212 186 / 60%); +} + +.panel-body { + min-height: 360px; + padding: 12px; +} + +.panel-body-compact { + min-height: 208px; +} + +.empty-state { + display: grid; + min-height: 335px; + padding: 36px 20px; + place-content: center; + place-items: center; + text-align: center; +} + +.empty-state-small { + min-height: 182px; +} + +.empty-icon { + display: grid; + width: 42px; + height: 42px; + margin-bottom: 13px; + border: 1px solid var(--line); + border-radius: 12px; + place-items: center; + color: var(--accent); + background: var(--surface-raised); + line-height: 0; +} + +.empty-state h3 { + margin: 0; + font-size: 0.91rem; + font-weight: 650; +} + +.empty-state p { + max-width: 280px; + margin: 7px 0 0; + color: var(--muted); + font-size: 0.76rem; + line-height: 1.55; +} + +.job-list, +.event-list { + margin: 0; + padding: 0; + list-style: none; +} + +.job-list { + display: grid; + gap: 9px; +} + +.job-card { + padding: 17px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-raised); +} + +.job-card[data-enabled='false'] { + opacity: 0.72; +} + +.job-card-top, +.event-item { + display: flex; + align-items: center; +} + +.job-card-top { + justify-content: space-between; + gap: 16px; +} + +.job-identity { + display: flex; + align-items: center; + min-width: 0; + gap: 11px; +} + +.job-icon { + display: grid; + width: 38px; + height: 38px; + flex: 0 0 auto; + border: 1px solid rgb(73 212 186 / 20%); + border-radius: 10px; + place-items: center; + color: var(--accent); + background: rgb(73 212 186 / 7%); + font-size: 0.76rem; + font-weight: 800; + line-height: 1; +} + +.job-identity strong, +.job-identity code { + display: block; +} + +.job-identity strong { + overflow: hidden; + font-size: 0.9rem; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-identity code, +.job-schedule code, +.preset code { + color: var(--muted); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; + font-size: 0.67rem; +} + +.job-identity code { + margin-top: 3px; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border: 1px solid; + border-radius: 999px; + font-size: 0.63rem; + font-weight: 750; + text-transform: capitalize; +} + +.status-badge::before { + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + content: ''; +} + +.status-badge.enabled { + border-color: rgb(76 244 144 / 20%); + color: var(--green); + background: rgb(76 244 144 / 7%); +} + +.status-badge.disabled { + border-color: rgb(255 143 143 / 20%); + color: var(--red); + background: rgb(255 143 143 / 7%); +} + +.job-schedule { + margin: 15px 0; + padding: 10px 12px; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 5%); + border-radius: var(--radius-sm); + background: rgb(7 11 15 / 45%); + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-schedule code { + color: var(--muted-bright); +} + +.job-footer { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; +} + +.job-metrics { + display: flex; + min-width: 0; + gap: 22px; +} + +.metric span, +.metric strong { + display: block; +} + +.metric span { + margin-bottom: 3px; + color: var(--muted); + font-size: 0.59rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric strong { + max-width: 200px; + overflow: hidden; + color: var(--muted-bright); + font-size: 0.72rem; + font-weight: 570; + text-overflow: ellipsis; + white-space: nowrap; +} + +.metric strong.unhealthy { + color: var(--amber); +} + +.event-list { + max-height: 320px; + overflow-y: auto; +} + +.event-item { + align-items: flex-start; + gap: 11px; + padding: 12px 8px; + border-bottom: 1px solid rgb(34 49 59 / 70%); +} + +.event-item:last-child { + border-bottom: 0; +} + +.event-marker { + width: 8px; + height: 8px; + margin-top: 5px; + flex: 0 0 auto; + border: 2px solid var(--surface); + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 2px rgb(73 212 186 / 18%); +} + +.event-marker.ok { + background: var(--green); + box-shadow: 0 0 0 2px rgb(76 244 144 / 16%); +} + +.event-marker.failed { + background: var(--red); + box-shadow: 0 0 0 2px rgb(255 143 143 / 16%); +} + +.event-content { + min-width: 0; +} + +.event-title, +.event-copy, +.event-time { + display: block; +} + +.event-title { + overflow: hidden; + font-size: 0.76rem; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-copy { + margin-top: 3px; + overflow-wrap: anywhere; + color: var(--muted-bright); + font-size: 0.7rem; + line-height: 1.45; +} + +.event-time { + margin-top: 4px; + color: var(--muted); + font-size: 0.62rem; +} + +.scheduler { + display: grid; + grid-template-columns: minmax(260px, 0.7fr) minmax(0, 1.3fr); + gap: 40px; + margin-top: 12px; + padding: 36px; + scroll-margin-top: 20px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: + linear-gradient(125deg, rgb(73 212 186 / 4%), transparent 36%), + var(--surface); +} + +.scheduler-intro h2 { + margin: 0; + font-size: 1.65rem; + font-weight: 590; + letter-spacing: -0.04em; +} + +.scheduler-intro > p:not(.eyebrow) { + margin: 13px 0 0; + color: var(--muted); + font-size: 0.79rem; + line-height: 1.6; +} + +.presets { + display: grid; + gap: 7px; + margin-top: 22px; +} + +.preset { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; + min-height: 43px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--muted-bright); + background: var(--surface-raised); + text-align: left; + transition: + border-color 150ms ease, + background 150ms ease; +} + +.preset:hover { + border-color: var(--line-strong); + background: var(--surface-soft); +} + +.preset span { + font-size: 0.7rem; + font-weight: 650; +} + +.schedule-form { + align-self: center; + padding: 22px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgb(7 11 15 / 36%); +} + +.field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.field { + display: grid; + min-width: 0; + gap: 7px; +} + +.field-wide { + grid-column: 1 / -1; +} + +.field > span { + color: var(--muted-bright); + font-size: 0.67rem; + font-weight: 700; +} + +.field input, +.field select { + width: 100%; + min-width: 0; + min-height: 42px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + outline: none; + color: var(--text); + background: var(--surface-raised); + font-size: 0.78rem; + transition: + border-color 150ms ease, + box-shadow 150ms ease; +} + +.field select { + cursor: pointer; +} + +.field input:focus, +.field select:focus { + border-color: rgb(73 212 186 / 70%); + box-shadow: 0 0 0 3px rgb(73 212 186 / 9%); +} + +.field small { + color: var(--muted); + font-size: 0.62rem; +} + +.form-actions { + min-height: 58px; + margin-top: 18px; + padding-top: 17px; + border-top: 1px solid var(--line); + justify-content: space-between; +} + +.form-status { + margin: 0; + color: var(--muted); + font-size: 0.7rem; + line-height: 1.45; +} + +.form-status[data-tone='success'] { + color: var(--green); +} + +.form-status[data-tone='error'] { + color: var(--red); +} + +.footer { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 28px 0 2px; + color: var(--muted); + font-size: 0.68rem; +} + +.footer img { + width: 18px; + height: 16px; +} + +.footer a { + color: var(--muted-bright); + font-weight: 650; + text-decoration: none; +} + +.footer a:hover { + color: var(--text); +} + +.footer-divider { + width: 1px; + height: 14px; + margin: 0 3px; + background: var(--line-strong); +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +@media (max-width: 940px) { + .workspace, + .scheduler { + grid-template-columns: 1fr; + } + + .workspace-side { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: auto; + } + + .scheduler { + gap: 28px; + } + + .presets { + grid-template-columns: repeat(3, 1fr); + } + + .preset { + align-items: flex-start; + flex-direction: column; + gap: 5px; + padding: 11px; + } +} + +@media (max-width: 680px) { + .shell { + width: min(100% - 24px, 1240px); + padding-top: 12px; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + gap: 14px; + padding: 16px; + } + + .topbar-actions { + width: 100%; + } + + .connection, + .topbar-actions .button { + flex: 1; + } + + .summary { + grid-template-columns: 1fr; + } + + .summary-card { + min-height: 76px; + } + + .workspace-side { + grid-template-columns: 1fr; + } + + .panel-header { + padding: 17px; + } + + .job-footer { + align-items: stretch; + flex-direction: column; + } + + .job-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + } + + .metric strong { + max-width: 100%; + } + + .scheduler { + padding: 24px 18px; + } + + .presets, + .field-grid { + grid-template-columns: 1fr; + } + + .field-wide { + grid-column: auto; + } + + .schedule-form { + padding: 17px; + } + + .form-actions { + align-items: stretch; + flex-direction: column; + } + + .form-actions .button { + width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/spacetime-cron-ts/example/server.ts b/spacetime-cron-ts/example/server.ts new file mode 100644 index 00000000000..9d2f357b251 --- /dev/null +++ b/spacetime-cron-ts/example/server.ts @@ -0,0 +1,53 @@ +// Serves the browser bundle and connection settings. The browser connects +// directly to SpacetimeDB. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared env supplies defaults; example-local env wins for app settings. +// Explicit process environment has highest priority. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8788', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-cron-example'; + +const app = express(); +app.use(express.json({ limit: '256kb' })); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, app: STDB_APP_DB }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ stdbUri: STDB_URI, appDatabase: STDB_APP_DB }); +}); + +app.listen(PORT, HOST, () => { + console.log(`Cron example running at http://${HOST}:${PORT}`); + console.log(` SpacetimeDB: ${STDB_URI} (${STDB_APP_DB})`); +}); diff --git a/spacetime-cron-ts/example/spacetimedb/package.json b/spacetime-cron-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..1e3b0467bff --- /dev/null +++ b/spacetime-cron-ts/example/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-cron-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-cron-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-cron-example" + }, + "dependencies": { + "@spacetimedb/cron": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-cron-ts/example/spacetimedb/src/index.ts b/spacetime-cron-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..ac2c97bb5f3 --- /dev/null +++ b/spacetime-cron-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,177 @@ +// Example consumer of @spacetimedb/cron: two statically declared jobs plus +// client-facing management reducers so the UI can reschedule them at runtime. +import { + schema, + table, + t, + SenderError, + toCamelCase, + type ReducerCtx, + type InferSchema, +} from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { spacetimeCron, type CronJobReference } from '@spacetimedb/cron'; + +// One injection point: hand the library this module's own SDK objects so the +// bundle contains exactly one copy of the spacetimedb SDK. +const { cronTable, createCron, schedule, unschedule } = spacetimeCron({ + table, + t, + toCamelCase, + ScheduleAt, + Timestamp, + SenderError, +}); + +// ── Jobs ───────────────────────────────────────────────────────────────────── + +const digest = cronTable({ name: 'digest' }); +const cleanup = cronTable({ + name: 'cleanup', + args: t.object('CleanupCronArgs', { keep: t.u32() }), +}); + +const cron = createCron([digest, cleanup], { + publicTables: true, + reconcileEverySeconds: 300, +}); + +// ── App tables ─────────────────────────────────────────────────────────────── + +const activityLog = table( + { name: 'activity_log', public: true }, + { + id: t.u64().primaryKey().autoInc(), + jobName: t.string().index(), + message: t.string(), + at: t.timestamp().index(), + } +); + +const spacetimedb = schema({ ...cron.tables, activityLog }); +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx; + +// ── Cron wiring ────────────────────────────────────────────────────────────── + +export const runDigest = digest.cronReducer(spacetimedb, (ctx: Tx) => { + const entries = ctx.db.activityLog.count(); + ctx.db.activityLog.insert({ + id: 0n, + jobName: 'digest', + message: `digest generated over ${entries} log entries`, + at: ctx.timestamp, + }); +}); + +export const runCleanup = cleanup.cronReducer(spacetimedb, (ctx: Tx, args) => { + const total = Number(ctx.db.activityLog.count()); + if (total <= args.keep) return; + const excess = total - args.keep + 1; + const oldest = [...ctx.db.activityLog.iter()] + .sort((left, right) => { + const delta = + left.at.microsSinceUnixEpoch - right.at.microsSinceUnixEpoch; + return delta < 0n ? -1 : delta > 0n ? 1 : 0; + }) + .slice(0, excess); + for (const row of oldest) { + ctx.db.activityLog.delete(row); + } + ctx.db.activityLog.insert({ + id: 0n, + jobName: 'cleanup', + message: `pruned ${oldest.length} old entries`, + at: ctx.timestamp, + }); +}); + +export const cronReconcile = cron.reconcileReducer(spacetimedb); +export const { jobs: cronJobs } = cron.publicViews(spacetimedb); + +export const init = spacetimedb.init(ctx => { + // Code-declared defaults on first publish. Both are runtime state after + // this: reschedule or disable them from the UI without republishing. + schedule(ctx, digest, '0 9 * * 1-5', { timezone: 'America/New_York' }); + schedule(ctx, cleanup, { everySeconds: 300 }, { args: { keep: 50 } }); +}); + +// ── Client-facing management ───────────────────────────────────────────────── + +// These reducers are intentionally open so the local browser can exercise the +// component. Production applications must enforce their own admin policy. + +const jobs: Record = { digest, cleanup }; + +function jobByName(name: string): CronJobReference { + const job = jobs[name]; + if (!job) throw new SenderError(`cron.unknown_job:${name}`); + return job; +} + +function cleanupKeep(value: number): number { + if (!Number.isInteger(value) || value < 1 || value > 1_000) { + throw new SenderError('cleanup.invalid_keep:must be between 1 and 1000'); + } + return value; +} + +export const scheduleCron = spacetimedb.reducer( + { + name: t.string(), + expression: t.string(), + timezone: t.string(), + keep: t.u32(), + }, + (ctx, args) => { + try { + if (args.name === 'cleanup') { + schedule(ctx, cleanup, args.expression, { + timezone: args.timezone, + args: { keep: cleanupKeep(args.keep) }, + }); + } else if (args.name === 'digest') { + schedule(ctx, digest, args.expression, { timezone: args.timezone }); + } else { + throw new SenderError(`cron.unknown_job:${args.name}`); + } + } catch (err) { + throw err instanceof SenderError + ? err + : new SenderError(err instanceof Error ? err.message : String(err)); + } + } +); + +export const scheduleEvery = spacetimedb.reducer( + { name: t.string(), seconds: t.u32(), keep: t.u32() }, + (ctx, args) => { + try { + if (args.name === 'cleanup') { + schedule( + ctx, + cleanup, + { everySeconds: args.seconds }, + { args: { keep: cleanupKeep(args.keep) } } + ); + } else if (args.name === 'digest') { + schedule(ctx, digest, { everySeconds: args.seconds }); + } else { + throw new SenderError(`cron.unknown_job:${args.name}`); + } + } catch (err) { + throw err instanceof SenderError + ? err + : new SenderError(err instanceof Error ? err.message : String(err)); + } + } +); + +export const unscheduleJob = spacetimedb.reducer( + { name: t.string() }, + (ctx, { name }) => { + unschedule(ctx, jobByName(name)); + } +); diff --git a/spacetime-cron-ts/example/spacetimedb/tsconfig.json b/spacetime-cron-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..4b41d0f867b --- /dev/null +++ b/spacetime-cron-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-cron-ts/example/src/app.ts b/spacetime-cron-ts/example/src/app.ts new file mode 100644 index 00000000000..4f47b876c69 --- /dev/null +++ b/spacetime-cron-ts/example/src/app.ts @@ -0,0 +1,437 @@ +import { DbConnection, tables, type ErrorContext } from './codegen/app'; +import type { CronSchedule } from './codegen/app/types'; + +interface ServerConfig { + stdbUri: string; + appDatabase: string; +} + +type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'; +type StatusTone = 'neutral' | 'success' | 'error'; + +let connection: DbConnection | null = null; + +function element( + tag: T, + className?: string, + text?: string +): HTMLElementTagNameMap[T] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function byId(id: string): T { + const node = document.getElementById(id); + if (!node) throw new Error(`Missing required element #${id}`); + return node as T; +} + +function setText(id: string, value: string | number | bigint): void { + byId(id).textContent = String(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function formatTime(micros: bigint): string { + return new Date(Number(micros / 1_000n)).toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'medium', + }); +} + +function formatJobName(name: string): string { + return name + .split('_') + .map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' '); +} + +function scheduleLabel(schedule: CronSchedule): string { + if (schedule.tag === 'Cron') { + return `${schedule.value.expression} · ${schedule.value.timezone}`; + } + return `Every ${schedule.value.seconds} seconds`; +} + +function nextRunLabel(nextRunAt?: { microsSinceUnixEpoch: bigint }): string { + return nextRunAt ? formatTime(nextRunAt.microsSinceUnixEpoch) : 'Not armed'; +} + +function setConnection(state: ConnectionState, label: string): void { + byId('connection').dataset.state = state; + setText('conn', label); + const enabled = state === 'connected'; + byId('open-scheduler').disabled = !enabled; + byId('btn-schedule').disabled = !enabled; +} + +function setFormStatus(text: string, tone: StatusTone = 'neutral'): void { + const status = byId('form-status'); + status.textContent = text; + status.dataset.tone = tone; +} + +function setCollectionVisibility( + listId: string, + emptyId: string, + hasItems: boolean +): void { + byId(listId).hidden = !hasItems; + byId(emptyId).hidden = hasItems; +} + +function metric(label: string, value: string, unhealthy = false): HTMLElement { + const node = element('div', 'metric'); + const valueNode = element( + 'strong', + unhealthy ? 'unhealthy' : undefined, + value + ); + node.append(element('span', undefined, label), valueNode); + return node; +} + +function renderJobs(): void { + if (!connection) return; + const jobs = [...connection.db.cronJobs.iter()].sort((left, right) => + left.name.localeCompare(right.name) + ); + const list = byId('jobs'); + list.replaceChildren(); + + let healthy = 0; + let dispatches = 0n; + + for (const job of jobs) { + if (job.enabled && job.consecutiveFailures === 0) healthy += 1; + dispatches += job.fireCount; + + const item = element('li', 'job-card'); + item.dataset.enabled = String(job.enabled); + + const heading = element('div', 'job-card-top'); + const identity = element('div', 'job-identity'); + const name = element('div'); + name.append( + element('strong', undefined, formatJobName(job.name)), + element('code', undefined, job.name) + ); + identity.append( + element('span', 'job-icon', job.name.charAt(0).toUpperCase()), + name + ); + + const statusText = job.enabled ? 'enabled' : 'disabled'; + const status = element('span', `status-badge ${statusText}`, statusText); + if (job.disabledReason) status.title = job.disabledReason; + heading.append(identity, status); + + const schedule = element('div', 'job-schedule'); + schedule.append(element('code', undefined, scheduleLabel(job.schedule))); + + const footer = element('div', 'job-footer'); + const metrics = element('div', 'job-metrics'); + metrics.append( + metric('Next run', nextRunLabel(job.nextRunAt)), + metric('Dispatches', String(job.fireCount)), + metric( + 'Failures', + String(job.consecutiveFailures), + job.consecutiveFailures > 0 + ) + ); + footer.append(metrics); + + if (job.enabled) { + const button = element( + 'button', + 'button button-secondary button-small', + 'Unschedule' + ); + button.type = 'button'; + button.onclick = async () => { + const current = connection; + if (!current) return; + button.disabled = true; + setFormStatus(`Unscheduling ${formatJobName(job.name)}...`); + try { + await current.reducers.unscheduleJob({ name: job.name }); + setFormStatus(`${formatJobName(job.name)} unscheduled.`, 'success'); + } catch (error) { + setFormStatus(errorMessage(error), 'error'); + button.disabled = false; + } + }; + footer.append(button); + } + + item.append(heading, schedule, footer); + list.append(item); + } + + setText('job-count', jobs.length); + setText('stat-jobs', jobs.length); + setText('stat-healthy', healthy); + setText('stat-fires', dispatches); + setCollectionVisibility('jobs', 'jobs-empty', jobs.length > 0); +} + +function renderRuns(): void { + if (!connection) return; + const runs = [...connection.db.cronRun.iter()] + .sort((left, right) => { + const leftTime = left.scheduledFor.microsSinceUnixEpoch; + const rightTime = right.scheduledFor.microsSinceUnixEpoch; + if (leftTime !== rightTime) return leftTime > rightTime ? -1 : 1; + const byName = left.jobName.localeCompare(right.jobName); + if (byName !== 0) return byName; + return left.sequence > right.sequence + ? -1 + : left.sequence < right.sequence + ? 1 + : 0; + }) + .slice(0, 20); + const list = byId('runs'); + list.replaceChildren(); + + for (const run of runs) { + const failed = run.status.tag === 'Failed'; + const item = element('li', 'event-item'); + const marker = element('span', `event-marker ${failed ? 'failed' : 'ok'}`); + marker.setAttribute('aria-hidden', 'true'); + const content = element('div', 'event-content'); + content.append( + element( + 'span', + 'event-title', + `${formatJobName(run.jobName)} · ${run.status.tag}` + ) + ); + if (run.error) { + content.append(element('span', 'event-copy', run.error)); + } + content.append( + element( + 'time', + 'event-time', + formatTime(run.scheduledFor.microsSinceUnixEpoch) + ) + ); + item.append(marker, content); + list.append(item); + } + + setText('run-count', runs.length); + setCollectionVisibility('runs', 'runs-empty', runs.length > 0); +} + +function renderActivity(): void { + if (!connection) return; + const entries = [...connection.db.activityLog.iter()] + .sort((left, right) => { + const leftTime = left.at.microsSinceUnixEpoch; + const rightTime = right.at.microsSinceUnixEpoch; + if (leftTime !== rightTime) return leftTime > rightTime ? -1 : 1; + return left.id > right.id ? -1 : left.id < right.id ? 1 : 0; + }) + .slice(0, 20); + const list = byId('activity'); + list.replaceChildren(); + + for (const entry of entries) { + const item = element('li', 'event-item'); + const marker = element('span', 'event-marker'); + marker.setAttribute('aria-hidden', 'true'); + const content = element('div', 'event-content'); + content.append( + element('span', 'event-title', formatJobName(entry.jobName)), + element('span', 'event-copy', entry.message), + element('time', 'event-time', formatTime(entry.at.microsSinceUnixEpoch)) + ); + item.append(marker, content); + list.append(item); + } + + setText('activity-count', entries.length); + setCollectionVisibility('activity', 'activity-empty', entries.length > 0); +} + +function renderAll(): void { + renderJobs(); + renderRuns(); + renderActivity(); +} + +function updateScheduleFields(): void { + const kind = byId('spec-kind'); + const expression = byId('spec-expr'); + const isCron = kind.value === 'cron'; + setText('spec-expr-label', isCron ? 'Expression' : 'Seconds'); + setText( + 'spec-help', + isCron + ? 'Use five fields, or six fields to include seconds.' + : 'Enter a whole number of seconds.' + ); + expression.placeholder = isCron ? '*/10 * * * * *' : '10'; + byId('spec-tz-wrap').hidden = !isCron; +} + +function applyPreset(preset: string): void { + const kind = byId('spec-kind'); + const expression = byId('spec-expr'); + const timezone = byId('spec-tz'); + + if (preset === 'weekday') { + kind.value = 'cron'; + expression.value = '0 9 * * 1-5'; + timezone.value = 'America/New_York'; + } else if (preset === 'five-minutes') { + kind.value = 'every'; + expression.value = '300'; + } else { + kind.value = 'cron'; + expression.value = '*/10 * * * * *'; + timezone.value = 'UTC'; + } + + updateScheduleFields(); + setFormStatus('Preset applied.'); + expression.focus(); +} + +function wireForm(): void { + const form = byId('schedule-form'); + const kind = byId('spec-kind'); + const expression = byId('spec-expr'); + const timezone = byId('spec-tz'); + const jobName = byId('job-name'); + const cleanupKeep = byId('cleanup-keep'); + const submit = byId('btn-schedule'); + + form.onsubmit = async event => { + event.preventDefault(); + const current = connection; + if (!current) { + setFormStatus('Connect to SpacetimeDB before scheduling a job.', 'error'); + return; + } + + const name = jobName.value; + const keep = Number(cleanupKeep.value); + submit.disabled = true; + setFormStatus(''); + try { + if ( + name === 'cleanup' && + (!Number.isInteger(keep) || keep < 1 || keep > 1_000) + ) { + throw new Error('Rows to keep must be an integer from 1 through 1000.'); + } + if (kind.value === 'cron') { + const value = expression.value.trim(); + if (!value) throw new Error('Enter a cron expression.'); + setFormStatus(`Scheduling ${formatJobName(name)}...`); + await current.reducers.scheduleCron({ + name, + expression: value, + timezone: timezone.value.trim() || 'UTC', + keep, + }); + } else { + const seconds = Number(expression.value); + if (!Number.isInteger(seconds) || seconds < 1) { + throw new Error('Interval seconds must be a positive integer.'); + } + setFormStatus(`Scheduling ${formatJobName(name)}...`); + await current.reducers.scheduleEvery({ name, seconds, keep }); + } + setFormStatus(`${formatJobName(name)} scheduled.`, 'success'); + } catch (error) { + setFormStatus(errorMessage(error), 'error'); + } finally { + submit.disabled = connection === null; + } + }; + + kind.onchange = () => { + const isCron = kind.value === 'cron'; + expression.value = isCron ? '*/10 * * * * *' : '10'; + updateScheduleFields(); + setFormStatus(''); + }; + + jobName.onchange = () => { + byId('cleanup-keep-wrap').hidden = jobName.value !== 'cleanup'; + setFormStatus(''); + }; + + for (const preset of document.querySelectorAll( + '[data-preset]' + )) { + preset.onclick = () => applyPreset(preset.dataset.preset ?? ''); + } + + byId('open-scheduler').onclick = () => { + byId('scheduler').scrollIntoView({ behavior: 'smooth', block: 'center' }); + window.setTimeout(() => jobName.focus(), 250); + }; + + updateScheduleFields(); +} + +function watchTables(current: DbConnection): void { + current.db.cronJobs.onInsert(renderJobs); + current.db.cronJobs.onDelete(renderJobs); + current.db.cronJobs.onUpdate(renderJobs); + current.db.cronRun.onInsert(renderRuns); + current.db.cronRun.onDelete(renderRuns); + current.db.cronRun.onUpdate(renderRuns); + current.db.activityLog.onInsert(renderActivity); + current.db.activityLog.onDelete(renderActivity); + current.db.activityLog.onUpdate(renderActivity); +} + +async function main(): Promise { + wireForm(); + setConnection('connecting', 'Connecting'); + + const response = await fetch('/api/config'); + if (!response.ok) { + throw new Error(`Config request failed: ${response.status}`); + } + const config = (await response.json()) as ServerConfig; + + DbConnection.builder() + .withUri(config.stdbUri) + .withDatabaseName(config.appDatabase) + .onConnect((current: DbConnection) => { + connection = current; + setConnection('connected', config.appDatabase); + watchTables(current); + current + .subscriptionBuilder() + .onApplied(renderAll) + .subscribe([tables.cronJobs, tables.cronRun, tables.activityLog]); + }) + .onConnectError((_ctx: ErrorContext, error: Error) => { + setConnection('error', 'Connection failed'); + setFormStatus(error.message, 'error'); + }) + .onDisconnect((_ctx, error) => { + connection = null; + setConnection('disconnected', 'Disconnected'); + if (error) setFormStatus(error.message, 'error'); + }) + .build(); +} + +void main().catch(error => { + setConnection('error', 'Configuration failed'); + setFormStatus(errorMessage(error), 'error'); +}); diff --git a/spacetime-cron-ts/example/src/codegen/app/activity_log_table.ts b/spacetime-cron-ts/example/src/codegen/app/activity_log_table.ts new file mode 100644 index 00000000000..e6c83267786 --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/activity_log_table.ts @@ -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({ + id: __t.u64().primaryKey(), + jobName: __t.string().name("job_name"), + message: __t.string(), + at: __t.timestamp(), +}); diff --git a/spacetime-cron-ts/example/src/codegen/app/cleanup_fire_table.ts b/spacetime-cron-ts/example/src/codegen/app/cleanup_fire_table.ts new file mode 100644 index 00000000000..107696eecfe --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/cleanup_fire_table.ts @@ -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 { + CronFireRecovery, +} from "./types"; + + +export default __t.row({ + scheduledId: __t.u64().primaryKey().name("scheduled_id"), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + jobName: __t.string().name("job_name"), + generation: __t.u64(), + targetAt: __t.option(__t.timestamp()).name("target_at"), + get recovery() { + return __t.option(CronFireRecovery); + }, +}); diff --git a/spacetime-cron-ts/example/src/codegen/app/cron_jobs_table.ts b/spacetime-cron-ts/example/src/codegen/app/cron_jobs_table.ts new file mode 100644 index 00000000000..a7f49c2639a --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/cron_jobs_table.ts @@ -0,0 +1,30 @@ +// 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 { + CronSchedule, +} from "./types"; + + +export default __t.row({ + name: __t.string().primaryKey(), + get schedule() { + return CronSchedule; + }, + enabled: __t.bool(), + maxFailures: __t.u32().name("max_failures"), + consecutiveFailures: __t.u32().name("consecutive_failures"), + fireCount: __t.u64().name("fire_count"), + generation: __t.u64(), + lastRunAt: __t.option(__t.timestamp()).name("last_run_at"), + nextRunAt: __t.option(__t.timestamp()).name("next_run_at"), + disabledReason: __t.option(__t.string()).name("disabled_reason"), +}); diff --git a/spacetime-cron-ts/example/src/codegen/app/cron_reconcile_tick_table.ts b/spacetime-cron-ts/example/src/codegen/app/cron_reconcile_tick_table.ts new file mode 100644 index 00000000000..e58ad71b8ca --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/cron_reconcile_tick_table.ts @@ -0,0 +1,17 @@ +// 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({ + scheduledId: __t.u64().primaryKey().name("scheduled_id"), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + key: __t.string(), +}); diff --git a/spacetime-cron-ts/example/src/codegen/app/cron_run_table.ts b/spacetime-cron-ts/example/src/codegen/app/cron_run_table.ts new file mode 100644 index 00000000000..122a896817f --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/cron_run_table.ts @@ -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 { + CronRunStatus, +} from "./types"; + + +export default __t.row({ + invocationId: __t.string().primaryKey().name("invocation_id"), + jobName: __t.string().name("job_name"), + generation: __t.u64(), + sequence: __t.u64(), + scheduledFor: __t.timestamp().name("scheduled_for"), + completedAt: __t.timestamp().name("completed_at"), + get status() { + return CronRunStatus; + }, + error: __t.option(__t.string()), +}); diff --git a/spacetime-cron-ts/example/src/codegen/app/digest_fire_table.ts b/spacetime-cron-ts/example/src/codegen/app/digest_fire_table.ts new file mode 100644 index 00000000000..107696eecfe --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/digest_fire_table.ts @@ -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 { + CronFireRecovery, +} from "./types"; + + +export default __t.row({ + scheduledId: __t.u64().primaryKey().name("scheduled_id"), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + jobName: __t.string().name("job_name"), + generation: __t.u64(), + targetAt: __t.option(__t.timestamp()).name("target_at"), + get recovery() { + return __t.option(CronFireRecovery); + }, +}); diff --git a/spacetime-cron-ts/example/src/codegen/app/index.ts b/spacetime-cron-ts/example/src/codegen/app/index.ts new file mode 100644 index 00000000000..cac97144152 --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/index.ts @@ -0,0 +1,203 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 ScheduleCronReducer from "./schedule_cron_reducer"; +import ScheduleEveryReducer from "./schedule_every_reducer"; +import UnscheduleJobReducer from "./unschedule_job_reducer"; + +// Import all procedure arg schemas + +// Import all table schema definitions +import ActivityLogRow from "./activity_log_table"; +import CleanupFireRow from "./cleanup_fire_table"; +import CronJobsRow from "./cron_jobs_table"; +import CronReconcileTickRow from "./cron_reconcile_tick_table"; +import CronRunRow from "./cron_run_table"; +import DigestFireRow from "./digest_fire_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({ + activityLog: __table({ + name: 'activity_log', + indexes: [ + { accessor: 'at', name: 'activity_log_at_idx_btree', algorithm: 'btree', columns: [ + 'at', + ] }, + { accessor: 'id', name: 'activity_log_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + { accessor: 'jobName', name: 'activity_log_job_name_idx_btree', algorithm: 'btree', columns: [ + 'jobName', + ] }, + ], + constraints: [ + { name: 'activity_log_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, ActivityLogRow), + cleanupFire: __table({ + name: 'cleanup_fire', + indexes: [ + { accessor: 'jobName', name: 'cleanup_fire_job_name_idx_btree', algorithm: 'btree', columns: [ + 'jobName', + ] }, + { accessor: 'scheduledId', name: 'cleanup_fire_scheduled_id_idx_btree', algorithm: 'btree', columns: [ + 'scheduledId', + ] }, + ], + constraints: [ + { name: 'cleanup_fire_job_name_key', constraint: 'unique', columns: ['jobName'] }, + { name: 'cleanup_fire_scheduled_id_key', constraint: 'unique', columns: ['scheduledId'] }, + ], + }, CleanupFireRow), + cronReconcileTick: __table({ + name: 'cron_reconcile_tick', + indexes: [ + { accessor: 'key', name: 'cron_reconcile_tick_key_idx_btree', algorithm: 'btree', columns: [ + 'key', + ] }, + { accessor: 'scheduledId', name: 'cron_reconcile_tick_scheduled_id_idx_btree', algorithm: 'btree', columns: [ + 'scheduledId', + ] }, + ], + constraints: [ + { name: 'cron_reconcile_tick_key_key', constraint: 'unique', columns: ['key'] }, + { name: 'cron_reconcile_tick_scheduled_id_key', constraint: 'unique', columns: ['scheduledId'] }, + ], + }, CronReconcileTickRow), + cronRun: __table({ + name: 'cron_run', + indexes: [ + { accessor: 'invocationId', name: 'cron_run_invocation_id_idx_btree', algorithm: 'btree', columns: [ + 'invocationId', + ] }, + { accessor: 'jobName', name: 'cron_run_job_name_idx_btree', algorithm: 'btree', columns: [ + 'jobName', + ] }, + ], + constraints: [ + { name: 'cron_run_invocation_id_key', constraint: 'unique', columns: ['invocationId'] }, + ], + }, CronRunRow), + digestFire: __table({ + name: 'digest_fire', + indexes: [ + { accessor: 'jobName', name: 'digest_fire_job_name_idx_btree', algorithm: 'btree', columns: [ + 'jobName', + ] }, + { accessor: 'scheduledId', name: 'digest_fire_scheduled_id_idx_btree', algorithm: 'btree', columns: [ + 'scheduledId', + ] }, + ], + constraints: [ + { name: 'digest_fire_job_name_key', constraint: 'unique', columns: ['jobName'] }, + { name: 'digest_fire_scheduled_id_key', constraint: 'unique', columns: ['scheduledId'] }, + ], + }, DigestFireRow), + cronJobs: __table({ + name: 'cron_jobs', + indexes: [ + ], + constraints: [ + ], + }, CronJobsRow), +}); + +/** 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( + __reducerSchema("schedule_cron", ScheduleCronReducer), + __reducerSchema("schedule_every", ScheduleEveryReducer), + __reducerSchema("unschedule_job", UnscheduleJobReducer), +); + +/** 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( +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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 = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The procedures available in this remote SpacetimeDB module. */ +export const procedures = __convertToAccessorMap(proceduresSchema.procedures); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-cron-ts/example/src/codegen/app/schedule_cron_reducer.ts b/spacetime-cron-ts/example/src/codegen/app/schedule_cron_reducer.ts new file mode 100644 index 00000000000..708fe42e2e6 --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/schedule_cron_reducer.ts @@ -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 { + name: __t.string(), + expression: __t.string(), + timezone: __t.string(), + keep: __t.u32(), +}; diff --git a/spacetime-cron-ts/example/src/codegen/app/schedule_every_reducer.ts b/spacetime-cron-ts/example/src/codegen/app/schedule_every_reducer.ts new file mode 100644 index 00000000000..e054dd57fa5 --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/schedule_every_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + name: __t.string(), + seconds: __t.u32(), + keep: __t.u32(), +}; diff --git a/spacetime-cron-ts/example/src/codegen/app/types.ts b/spacetime-cron-ts/example/src/codegen/app/types.ts new file mode 100644 index 00000000000..c538888e35f --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/types.ts @@ -0,0 +1,153 @@ +// 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 const ActivityLog = __t.object("ActivityLog", { + id: __t.u64(), + jobName: __t.string(), + message: __t.string(), + at: __t.timestamp(), +}); +export type ActivityLog = __Infer; + +export const CleanupCronArgs = __t.object("CleanupCronArgs", { + keep: __t.u32(), +}); +export type CleanupCronArgs = __Infer; + +export const CleanupFire = __t.object("CleanupFire", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), + jobName: __t.string(), + generation: __t.u64(), + targetAt: __t.option(__t.timestamp()), + get recovery() { + return __t.option(CronFireRecovery); + }, +}); +export type CleanupFire = __Infer; + +export const CronFireRecovery = __t.object("CronFireRecovery", { + sequence: __t.u64(), + scheduledFor: __t.timestamp(), + error: __t.string(), +}); +export type CronFireRecovery = __Infer; + +export const CronJob = __t.object("CronJob", { + name: __t.string(), + get schedule() { + return CronSchedule; + }, + get args() { + return CronJobArgsValue; + }, + enabled: __t.bool(), + maxFailures: __t.u32(), + consecutiveFailures: __t.u32(), + fireCount: __t.u64(), + generation: __t.u64(), + lastRunAt: __t.option(__t.timestamp()), + nextRunAt: __t.option(__t.timestamp()), + disabledReason: __t.option(__t.string()), +}); +export type CronJob = __Infer; + +// The tagged union or sum type for the algebraic type `CronJobArgsValue`. +export const CronJobArgsValue = __t.enum("CronJobArgsValue", { + get Cleanup() { + return CleanupCronArgs; + }, + Digest: __t.unit(), +}); +export type CronJobArgsValue = __Infer; + +export const CronJobView = __t.object("CronJobView", { + name: __t.string(), + get schedule() { + return CronSchedule; + }, + enabled: __t.bool(), + maxFailures: __t.u32(), + consecutiveFailures: __t.u32(), + fireCount: __t.u64(), + generation: __t.u64(), + lastRunAt: __t.option(__t.timestamp()), + nextRunAt: __t.option(__t.timestamp()), + disabledReason: __t.option(__t.string()), +}); +export type CronJobView = __Infer; + +export const CronJobs = __t.object("CronJobs", {}); +export type CronJobs = __Infer; + +export const CronReconcileTick = __t.object("CronReconcileTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), + key: __t.string(), +}); +export type CronReconcileTick = __Infer; + +export const CronRun = __t.object("CronRun", { + invocationId: __t.string(), + jobName: __t.string(), + generation: __t.u64(), + sequence: __t.u64(), + scheduledFor: __t.timestamp(), + completedAt: __t.timestamp(), + get status() { + return CronRunStatus; + }, + error: __t.option(__t.string()), +}); +export type CronRun = __Infer; + +// The tagged union or sum type for the algebraic type `CronRunStatus`. +export const CronRunStatus = __t.enum("CronRunStatus", { + Ok: __t.unit(), + Failed: __t.unit(), +}); +export type CronRunStatus = __Infer; + +// The tagged union or sum type for the algebraic type `CronSchedule`. +export const CronSchedule = __t.enum("CronSchedule", { + get Cron() { + return CronSpec; + }, + get Every() { + return EverySpec; + }, +}); +export type CronSchedule = __Infer; + +export const CronSpec = __t.object("CronSpec", { + expression: __t.string(), + timezone: __t.string(), +}); +export type CronSpec = __Infer; + +export const DigestFire = __t.object("DigestFire", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), + jobName: __t.string(), + generation: __t.u64(), + targetAt: __t.option(__t.timestamp()), + get recovery() { + return __t.option(CronFireRecovery); + }, +}); +export type DigestFire = __Infer; + +export const EverySpec = __t.object("EverySpec", { + seconds: __t.u32(), +}); +export type EverySpec = __Infer; + diff --git a/spacetime-cron-ts/example/src/codegen/app/types/procedures.ts b/spacetime-cron-ts/example/src/codegen/app/types/procedures.ts new file mode 100644 index 00000000000..d5ac825c9ab --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/types/procedures.ts @@ -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 procedure arg schemas + + diff --git a/spacetime-cron-ts/example/src/codegen/app/types/reducers.ts b/spacetime-cron-ts/example/src/codegen/app/types/reducers.ts new file mode 100644 index 00000000000..3f09c30fc78 --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/types/reducers.ts @@ -0,0 +1,16 @@ +// 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 +import ScheduleCronReducer from "../schedule_cron_reducer"; +import ScheduleEveryReducer from "../schedule_every_reducer"; +import UnscheduleJobReducer from "../unschedule_job_reducer"; + +export type ScheduleCronParams = __Infer; +export type ScheduleEveryParams = __Infer; +export type UnscheduleJobParams = __Infer; + diff --git a/spacetime-cron-ts/example/src/codegen/app/unschedule_job_reducer.ts b/spacetime-cron-ts/example/src/codegen/app/unschedule_job_reducer.ts new file mode 100644 index 00000000000..ce493ee8574 --- /dev/null +++ b/spacetime-cron-ts/example/src/codegen/app/unschedule_job_reducer.ts @@ -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 { + name: __t.string(), +}; diff --git a/spacetime-cron-ts/example/tsconfig.json b/spacetime-cron-ts/example/tsconfig.json new file mode 100644 index 00000000000..143c4fb996c --- /dev/null +++ b/spacetime-cron-ts/example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-cron-ts/package.json b/spacetime-cron-ts/package.json new file mode 100644 index 00000000000..2e01ae6132c --- /dev/null +++ b/spacetime-cron-ts/package.json @@ -0,0 +1,69 @@ +{ + "name": "@spacetimedb/cron", + "description": "Calendar and interval scheduling for SpacetimeDB TypeScript modules.", + "version": "0.3.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./parser": { + "types": "./src/parser.ts", + "default": "./src/parser.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-cron-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-cron-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "cron", + "scheduler", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "pnpm run test:parser && pnpm run test:schedule && pnpm run test:validation && pnpm run test:registration", + "test:parser": "tsx scripts/test-cron-parser.ts", + "test:module:local": "node scripts/test-module-local.mjs", + "test:registration": "node --import tsx --import ./scripts/sys-abi-test-register.mjs scripts/test-registration.ts", + "test:recovery": "node scripts/test-recovery.mjs", + "test:schedule": "tsx scripts/test-schedule.ts", + "test:validation": "tsx scripts/test-validation.ts" + }, + "dependencies": { + "cron-parser": "5.5.0" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-cron-ts/scripts/sys-abi-test-register.mjs b/spacetime-cron-ts/scripts/sys-abi-test-register.mjs new file mode 100644 index 00000000000..556603c3447 --- /dev/null +++ b/spacetime-cron-ts/scripts/sys-abi-test-register.mjs @@ -0,0 +1,19 @@ +import { register } from 'node:module'; + +const loaderSource = ` + const sysAbiUrl = 'data:text/javascript,' + encodeURIComponent( + 'export function volatile_nonatomic_schedule_immediate() {}' + ); + + export function resolve(specifier, context, nextResolve) { + if (specifier === 'spacetime:sys@2.0') { + return { shortCircuit: true, url: sysAbiUrl }; + } + return nextResolve(specifier, context); + } +`; + +register( + `data:text/javascript,${encodeURIComponent(loaderSource)}`, + import.meta.url +); diff --git a/spacetime-cron-ts/scripts/test-cron-parser.ts b/spacetime-cron-ts/scripts/test-cron-parser.ts new file mode 100644 index 00000000000..b0e4151fa9b --- /dev/null +++ b/spacetime-cron-ts/scripts/test-cron-parser.ts @@ -0,0 +1,210 @@ +// Pure-Node sanity test. No STDB needed. + +import { + MAX_CRON_EXPRESSION_LENGTH, + MAX_TIMEZONE_LENGTH, + isValidTimezone, + nextFireAfter, + parseCronExpression, +} from '../src/parser'; + +function assert(cond: boolean, msg: string): void { + if (!cond) { + process.stderr.write(`FAIL: ${msg}\n`); + process.exit(1); + } +} + +function check( + expr: string, + fromIso: string, + expectIso: string, + timezone = 'UTC' +): void { + const parsed = parseCronExpression(expr); + const fromMicros = BigInt(new Date(fromIso).getTime()) * 1000n; + const next = nextFireAfter(parsed, fromMicros, timezone); + if (next === undefined) { + assert( + false, + `${expr} [${timezone}] from ${fromIso} → undefined (expected ${expectIso})` + ); + return; + } + const got = new Date(Number(next / 1000n)).toISOString(); + assert( + got === expectIso, + `${expr} [${timezone}] from ${fromIso} → ${got} (expected ${expectIso})` + ); + process.stdout.write( + ` ${expr.padEnd(15)} [${timezone.padEnd(20)}] from ${fromIso} → ${got} ✓\n` + ); +} + +process.stdout.write('cron parser tests\n'); + +// "every minute" +check('* * * * *', '2026-05-04T12:00:00.000Z', '2026-05-04T12:01:00.000Z'); +check('* * * * *', '2026-05-04T12:00:30.000Z', '2026-05-04T12:01:00.000Z'); + +// every 15 minutes +check('*/15 * * * *', '2026-05-04T12:00:00.000Z', '2026-05-04T12:15:00.000Z'); +check('*/15 * * * *', '2026-05-04T12:50:00.000Z', '2026-05-04T13:00:00.000Z'); + +// daily at 9:00 UTC +check('0 9 * * *', '2026-05-04T08:00:00.000Z', '2026-05-04T09:00:00.000Z'); +check('0 9 * * *', '2026-05-04T09:00:00.000Z', '2026-05-05T09:00:00.000Z'); + +// Mondays at 9:00 UTC (Mon = 1) +check('0 9 * * 1', '2026-05-04T08:00:00.000Z', '2026-05-04T09:00:00.000Z'); // Mon May 4 +check('0 9 * * MON', '2026-05-04T10:00:00.000Z', '2026-05-11T09:00:00.000Z'); // next Mon + +// First of the month at midnight +check('0 0 1 * *', '2026-05-04T00:00:00.000Z', '2026-06-01T00:00:00.000Z'); + +// Range + list: weekdays at quarter past 9 +check('15 9 * * 1-5', '2026-05-02T00:00:00.000Z', '2026-05-04T09:15:00.000Z'); // skip weekend + +// Step inside range: every 5 minutes between :00 and :30 +check('0-30/5 * * * *', '2026-05-04T12:00:00.000Z', '2026-05-04T12:05:00.000Z'); +check('0-30/5 * * * *', '2026-05-04T12:30:00.000Z', '2026-05-04T13:00:00.000Z'); + +// DOM + DOW union (Vixie cron); next Mon (May 11) wins over next 1st (Jun 1). +check('0 0 1 * 1', '2026-05-04T00:00:00.000Z', '2026-05-11T00:00:00.000Z'); + +// Named months +check('0 0 1 JAN *', '2026-05-04T00:00:00.000Z', '2027-01-01T00:00:00.000Z'); + +// Unsatisfiable (Feb 31): cron-parser rejects up-front in strict mode. +let unsatThrew = false; +try { + parseCronExpression('0 0 31 2 *'); +} catch { + unsatThrew = true; +} +assert(unsatThrew, '0 0 31 2 * should be rejected as unsatisfiable'); +process.stdout.write( + ' 0 0 31 2 * rejected as unsatisfiable ✓\n' +); + +// Validation +let threw = false; +try { + parseCronExpression('invalid'); +} catch { + threw = true; +} +assert(threw, 'invalid expression should throw'); + +assert( + parseCronExpression(' 0 9 * * * ').expression === '0 9 * * *', + 'expression should be normalized' +); +for (const invalid of ['', ' ', '*'.repeat(MAX_CRON_EXPRESSION_LENGTH + 1)]) { + let invalidThrew = false; + try { + parseCronExpression(invalid); + } catch { + invalidThrew = true; + } + assert( + invalidThrew, + `expression should be rejected: ${JSON.stringify(invalid.slice(0, 20))}` + ); +} + +const hashed = parseCronExpression('H * * * *'); +const hashedFrom = + BigInt(new Date('2026-05-04T12:00:00.000Z').getTime()) * 1000n; +assert( + nextFireAfter(hashed, hashedFrom, 'UTC') === + nextFireAfter(hashed, hashedFrom, 'UTC'), + 'hashed expressions should resolve deterministically' +); + +process.stdout.write('\ntimezone tests\n'); + +// 9am Pacific: 17:00 UTC in PST, 16:00 UTC in PDT. +check( + '0 9 * * *', + '2026-01-15T00:00:00.000Z', + '2026-01-15T17:00:00.000Z', + 'America/Los_Angeles' +); +check( + '0 9 * * *', + '2026-07-15T00:00:00.000Z', + '2026-07-15T16:00:00.000Z', + 'America/Los_Angeles' +); + +// 9am Tokyo = 00:00 UTC same day. +check( + '0 9 * * *', + '2026-05-03T23:59:00.000Z', + '2026-05-04T00:00:00.000Z', + 'Asia/Tokyo' +); +// 00:00 UTC IS 9am Tokyo today; strict-after means next is tomorrow. +check( + '0 9 * * *', + '2026-05-04T00:00:00.000Z', + '2026-05-05T00:00:00.000Z', + 'Asia/Tokyo' +); + +// "Every Monday 9am" in NY (EST/EDT) +check( + '0 9 * * MON', + '2026-01-04T00:00:00.000Z', + '2026-01-05T14:00:00.000Z', + 'America/New_York' +); // Mon Jan 5 9am EST = 14:00 UTC + +// During the New York DST spring-forward, 02:30 resolves to the next available local time. +check( + '30 2 * * *', + '2026-03-08T00:00:00.000Z', + '2026-03-08T07:30:00.000Z', + 'America/New_York' +); + +// DST fall-back: 01:30 NY happens twice, fires once at first occurrence. +check( + '30 1 * * *', + '2026-10-31T23:59:59.000Z', + '2026-11-01T05:30:00.000Z', + 'America/New_York' +); +check( + '30 1 * * *', + '2026-11-01T05:30:00.000Z', + '2026-11-02T06:30:00.000Z', + 'America/New_York' +); + +// IANA validation +assert(isValidTimezone('UTC'), 'UTC should be valid'); +assert( + isValidTimezone('America/Los_Angeles'), + 'America/Los_Angeles should be valid' +); +assert(!isValidTimezone('Not_A_Real/Timezone'), 'bogus tz should be invalid'); +assert( + !isValidTimezone(' UTC'), + 'timezone with surrounding whitespace should be invalid' +); +assert( + !isValidTimezone('x'.repeat(MAX_TIMEZONE_LENGTH + 1)), + 'oversized timezone should be invalid' +); +assert( + nextFireAfter(parseCronExpression('* * * * *'), 0n, 'Not_A_Real/Timezone') === + undefined, + 'nextFireAfter should reject an invalid timezone' +); +process.stdout.write( + ' isValidTimezone gate ✓\n' +); + +process.stdout.write('\nall parser tests passed.\n'); diff --git a/spacetime-cron-ts/scripts/test-module-local.mjs b/spacetime-cron-ts/scripts/test-module-local.mjs new file mode 100644 index 00000000000..90b1f627937 --- /dev/null +++ b/spacetime-cron-ts/scripts/test-module-local.mjs @@ -0,0 +1,509 @@ +// Local integration suite for @spacetimedb/cron. Publishes the demo module +// (spacetimedb/) and the example module (example/spacetimedb/) against a +// running local SpacetimeDB and drives the full fire semantics: +// single-stage scheduled handlers, volatile failure recovery, lost-fire repair, +// payload rollback, auto-disable, procedures, generations, and cancellation. +// +// Requires: `spacetime start` running locally. +import * as assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +const database = `cron-release-${process.pid}-${Date.now()}`; +const exampleDatabase = `${database}-example`; +const serverArgs = ['--server', 'local']; +let published = false; +let examplePublished = false; + +function run(args, options = {}) { + const result = spawnSync('spacetime', args, { + cwd: new URL('..', import.meta.url), + encoding: 'utf8', + shell: false, + }); + if (options.expectFailure) { + assert.notEqual( + result.status, + 0, + `command unexpectedly succeeded: spacetime ${args.join(' ')}` + ); + return result; + } + assert.equal( + result.status, + 0, + `command failed: spacetime ${args.join(' ')}\n${result.stdout}\n${result.stderr}` + ); + return result; +} + +function call(name, ...args) { + return run(['call', ...serverArgs, database, name, ...args]); +} + +function callExpectFailure(name, ...args) { + return run(['call', ...serverArgs, database, name, ...args], { + expectFailure: true, + }); +} + +function sql(query, target = database) { + const result = run(['sql', ...serverArgs, '--format', 'json', target, query]); + return JSON.parse(result.stdout)[0].rows; +} + +function sqlExpectFailure(query, target = database) { + return run(['sql', ...serverArgs, '--format', 'json', target, query], { + expectFailure: true, + }); +} + +function count(table, where = '') { + return Number(sql(`SELECT COUNT(*) AS count FROM ${table} ${where}`)[0][0]); +} + +function jobRow(name) { + const rows = sql( + `SELECT enabled, fireCount, consecutiveFailures, disabledReason, generation FROM cron_job WHERE name = '${name}'` + ); + assert.equal(rows.length, 1, `expected one cron_job row for ${name}`); + const [enabled, fireCount, consecutiveFailures, disabledReason, generation] = + rows[0]; + return { + enabled, + fireCount, + consecutiveFailures, + disabledReason, + generation, + }; +} + +function publicDisabledReason(name) { + const rows = sql( + `SELECT disabledReason FROM cron_jobs WHERE name = '${name}'` + ); + assert.equal(rows.length, 1, `expected one cron_jobs row for ${name}`); + const option = rows[0][0]; + assert.deepEqual(option?.[0], 0, `expected a disabled reason for ${name}`); + return option[1]; +} + +function wait(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +async function poll(description, check, timeoutMilliseconds = 15_000) { + const deadline = Date.now() + timeoutMilliseconds; + let lastError; + + while (Date.now() < deadline) { + try { + const result = check(); + if (result) return result; + lastError = undefined; + } catch (error) { + lastError = error; + } + await wait(200); + } + + const detail = lastError instanceof Error ? `: ${lastError.message}` : ''; + throw new Error(`timed out waiting for ${description}${detail}`); +} + +try { + run([ + 'publish', + ...serverArgs, + '--yes', + '--module-path', + 'spacetimedb', + database, + ]); + published = true; + + // init seeded the heartbeat job (every 30s) declaratively. + assert.equal(count('cron_job'), 1, 'init must seed exactly one job'); + assert.equal( + count('cron_jobs'), + 1, + 'public job view must expose seeded state' + ); + assert.equal( + sql("SELECT name, enabled FROM cron_jobs WHERE name = 'heartbeat'").length, + 1, + 'public job view must support filtered operational queries' + ); + sqlExpectFailure('SELECT args FROM cron_jobs'); + assert.equal( + count('heartbeat_fire', "WHERE jobName = 'heartbeat'"), + 1, + 'seeded job must have exactly one pending fire' + ); + assert.equal( + count('cron_reconcile_tick'), + 1, + 'configured reconciliation must keep one native interval row' + ); + + // Management operations opportunistically repair an enabled job whose fire + // disappeared, record the loss, and preserve the one-row invariant. + call('drop_heartbeat_fire_for_test'); + assert.equal(count('heartbeat_fire'), 0); + assert.equal(jobRow('heartbeat').enabled, true); + call( + 'schedule_report', + JSON.stringify('0 0 1 1 *'), + JSON.stringify('UTC'), + '0', + JSON.stringify('reconcile-probe'), + '1' + ); + assert.equal( + count('heartbeat_fire', "WHERE jobName = 'heartbeat'"), + 1, + 'management scheduling must repair another enabled job' + ); + assert.equal( + count('cron_run', "WHERE jobName = 'heartbeat'"), + 1, + 'opportunistic repair must record lost_fire' + ); + assert.deepEqual( + sql("SELECT error FROM cron_run WHERE jobName = 'heartbeat'")[0][0], + [0, 'lost_fire'], + 'repair history must preserve the lost_fire reason' + ); + + // The optional interval sweep repairs the same invariant without a + // management call. + call('schedule_every', JSON.stringify('heartbeat'), '30', '0'); + call('drop_heartbeat_fire_for_test'); + await poll('the reconciliation sweep to restore heartbeat', () => { + return ( + count('heartbeat_fire', "WHERE jobName = 'heartbeat'") === 1 && + count('cron_run', "WHERE jobName = 'heartbeat'") >= 2 + ); + }); + assert.equal( + count('heartbeat_fire', "WHERE jobName = 'heartbeat'"), + 1, + 'reconciliation sweep must restore a missing fire' + ); + assert.equal( + count('cron_run', "WHERE jobName = 'heartbeat'"), + 2, + 'sweep repair must record lost_fire' + ); + + // Lost fires participate in the normal consecutive-failure policy. + call('schedule_every', JSON.stringify('heartbeat'), '30', '1'); + call('drop_heartbeat_fire_for_test'); + await poll( + 'the lost-fire policy to disable heartbeat', + () => jobRow('heartbeat').enabled === false + ); + const lostFireDisabled = jobRow('heartbeat'); + assert.equal(lostFireDisabled.enabled, false); + assert.match( + String(lostFireDisabled.disabledReason), + /failed_1_consecutive_times:lost_fire/ + ); + assert.equal( + publicDisabledReason('heartbeat'), + 'lost_fire_threshold_reached', + 'public view must expose a safe lost-fire reason code' + ); + assert.equal(count('heartbeat_fire'), 0); + call('schedule_every', JSON.stringify('heartbeat'), '30', '0'); + + // Chain job fires on cadence and keeps the single-pending-fire invariant. + call( + 'schedule_report', + JSON.stringify('*/1 * * * * *'), + JSON.stringify('UTC'), + '0', + JSON.stringify('primary'), + '25' + ); + await poll( + 'at least two report fires', + () => count('tick_log', "WHERE jobName = 'report'") >= 2 + ); + const reportTicks = count('tick_log', "WHERE jobName = 'report'"); + assert.ok( + reportTicks >= 2, + `expected at least two report fires, got ${reportTicks}` + ); + assert.equal( + count('report_fire', "WHERE jobName = 'report'"), + 1, + 'chain job must keep exactly one pending fire' + ); + const report = jobRow('report'); + assert.equal(report.enabled, true); + // Fires keep landing between queries, so bound rather than equate. + assert.ok( + Number(report.fireCount) >= reportTicks, + `fireCount ${report.fireCount} must cover observed ticks ${reportTicks}` + ); + const reportArgs = sql( + "SELECT value, count FROM argument_log WHERE jobName = 'report'" + ); + assert.ok(reportArgs.length >= 2, 'report arguments must reach every fire'); + assert.ok( + reportArgs.every( + ([value, batchSize]) => value === 'primary' && batchSize === 25 + ), + 'report fires must receive the scheduled typed arguments' + ); + + const reportGeneration = BigInt(report.generation); + call( + 'schedule_report', + JSON.stringify('*/1 * * * * *'), + JSON.stringify('UTC'), + '0', + JSON.stringify('replacement'), + '7' + ); + assert.equal( + BigInt(jobRow('report').generation), + reportGeneration + 1n, + 'rescheduling typed work must start a new generation' + ); + await poll( + 'the replacement report arguments', + () => + count( + 'argument_log', + "WHERE jobName = 'report' AND value = 'replacement' AND count = 7" + ) >= 1 + ); + assert.ok( + count( + 'argument_log', + "WHERE jobName = 'report' AND value = 'replacement' AND count = 7" + ) >= 1, + 'rescheduling must replace the durable arguments' + ); + + // A failed payload rolls back its writes. Volatile recovery rearms the + // calendar chain, records the failure, and disables at the threshold. + call( + 'schedule_cron', + JSON.stringify('flaky'), + JSON.stringify('*/1 * * * * *'), + JSON.stringify('UTC'), + '2' + ); + call('set_flaky_failing', 'true'); + await poll('the flaky job to reach its failure threshold', () => { + const state = jobRow('flaky'); + return !state.enabled && Number(state.consecutiveFailures) >= 2; + }); + assert.equal( + count('tick_log', "WHERE jobName = 'flaky'"), + 0, + 'failed payload writes must roll back' + ); + const flaky = jobRow('flaky'); + assert.equal(flaky.enabled, false, 'flaky must auto-disable'); + assert.equal(Number(flaky.consecutiveFailures), 2); + assert.match( + String(flaky.disabledReason), + /failed_2_consecutive_times:flaky\.failure/ + ); + assert.equal( + publicDisabledReason('flaky'), + 'failure_threshold_reached', + 'public view must not expose handler errors' + ); + assert.equal( + count('flaky_fire', "WHERE jobName = 'flaky'"), + 0, + 'auto-disabled job must have no pending fire' + ); + assert.equal( + count('cron_run', "WHERE jobName = 'flaky'"), + 2, + 'each failed invocation must have one durable run record' + ); + + // Native interval rows persist after a failed transaction. The explicit + // recovery payload must still record the failure and apply maxFailures. + const failuresBeforeInterval = count('cron_run', "WHERE jobName = 'flaky'"); + call('schedule_every', JSON.stringify('flaky'), '1', '1'); + await poll('the failed interval job to disable itself', () => { + const state = jobRow('flaky'); + return !state.enabled && Number(state.consecutiveFailures) >= 1; + }); + assert.equal( + count('cron_run', "WHERE jobName = 'flaky'"), + failuresBeforeInterval + 1, + 'interval recovery must commit one failed run record' + ); + assert.equal( + count('flaky_fire', "WHERE jobName = 'flaky'"), + 0, + 'an interval job disabled by maxFailures must remove its persistent fire' + ); + + // Recovery: fix the payload and reschedule; the chain resumes cleanly. + call('set_flaky_failing', 'false'); + call('schedule_every', JSON.stringify('flaky'), '1', '0'); + await poll( + 'the rescheduled flaky job to fire', + () => count('tick_log', "WHERE jobName = 'flaky'") >= 1 + ); + assert.ok( + count('tick_log', "WHERE jobName = 'flaky'") >= 1, + 'rescheduled job must fire again' + ); + assert.equal(jobRow('flaky').enabled, true); + + // Rescheduling creates a new generation so stale fire or recovery work cannot + // mutate the replacement schedule. + const recoveredGeneration = BigInt(jobRow('flaky').generation); + call('schedule_every', JSON.stringify('flaky'), '2', '0'); + assert.equal( + BigInt(jobRow('flaky').generation), + recoveredGeneration + 1n, + 'reschedule must increment the generation' + ); + assert.equal( + count('flaky_fire', "WHERE jobName = 'flaky'"), + 1, + 'reschedule must atomically replace the pending trigger' + ); + + // Procedure job: fires through the two-transaction middleware. + call( + 'schedule_probe', + JSON.stringify('*/1 * * * * *'), + JSON.stringify('UTC'), + '0', + JSON.stringify('health-check') + ); + await poll('the procedure job to fire with its arguments', () => { + return ( + count('tick_log', "WHERE jobName = 'probe'") >= 1 && + count( + 'argument_log', + "WHERE jobName = 'probe' AND value = 'health-check'" + ) >= 1 + ); + }); + assert.ok( + count('tick_log', "WHERE jobName = 'probe'") >= 1, + 'procedure job must fire' + ); + assert.ok( + count( + 'argument_log', + "WHERE jobName = 'probe' AND value = 'health-check'" + ) >= 1, + 'procedure job must receive its typed arguments' + ); + assert.equal(count('probe_fire', "WHERE jobName = 'probe'"), 1); + + // Unschedule disarms and disables without deleting state. + call('unschedule_job', JSON.stringify('probe')); + assert.equal( + count('probe_fire', "WHERE jobName = 'probe'"), + 0, + 'unschedule must disarm' + ); + assert.equal(jobRow('probe').enabled, false); + + // Procedure failures are recorded in a committed follow-up transaction and + // participate in the same automatic disable policy. + call('unschedule_job', JSON.stringify('flaky')); + call('set_flaky_failing', 'true'); + const successfulProbeTicks = count('tick_log', "WHERE jobName = 'probe'"); + call( + 'schedule_probe', + JSON.stringify('*/1 * * * * *'), + JSON.stringify('UTC'), + '1', + JSON.stringify('failure-check') + ); + await poll( + 'the failed procedure to disable itself', + () => jobRow('probe').enabled === false + ); + const failedProbe = jobRow('probe'); + assert.equal( + failedProbe.enabled, + false, + 'failed procedure must auto-disable' + ); + assert.equal(Number(failedProbe.consecutiveFailures), 1); + assert.match(String(failedProbe.disabledReason), /probe\.failure/); + assert.equal( + count('tick_log', "WHERE jobName = 'probe'"), + successfulProbeTicks, + 'failed procedure must not report successful application work' + ); + call('set_flaky_failing', 'false'); + + // History stays capped (default 5 per job) despite many report fires. + const reportFireCountBeforeHistoryCheck = Number(jobRow('report').fireCount); + await poll( + 'additional report fires for history pruning', + () => + Number(jobRow('report').fireCount) >= + reportFireCountBeforeHistoryCheck + 2 + ); + assert.ok( + count('cron_run', "WHERE jobName = 'report'") <= 5, + 'completed history must stay bounded' + ); + + // Validation rejections surface as failed calls before any row changes. + callExpectFailure( + 'schedule_cron', + JSON.stringify('report'), + JSON.stringify('not a cron'), + JSON.stringify('UTC'), + '0' + ); + callExpectFailure( + 'schedule_cron', + JSON.stringify('report'), + JSON.stringify('* * * * *'), + JSON.stringify('Mars/Olympus'), + '0' + ); + callExpectFailure('schedule_every', JSON.stringify('report'), '0', '0'); + callExpectFailure('schedule_every', JSON.stringify('nope'), '5', '0'); + + // Scheduled job reducers reject direct client calls. + callExpectFailure('forge_report_fire_for_test'); + + // The example module publishes and seeds its declared jobs. + run([ + 'publish', + ...serverArgs, + '--yes', + '--module-path', + 'example/spacetimedb', + exampleDatabase, + ]); + examplePublished = true; + assert.equal( + Number( + sql('SELECT COUNT(*) AS count FROM cron_job', exampleDatabase)[0][0] + ), + 2, + 'example init must seed digest and cleanup' + ); + + console.log('cron module local test passed'); +} finally { + if (published) { + run(['delete', ...serverArgs, '--yes', database]); + } + if (examplePublished) { + run(['delete', ...serverArgs, '--yes', exampleDatabase]); + } +} diff --git a/spacetime-cron-ts/scripts/test-recovery.mjs b/spacetime-cron-ts/scripts/test-recovery.mjs new file mode 100644 index 00000000000..eb5d28295d8 --- /dev/null +++ b/spacetime-cron-ts/scripts/test-recovery.mjs @@ -0,0 +1,307 @@ +// Crash-recovery integration test for @spacetimedb/cron. Runs an isolated +// SpacetimeDB instance so force-stopping it cannot disturb another server. The +// probe procedure remains in flight while the host is killed. The procedure's +// first transaction must preserve the calendar chain before the interruption. +import * as assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const dataDir = await mkdtemp(path.join(os.tmpdir(), 'cron-recovery-')); +const port = await reservePort(); +const serverUrl = `http://127.0.0.1:${port}`; +const database = 'cron-recovery'; +const serverLogs = []; +let server; + +function wait(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +async function reservePort() { + const listener = net.createServer(); + listener.unref(); + await new Promise((resolve, reject) => { + listener.once('error', reject); + listener.listen(0, '127.0.0.1', resolve); + }); + const address = listener.address(); + assert.ok(address && typeof address === 'object'); + const selected = address.port; + await new Promise((resolve, reject) => + listener.close(error => (error ? reject(error) : resolve())) + ); + return selected; +} + +function run(args) { + const result = spawnSync('spacetime', args, { + cwd: root, + encoding: 'utf8', + shell: false, + windowsHide: true, + }); + assert.ifError(result.error); + assert.equal( + result.status, + 0, + `command failed: spacetime ${args.join(' ')}\n${result.stdout}\n${result.stderr}` + ); + return result; +} + +function call(name, ...args) { + return run(['call', '--server', serverUrl, database, name, ...args]); +} + +function sql(query) { + const result = run([ + 'sql', + '--server', + serverUrl, + '--format', + 'json', + database, + query, + ]); + return JSON.parse(result.stdout)[0].rows; +} + +function statusTag(value) { + const tags = ['Ok', 'Failed']; + assert.ok(Array.isArray(value), `unexpected status encoding: ${value}`); + const tag = tags[value[0]]; + assert.ok(tag, `unknown status variant: ${value[0]}`); + return tag; +} + +function runs() { + return sql( + "SELECT invocationId, sequence, status FROM cron_run WHERE jobName = 'recovery_probe'" + ).map(([invocationId, sequence, status]) => ({ + invocationId, + sequence: BigInt(sequence), + status: statusTag(status), + })); +} + +function job() { + const rows = sql( + "SELECT enabled, fireCount, consecutiveFailures FROM cron_job WHERE name = 'recovery_probe'" + ); + assert.equal(rows.length, 1, 'expected the recovery probe job'); + const [enabled, fireCount, consecutiveFailures] = rows[0]; + return { + enabled, + fireCount: BigInt(fireCount), + consecutiveFailures: Number(consecutiveFailures), + }; +} + +function blockingInvocationId() { + const rows = sql( + 'SELECT invocationId FROM recovery_probe_state WHERE singleton = true' + ); + return rows[0]?.[0]; +} + +async function poll(description, check, timeoutMilliseconds = 30_000) { + const deadline = Date.now() + timeoutMilliseconds; + let lastError; + while (Date.now() < deadline) { + try { + const result = await check(); + if (result) return result; + lastError = undefined; + } catch (error) { + lastError = error; + } + await wait(200); + } + const detail = lastError instanceof Error ? `: ${lastError.message}` : ''; + throw new Error(`timed out waiting for ${description}${detail}`); +} + +function startServer() { + const child = spawn( + 'spacetime', + [ + 'start', + '--listen-addr', + `127.0.0.1:${port}`, + '--data-dir', + dataDir, + '--non-interactive', + ], + { + cwd: root, + shell: false, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + let output = ''; + const capture = chunk => { + output = `${output}${chunk}`.slice(-20_000); + }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + serverLogs.push(() => output); + return child; +} + +async function waitForServer(child) { + await poll( + 'isolated SpacetimeDB server', + () => + new Promise(resolve => { + if (child.exitCode !== null) { + resolve(false); + return; + } + const socket = net.createConnection({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + socket.setTimeout(500, () => { + socket.destroy(); + resolve(false); + }); + }) + ); + if (child.exitCode !== null) { + throw new Error(`isolated server exited early\n${serverLogs.at(-1)?.()}`); + } +} + +async function stopServer(child) { + if (!child || child.exitCode !== null) return; + const exited = once(child, 'exit'); + if (process.platform === 'win32') { + spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { + encoding: 'utf8', + windowsHide: true, + }); + } else { + child.kill('SIGKILL'); + } + await Promise.race([exited, wait(5_000)]); + if (child.exitCode === null) { + throw new Error(`failed to stop isolated server process ${child.pid}`); + } +} + +try { + server = startServer(); + await waitForServer(server); + run([ + 'publish', + '--server', + serverUrl, + '--yes', + '--module-path', + 'spacetimedb', + database, + ]); + + call( + 'schedule_cron', + JSON.stringify('recovery_probe'), + JSON.stringify('*/1 * * * * *'), + JSON.stringify('UTC'), + '0' + ); + const blockedInvocation = await poll( + 'the recovery probe to enter its blocking section', + blockingInvocationId + ); + assert.match(blockedInvocation, /^recovery_probe:[0-9]+:1$/); + assert.equal( + job().fireCount, + 1n, + 'the first transaction must reserve exactly one invocation before blocking' + ); + assert.equal( + runs().some(run => run.sequence === 1n), + false, + 'the blocked invocation must not have a completed outcome' + ); + assert.equal( + Number( + sql( + "SELECT COUNT(*) AS count FROM tick_log WHERE jobName = 'recovery_probe'" + )[0][0] + ), + 0, + 'the blocked handler must not record completed application work' + ); + assert.equal( + Number( + sql( + "SELECT COUNT(*) AS count FROM recovery_probe_fire WHERE jobName = 'recovery_probe'" + )[0][0] + ), + 1, + 'the first transaction must persist the successor before blocking' + ); + + await stopServer(server); + server = undefined; + + // Leave several calendar occurrences behind us. Recovery should execute at + // most one overdue occurrence before resuming from the current time. + await wait(5_000); + + server = startServer(); + await waitForServer(server); + await poll('a successful post-restart recovery probe', () => { + const observed = runs(); + const recovered = observed.some( + run => run.sequence > 1n && run.status === 'Ok' + ); + const state = job(); + return ( + recovered && + state.enabled && + state.fireCount >= 2n && + state.consecutiveFailures === 0 + ); + }); + await wait(500); + assert.ok( + job().fireCount <= 3n, + 'calendar recovery must not replay every occurrence missed during downtime' + ); + assert.equal( + Number( + sql( + "SELECT COUNT(*) AS count FROM recovery_probe_fire WHERE jobName = 'recovery_probe'" + )[0][0] + ), + 1, + 'the recovered calendar job must retain one successor trigger' + ); + + console.log('cron crash-recovery test passed'); +} catch (error) { + const logs = serverLogs.map( + (read, index) => `server ${index + 1}:\n${read()}` + ); + if (logs.length > 0) console.error(logs.join('\n')); + throw error; +} finally { + await stopServer(server); + await rm(dataDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }); +} diff --git a/spacetime-cron-ts/scripts/test-registration.ts b/spacetime-cron-ts/scripts/test-registration.ts new file mode 100644 index 00000000000..2284076cc79 --- /dev/null +++ b/spacetime-cron-ts/scripts/test-registration.ts @@ -0,0 +1,218 @@ +import * as assert from 'node:assert/strict'; +import { spacetimeCron } from '../src/cron'; +import type { CronSdk } from '../src/types'; + +interface DynamicValue { + (...args: unknown[]): DynamicValue; + readonly [key: PropertyKey]: DynamicValue; +} + +function dynamicValue(): DynamicValue { + const callable = () => dynamicValue(); + return new Proxy(callable, { + apply: () => dynamicValue(), + get: () => dynamicValue(), + }) as DynamicValue; +} + +function toCamelCase(value: string): string { + const converted = value + .replace(/[-_]+/g, '_') + .replace(/_([a-zA-Z0-9])/g, (_match, character: string) => + character.toUpperCase() + ); + return converted.charAt(0).toLowerCase() + converted.slice(1); +} + +const registrationSchema = { + reducer: (..._args: unknown[]) => ({}), + procedure: (..._args: unknown[]) => ({}), + anonymousView: (..._args: unknown[]) => ({}), +}; + +function createApi() { + return spacetimeCron({ + table: dynamicValue(), + t: dynamicValue(), + toCamelCase, + ScheduleAt: dynamicValue(), + Timestamp: dynamicValue(), + SenderError: dynamicValue(), + } as unknown as CronSdk); +} + +{ + const owner = createApi(); + const foreign = createApi().cronTable({ name: 'foreign' }); + assert.throws(() => owner.createCron([foreign]), /cron\.foreign_job_handle/); +} + +{ + const api = createApi(); + const job = api.cronTable({ name: 'duplicate' }); + assert.throws( + () => api.createCron([job, job]), + /cron\.duplicate_job:duplicate/ + ); +} + +{ + const api = createApi(); + const first = api.cronTable({ name: 'first' }); + const second = api.cronTable({ name: 'second' }); + api.createCron([first]); + assert.throws( + () => api.createCron([second]), + /cron\.multiple_cores_not_supported/ + ); +} + +{ + const api = createApi(); + const job = api.cronTable({ name: 'not_wired' }); + assert.throws( + () => job.cronReducer(registrationSchema, () => {}), + /cron\.not_wired:not_wired/ + ); +} + +{ + const api = createApi(); + const job = api.cronTable({ name: 'missing_handler' }); + const cron = api.createCron([job], { reconcileEverySeconds: 60 }); + assert.throws( + () => cron.reconcileReducer(registrationSchema), + /cron\.missing_handlers:missing_handler/ + ); +} + +{ + const api = createApi(); + const job = api.cronTable({ name: 'unscheduled_handler' }); + api.createCron([job]); + assert.throws( + () => api.schedule({}, job, { everySeconds: 60 }), + /cron\.missing_handlers:unscheduled_handler/ + ); +} + +{ + const api = createApi(); + const job = api.cronTable({ name: 'duplicate_handler' }); + api.createCron([job]); + job.cronReducer(registrationSchema, () => {}); + assert.throws( + () => job.cronProcedure(registrationSchema, () => {}), + /cron\.handler_already_registered:duplicate_handler:reducer/ + ); +} + +{ + const api = createApi(); + const job = api.cronTable({ name: 'no_reconciler' }); + const cron = api.createCron([job]); + job.cronReducer(registrationSchema, () => {}); + assert.throws( + () => cron.reconcileReducer(registrationSchema), + /cron\.reconcile_not_configured/ + ); +} + +{ + const api = createApi(); + const job = api.cronTable({ name: 'reconcile_once' }); + const cron = api.createCron([job], { reconcileEverySeconds: 60 }); + job.cronReducer(registrationSchema, () => {}); + cron.reconcileReducer(registrationSchema); + assert.throws( + () => cron.reconcileReducer(registrationSchema), + /cron\.reconcile_reducer_already_registered/ + ); +} + +{ + const api = createApi(); + const job = api.cronTable({ name: 'views_once' }); + const cron = api.createCron([job]); + cron.publicViews(registrationSchema); + assert.throws( + () => cron.publicViews(registrationSchema), + /cron\.public_views_already_registered/ + ); +} + +{ + const api = createApi(); + const firstJob = api.cronTable({ name: 'first_job' }); + const secondJob = api.cronTable({ name: 'second_job' }); + const cron = api.createCron([firstJob, secondJob], { + reconcileEverySeconds: 60, + }); + assert.deepEqual(Object.keys(cron.tables).sort(), [ + 'cronJob', + 'cronReconcileTick', + 'cronRun', + 'firstJobFire', + 'secondJobFire', + ]); +} + +{ + let viewBody: ((ctx: unknown) => unknown) | undefined; + const schemaWithInspectableView = { + ...registrationSchema, + anonymousView: (...args: unknown[]) => { + viewBody = args[2] as (ctx: unknown) => unknown; + return {}; + }, + }; + const api = createApi(); + const job = api.cronTable({ name: 'sanitized_view' }); + const cron = api.createCron([job]); + cron.publicViews(schemaWithInspectableView); + assert.ok(viewBody); + + const baseRow = { + name: 'sanitized_view', + schedule: { tag: 'every', value: { seconds: 60 } }, + args: { tag: 'sanitized_view', value: undefined }, + enabled: false, + maxFailures: 1, + consecutiveFailures: 1, + fireCount: 1n, + generation: 1n, + lastRunAt: undefined, + nextRunAt: undefined, + }; + const privateReasons = [ + 'failed_1_consecutive_times:secret application failure', + 'failed_1_consecutive_times:lost_fire', + 'cron.invalid_schedule_state:secret parser detail', + 'disabled_by_operator', + 'unrecognized private detail', + ]; + const publicRows = viewBody({ + db: { + cronJob: { + iter: () => + privateReasons.map(disabledReason => ({ + ...baseRow, + disabledReason, + })), + }, + }, + }) as Array<{ disabledReason: string }>; + + assert.deepEqual( + publicRows.map(row => row.disabledReason), + [ + 'failure_threshold_reached', + 'lost_fire_threshold_reached', + 'invalid_schedule_state', + 'disabled_by_operator', + 'disabled', + ] + ); +} + +console.log('cron registration tests passed'); diff --git a/spacetime-cron-ts/scripts/test-schedule.ts b/spacetime-cron-ts/scripts/test-schedule.ts new file mode 100644 index 00000000000..cbd4e4f0786 --- /dev/null +++ b/spacetime-cron-ts/scripts/test-schedule.ts @@ -0,0 +1,199 @@ +// Unit tests for calendar occurrence computation. +// Run: pnpm test:schedule +import { + parseCronExpression, + nextFireAfter, + isValidTimezone, +} from '../src/parser'; + +let failures = 0; +function check(name: string, cond: boolean, detail?: string) { + if (cond) { + console.log(`ok ${name}`); + } else { + failures++; + console.error(`FAIL ${name}${detail ? `: ${detail}` : ''}`); + } +} + +const MICROS = 1000n; +function utcMicros( + y: number, + mo: number, + d: number, + h = 0, + mi = 0, + s = 0 +): bigint { + return BigInt(Date.UTC(y, mo - 1, d, h, mi, s)) * MICROS; +} +function fmt(micros: bigint | undefined, tz = 'UTC'): string { + if (micros === undefined) return 'undefined'; + return new Date(Number(micros / MICROS)).toLocaleString('en-US', { + timeZone: tz, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +// 1. Basic calendar step: next minute boundary, strictly after `now`. +{ + const parsed = parseCronExpression('* * * * *'); + const now = utcMicros(2026, 8, 12, 10, 0, 30); // 10:00:30 + const next = nextFireAfter(parsed, now, 'UTC'); + check( + 'next-minute boundary', + next === utcMicros(2026, 8, 12, 10, 1, 0), + fmt(next) + ); +} + +// 2. Strictly-after semantics: asking at an exact occurrence must return the NEXT one. +{ + const parsed = parseCronExpression('* * * * *'); + const now = utcMicros(2026, 8, 12, 10, 1, 0); // exactly on an occurrence + const next = nextFireAfter(parsed, now, 'UTC'); + check( + 'strictly-after at exact occurrence', + next === utcMicros(2026, 8, 12, 10, 2, 0), + fmt(next) + ); +} + +// 3. Seconds granularity (6-field cron, used by the integration test). +{ + const parsed = parseCronExpression('*/2 * * * * *'); + const now = utcMicros(2026, 8, 12, 10, 0, 1); + const next = nextFireAfter(parsed, now, 'UTC'); + check( + 'seconds-granularity */2', + next === utcMicros(2026, 8, 12, 10, 0, 2), + fmt(next) + ); +} + +// 4. Timezone correctness: 09:00 America/New_York in August = 13:00 UTC (EDT, UTC-4). +{ + const parsed = parseCronExpression('0 9 * * *'); + const now = utcMicros(2026, 8, 12, 0, 0, 0); + const next = nextFireAfter(parsed, now, 'America/New_York'); + check('tz offset EDT', next === utcMicros(2026, 8, 12, 13, 0, 0), fmt(next)); +} + +// 5. DST spring-forward: 2026-03-08 02:30 does not exist in America/New_York. +// The job must not be lost and must not fire twice; document what it does. +{ + const parsed = parseCronExpression('30 2 * * *'); + const start = utcMicros(2026, 3, 7, 12, 0, 0); + const a = nextFireAfter(parsed, start, 'America/New_York'); + const b = + a !== undefined ? nextFireAfter(parsed, a, 'America/New_York') : undefined; + const c = + b !== undefined ? nextFireAfter(parsed, b, 'America/New_York') : undefined; + // Starting after noon on March 7 makes the first result the March 8 slot. + check( + 'spring-forward: occurrence exists', + a !== undefined && b !== undefined && c !== undefined + ); + check( + 'spring-forward: monotonic chain', + a !== undefined && b !== undefined && c !== undefined && a < b && b < c, + `${fmt(a, 'America/New_York')} | ${fmt(b, 'America/New_York')} | ${fmt(c, 'America/New_York')}` + ); + console.log( + ` info: 02:30 chain around spring-forward fires at: ${fmt(a, 'America/New_York')}, ${fmt(b, 'America/New_York')}, ${fmt(c, 'America/New_York')} (local NY time)` + ); +} + +// 6. DST fall-back: 2026-11-01 01:30 occurs twice in America/New_York. +// The chain must fire exactly once per calendar day, not twice. +{ + const parsed = parseCronExpression('30 1 * * *'); + const start = utcMicros(2026, 10, 31, 12, 0, 0); + const fires: bigint[] = []; + let cursor: bigint | undefined = start; + for (let i = 0; i < 3 && cursor !== undefined; i++) { + cursor = nextFireAfter(parsed, cursor, 'America/New_York'); + if (cursor !== undefined) fires.push(cursor); + } + const days = fires.map(f => + new Date(Number(f / MICROS)).toLocaleDateString('en-US', { + timeZone: 'America/New_York', + }) + ); + check( + 'fall-back: one fire per day', + new Set(days).size === days.length, + fires.map(f => fmt(f, 'America/New_York')).join(' | ') + ); + console.log( + ` info: 01:30 chain around fall-back fires at: ${fires.map(f => fmt(f, 'America/New_York')).join(', ')} (local NY time)` + ); +} + +// 7a. Statically impossible expressions are rejected at PARSE time (finding: +// cron-parser refuses Feb 30 outright, so create_job's validateSchedule +// catches these as invalid_expression before any row exists). +{ + let threw = false; + try { + parseCronExpression('0 0 30 2 *'); + } catch { + threw = true; + } + check('impossible Feb 30 rejected at parse', threw); +} + +// 7b. nextFireAfter -> undefined is still reachable at JS date bounds, so the +// fire reducer's disable-loudly path has a real (if exotic) trigger. +{ + const parsed = parseCronExpression('0 0 1 1 *'); + const nearMax = 8_640_000_000_000_000n * 1000n - 1_000_000n; // ~max JS date, in micros + const next = nextFireAfter(parsed, nearMax, 'UTC'); + check('date-bound overflow -> undefined', next === undefined, fmt(next)); +} + +// 8. Chain-step simulation: late fire does not drift the schedule. +// Job '0 * * * *' (hourly). Fire lands 7 minutes late; next must still be the +// top of the NEXT hour, not lateFire+1h. +{ + const parsed = parseCronExpression('0 * * * *'); + const lateFire = utcMicros(2026, 8, 12, 9, 7, 0); // fired late at 09:07 + const next = nextFireAfter(parsed, lateFire, 'UTC'); + check( + 'late fire re-arms nominal slot', + next === utcMicros(2026, 8, 12, 10, 0, 0), + fmt(next) + ); +} + +// 9. Catch-up semantics: an armed fire far in the past re-arms to the next FUTURE +// occurrence, skipping intermediate ones. This is one catch-up run. +{ + const parsed = parseCronExpression('*/5 * * * *'); + const wokeUpAt = utcMicros(2026, 8, 12, 11, 3, 0); // was down for hours + const next = nextFireAfter(parsed, wokeUpAt, 'UTC'); + check( + 'catch-up skips missed occurrences', + next === utcMicros(2026, 8, 12, 11, 5, 0), + fmt(next) + ); +} + +// 10. Timezone validation. +{ + check('valid tz', isValidTimezone('America/New_York')); + check('invalid tz rejected', !isValidTimezone('Mars/Olympus_Mons')); +} + +console.log( + failures === 0 + ? '\nAll schedule tests passed.' + : `\n${failures} test(s) FAILED.` +); +process.exit(failures === 0 ? 0 : 1); diff --git a/spacetime-cron-ts/scripts/test-types.ts b/spacetime-cron-ts/scripts/test-types.ts new file mode 100644 index 00000000000..5d8c2d78e7d --- /dev/null +++ b/spacetime-cron-ts/scripts/test-types.ts @@ -0,0 +1,67 @@ +// Compile-time API coverage. This file is checked by `pnpm typecheck`. +import { t } from 'spacetimedb/server'; +import type { CronApi, CronInvocation, CronJobReference } from '../src/index'; + +declare const cron: CronApi; +declare const ctx: object; +declare const spacetimedb: unknown; + +const heartbeat = cron.cronTable({ name: 'heartbeat' }); +const reportDefinition = { + name: 'report', + args: t.object('TypeTestReportArgs', { + tenantId: t.u64(), + batchSize: t.u32(), + }), +} as const; +const report = cron.cronTable(reportDefinition); + +heartbeat.cronReducer(spacetimedb, (_ctx: object, invocation) => { + const checked: CronInvocation = invocation; + void checked; +}); + +report.cronReducer(spacetimedb, (_ctx: object, args, invocation) => { + const tenantId: bigint = args.tenantId; + const batchSize: number = args.batchSize; + const checked: CronInvocation = invocation; + void tenantId; + void batchSize; + void checked; +}); + +cron.schedule(ctx, heartbeat, { everySeconds: 30 }); +cron.schedule(ctx, heartbeat, '0 * * * *', { timezone: 'UTC' }); +cron.schedule(ctx, report, '0 9 * * *', { + timezone: 'UTC', + args: { tenantId: 42n, batchSize: 100 }, +}); + +// @ts-expect-error Argument-bearing jobs require scheduling options with args. +cron.schedule(ctx, report, '0 9 * * *'); +// @ts-expect-error Argument-bearing jobs require an args property. +cron.schedule(ctx, report, '0 9 * * *', { timezone: 'UTC' }); +cron.schedule(ctx, report, '0 9 * * *', { + // @ts-expect-error tenantId is a u64 and therefore a bigint. + args: { tenantId: 42, batchSize: 100 }, +}); +cron.schedule( + ctx, + heartbeat, + { everySeconds: 30 }, + { + // @ts-expect-error Argumentless jobs do not accept an args property. + args: {}, + } +); + +const reference: CronJobReference = report; +cron.unschedule(ctx, reference); + +const core = cron.createCron([heartbeat, report], { + reconcileEverySeconds: 300, +}); +const reconcile = core.reconcileReducer(spacetimedb); +const publicViews = core.publicViews(spacetimedb); +void reconcile; +void publicViews.jobs; diff --git a/spacetime-cron-ts/scripts/test-validation.ts b/spacetime-cron-ts/scripts/test-validation.ts new file mode 100644 index 00000000000..549497ea7fc --- /dev/null +++ b/spacetime-cron-ts/scripts/test-validation.ts @@ -0,0 +1,97 @@ +import * as assert from 'node:assert/strict'; +import { + boundedScheduleTime, + CHECKPOINT_DELAY_MICROS, + MAX_ERROR_LENGTH, + MAX_FAILURES, + MAX_HISTORY_CAP, + MAX_INTERVAL_SECONDS, + normalizeHistoryCap, + normalizeJobArgs, + normalizeJobName, + normalizeMaxFailures, + normalizeReconcileEverySeconds, + normalizeSchedule, + truncateError, +} from '../src/schedule'; + +const utcMicros = (iso: string) => BigInt(Date.parse(iso)) * 1_000n; + +assert.equal(normalizeJobName('daily_report'), 'daily_report'); +for (const invalid of [ + '', + 'DailyReport', + 'daily-report', + '_daily', + 'daily__report', +]) { + assert.throws(() => normalizeJobName(invalid), /cron\.invalid_job_name/); +} +assert.throws(() => normalizeJobName(`a${'b'.repeat(48)}`), /invalid_job_name/); + +const now = utcMicros('2028-03-01T00:00:00.000Z'); +for (const everySeconds of [0, -1, 1.5, MAX_INTERVAL_SECONDS + 1]) { + assert.throws( + () => normalizeSchedule({ everySeconds }, undefined, now), + /cron\.invalid_interval/ + ); +} +assert.equal( + normalizeSchedule({ everySeconds: 30 }, undefined, now).firstAt, + now + 30_000_000n +); + +for (const maxFailures of [-1, 1.5, MAX_FAILURES + 1]) { + assert.throws( + () => normalizeMaxFailures(maxFailures), + /invalid_max_failures/ + ); +} +assert.equal(normalizeMaxFailures(undefined), 0); +assert.equal(normalizeMaxFailures(MAX_FAILURES), MAX_FAILURES); + +assert.deepEqual(normalizeJobArgs('heartbeat', false, undefined), {}); +assert.deepEqual( + normalizeJobArgs('report', true, { args: { tenantId: 42n } }), + { tenantId: 42n } +); +assert.equal( + normalizeJobArgs('optional', true, { args: undefined }), + undefined +); +assert.throws( + () => normalizeJobArgs('report', true, undefined), + /cron\.missing_args:report/ +); +assert.throws( + () => normalizeJobArgs('heartbeat', false, { args: {} }), + /cron\.unexpected_args:heartbeat/ +); + +for (const historyCap of [-1, 1.5, MAX_HISTORY_CAP + 1]) { + assert.throws(() => normalizeHistoryCap(historyCap), /invalid_history_cap/); +} +assert.equal(normalizeHistoryCap(undefined), 5); + +for (const seconds of [0, -1, 1.5, MAX_INTERVAL_SECONDS + 1]) { + assert.throws( + () => normalizeReconcileEverySeconds(seconds), + /invalid_reconcile_interval/ + ); +} +assert.equal(normalizeReconcileEverySeconds(undefined), undefined); +assert.equal(normalizeReconcileEverySeconds(300), 300); + +const leapDay = normalizeSchedule('0 0 29 2 *', { timezone: 'UTC' }, now); +assert.ok(leapDay.firstAt !== undefined); +assert.ok(leapDay.firstAt - now > CHECKPOINT_DELAY_MICROS); +assert.equal( + boundedScheduleTime(now, leapDay.firstAt), + now + CHECKPOINT_DELAY_MICROS +); + +const longError = 'x'.repeat(MAX_ERROR_LENGTH + 100); +assert.equal(truncateError(longError).length, MAX_ERROR_LENGTH); +assert.match(truncateError(longError), /\.\.\.$/); + +console.log('cron validation tests passed'); diff --git a/spacetime-cron-ts/spacetimedb/package.json b/spacetime-cron-ts/spacetimedb/package.json new file mode 100644 index 00000000000..8a8465d280f --- /dev/null +++ b/spacetime-cron-ts/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-cron-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-cron", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-cron" + }, + "dependencies": { + "@spacetimedb/cron": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-cron-ts/spacetimedb/src/index.ts b/spacetime-cron-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..ed43828cdfd --- /dev/null +++ b/spacetime-cron-ts/spacetimedb/src/index.ts @@ -0,0 +1,338 @@ +// Canonical @spacetimedb/cron demo and integration fixture. It covers typed +// jobs, reducer rollback, procedure failures, recovery, and runtime management. +import { + schema, + table, + t, + SenderError, + toCamelCase, + type ReducerCtx, + type InferSchema, + type ProcedureCtx, +} from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { + spacetimeCron, + type CronJobHandle, + type CronJobReference, +} from '@spacetimedb/cron'; + +// One injection point: hand the library this module's own SDK objects so the +// bundle contains exactly one copy of the spacetimedb SDK. +const { cronTable, createCron, schedule, unschedule } = spacetimeCron({ + table, + t, + toCamelCase, + ScheduleAt, + Timestamp, + SenderError, +}); + +// ── Jobs ───────────────────────────────────────────────────────────────────── + +const heartbeat = cronTable({ name: 'heartbeat' }); +const report = cronTable({ + name: 'report', + args: t.object('ReportCronArgs', { + label: t.string(), + batchSize: t.u32(), + }), +}); +const flaky = cronTable({ name: 'flaky' }); +const probe = cronTable({ + name: 'probe', + args: t.object('ProbeCronArgs', { source: t.string() }), +}); +const recoveryProbe = cronTable({ name: 'recovery_probe' }); + +const cron = createCron([heartbeat, report, flaky, probe, recoveryProbe], { + publicTables: true, + reconcileEverySeconds: 2, +}); + +// ── App tables ─────────────────────────────────────────────────────────────── + +const tickLog = table( + { name: 'tick_log', public: true }, + { + id: t.u64().primaryKey().autoInc(), + jobName: t.string().index(), + at: t.timestamp(), + } +); + +const flakyState = table( + { name: 'flaky_state', public: true }, + { + singleton: t.bool().primaryKey(), + failing: t.bool(), + } +); + +const argumentLog = table( + { name: 'argument_log', public: true }, + { + id: t.u64().primaryKey().autoInc(), + jobName: t.string().index(), + value: t.string(), + count: t.u32(), + invocationId: t.string(), + } +); + +const recoveryProbeState = table( + { name: 'recovery_probe_state' }, + { + singleton: t.bool().primaryKey(), + invocationId: t.string(), + } +); + +const spacetimedb = schema({ + ...cron.tables, + tickLog, + flakyState, + argumentLog, + recoveryProbeState, +}); +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx; +type Proc = ProcedureCtx; + +// ── Cron wiring ────────────────────────────────────────────────────────────── + +export const runHeartbeat = heartbeat.cronReducer(spacetimedb, (ctx: Tx) => { + ctx.db.tickLog.insert({ id: 0n, jobName: 'heartbeat', at: ctx.timestamp }); +}); + +export const runReport = report.cronReducer( + spacetimedb, + (ctx: Tx, args, invocation) => { + ctx.db.tickLog.insert({ id: 0n, jobName: 'report', at: ctx.timestamp }); + ctx.db.argumentLog.insert({ + id: 0n, + jobName: 'report', + value: args.label, + count: args.batchSize, + invocationId: invocation.id, + }); + } +); + +// Integration-only authorization probe. Direct calls must not execute a +// reducer that is reserved for the scheduler. +export const forgeReportFireForTest = spacetimedb.reducer(ctx => { + const fireTable = ctx.db.reportFire as unknown as { + iter(): Iterable; + }; + const fire = [...fireTable.iter()][0]; + if (!fire) throw new SenderError('cron.test_missing_report_fire'); + const invoke = runReport as unknown as ( + ctx: Tx, + args: { arg: unknown } + ) => void; + invoke(ctx, { arg: fire }); +}); + +// Handler writes roll back on failure. Volatile recovery records the failure +// and restores calendar jobs after the scheduled transaction aborts. +export const runFlaky = flaky.cronReducer(spacetimedb, (ctx: Tx) => { + ctx.db.tickLog.insert({ id: 0n, jobName: 'flaky', at: ctx.timestamp }); + const state = ctx.db.flakyState.singleton.find(true); + if (state?.failing) throw new Error('flaky.failure'); +}); + +// Procedure job: the natural home for side-effecting work such as outbound +// HTTP. Here it writes through withTx so the local suite can observe it. +export const runProbe = probe.cronProcedure( + spacetimedb, + (ctx: Proc, args, invocation) => { + const failing = ctx.withTx( + (tx: Tx) => tx.db.flakyState.singleton.find(true)?.failing ?? false + ); + if (failing) throw new Error('probe.failure'); + ctx.withTx((tx: Tx) => { + tx.db.tickLog.insert({ id: 0n, jobName: 'probe', at: tx.timestamp }); + tx.db.argumentLog.insert({ + id: 0n, + jobName: 'probe', + value: args.source, + count: 0, + invocationId: invocation.id, + }); + }); + } +); + +function blockUntilHostStops(): never { + for (;;) { + // The recovery test terminates the isolated host after observing its + // durable entry marker. V8 2.8 does not time out this execution path. + } +} + +// The first run remains in flight until the recovery test kills its isolated +// host. Its separate transactions prove what survives the interruption. +export const runRecoveryProbe = recoveryProbe.cronProcedure( + spacetimedb, + (ctx: Proc, run) => { + if (run.sequence === 1n) { + ctx.withTx((tx: Tx) => { + tx.db.recoveryProbeState.insert({ + singleton: true, + invocationId: run.id, + }); + }); + blockUntilHostStops(); + } + ctx.withTx((tx: Tx) => { + tx.db.tickLog.insert({ + id: 0n, + jobName: 'recovery_probe', + at: tx.timestamp, + }); + }); + } +); + +export const cronReconcile = cron.reconcileReducer(spacetimedb); +export const { jobs: cronJobs } = cron.publicViews(spacetimedb); + +export const init = spacetimedb.init(ctx => { + ctx.db.flakyState.insert({ singleton: true, failing: false }); + // Code-declared default for a fresh database. + schedule(ctx, heartbeat, { everySeconds: 30 }); +}); + +// ── CLI-facing management (thin wrappers over the library helpers) ─────────── + +const argumentlessJobs: Record = { + heartbeat, + flaky, + recovery_probe: recoveryProbe, +}; + +const allJobs: Record = { + ...argumentlessJobs, + report, + probe, +}; + +function argumentlessJobByName(name: string): CronJobHandle { + const job = argumentlessJobs[name]; + if (!job) throw new SenderError(`cron.unknown_job:${name}`); + return job; +} + +function jobByName(name: string): CronJobReference { + const job = allJobs[name]; + if (!job) throw new SenderError(`cron.unknown_job:${name}`); + return job; +} + +function scheduleSafely(operation: () => void): void { + try { + operation(); + } catch (err) { + throw err instanceof SenderError + ? err + : new SenderError(err instanceof Error ? err.message : String(err)); + } +} + +export const scheduleCron = spacetimedb.reducer( + { + name: t.string(), + expression: t.string(), + timezone: t.string(), + maxFailures: t.u32(), + }, + (ctx, args) => { + scheduleSafely(() => { + schedule(ctx, argumentlessJobByName(args.name), args.expression, { + timezone: args.timezone, + maxFailures: args.maxFailures, + }); + }); + } +); + +export const scheduleEvery = spacetimedb.reducer( + { name: t.string(), seconds: t.u32(), maxFailures: t.u32() }, + (ctx, args) => { + scheduleSafely(() => { + schedule( + ctx, + argumentlessJobByName(args.name), + { everySeconds: args.seconds }, + { maxFailures: args.maxFailures } + ); + }); + } +); + +export const scheduleReport = spacetimedb.reducer( + { + expression: t.string(), + timezone: t.string(), + maxFailures: t.u32(), + label: t.string(), + batchSize: t.u32(), + }, + (ctx, args) => { + scheduleSafely(() => { + schedule(ctx, report, args.expression, { + timezone: args.timezone, + maxFailures: args.maxFailures, + args: { label: args.label, batchSize: args.batchSize }, + }); + }); + } +); + +export const scheduleProbe = spacetimedb.reducer( + { + expression: t.string(), + timezone: t.string(), + maxFailures: t.u32(), + source: t.string(), + }, + (ctx, args) => { + scheduleSafely(() => { + schedule(ctx, probe, args.expression, { + timezone: args.timezone, + maxFailures: args.maxFailures, + args: { source: args.source }, + }); + }); + } +); + +export const unscheduleJob = spacetimedb.reducer( + { name: t.string() }, + (ctx, { name }) => { + unschedule(ctx, jobByName(name)); + } +); + +export const setFlakyFailing = spacetimedb.reducer( + { failing: t.bool() }, + (ctx, { failing }) => { + const state = ctx.db.flakyState.singleton.find(true); + if (state) ctx.db.flakyState.singleton.update({ ...state, failing }); + } +); + +// Integration-only fault injection for the lost-fire reconciler. +export const dropHeartbeatFireForTest = spacetimedb.reducer(ctx => { + const fireTable = ctx.db.heartbeatFire as unknown as { + iter(): Iterable<{ jobName: string }>; + delete(row: { jobName: string }): void; + }; + const pending = [...fireTable.iter()].find( + row => row.jobName === 'heartbeat' + ); + if (pending) fireTable.delete(pending); +}); diff --git a/spacetime-cron-ts/spacetimedb/tsconfig.json b/spacetime-cron-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..8d8f9b03455 --- /dev/null +++ b/spacetime-cron-ts/spacetimedb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/spacetime-cron-ts/src/cron.ts b/spacetime-cron-ts/src/cron.ts new file mode 100644 index 00000000000..b1ec2e4a18d --- /dev/null +++ b/spacetime-cron-ts/src/cron.ts @@ -0,0 +1,1137 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +import { volatile_nonatomic_schedule_immediate } from 'spacetime:sys@2.0'; +import { BinaryWriter } from 'spacetimedb'; +import type { Infer } from 'spacetimedb/server'; +import { + boundedScheduleTime, + CronInputError, + MAX_FAILURES, + normalizeHistoryCap, + normalizeJobArgs, + normalizeJobName, + normalizeMaxFailures, + normalizeReconcileEverySeconds, + normalizeSchedule, + nextOccurrence, + ONE_SECOND_MICROS, + truncateError, +} from './schedule'; +import type { + CronApi, + CronArgsBuilder, + CronCore, + CronCorePublicViews, + CronInvocation, + CronJobHandle, + CronJobReference, + CronSchedule, + CronSchema, + CronSdk, + CronTableDefinition, + CronTableOpts, + CronTableWithArgsOpts, + CronTimestamp, + CreateCronOpts, + ScheduleSpec, +} from './types'; + +const RECONCILE_REDUCER_NAME = 'cron_reconcile'; +const RECONCILE_TICK_KEY = 'cron'; + +const PUBLIC_DISABLED_REASONS = { + disabled: 'disabled', + disabledByOperator: 'disabled_by_operator', + failureThresholdReached: 'failure_threshold_reached', + invalidScheduleState: 'invalid_schedule_state', + lostFireThresholdReached: 'lost_fire_threshold_reached', +} as const; + +type RunStatus = 'Ok' | 'Failed'; + +interface IdentityLike { + toHexString(): string; +} + +interface DynamicSdkValue { + readonly [key: string]: DynamicSdkValue; + (...args: unknown[]): DynamicSdkValue; +} + +function asTableDefinition(value: DynamicSdkValue): CronTableDefinition { + return value as unknown as CronTableDefinition; +} + +interface RegistrationSchema { + anonymousView(...args: unknown[]): unknown; + reducer(...args: unknown[]): unknown; + procedure(...args: unknown[]): unknown; +} + +type InternalHandler = (...args: unknown[]) => unknown; + +interface RuntimeScheduleOpts { + timezone?: string; + maxFailures?: number; + args?: unknown; +} + +interface UniqueAccessor { + find(key: Key): Row | undefined; + update(row: Row): unknown; +} + +interface IndexAccessor { + filter(key: Key): Iterable; +} + +interface TableView { + insert(row: Row): Row; + delete(row: Row): unknown; + iter(): Iterable; +} + +interface CronArgsValue { + tag: string; + value: unknown; +} + +interface JobRow { + name: string; + schedule: CronSchedule; + args: CronArgsValue; + enabled: boolean; + maxFailures: number; + consecutiveFailures: number; + fireCount: bigint; + generation: bigint; + lastRunAt: CronTimestamp | undefined; + nextRunAt: CronTimestamp | undefined; + disabledReason: string | undefined; +} + +function publicDisabledReason(reason: string | undefined): string | undefined { + if (reason === undefined) return undefined; + if (reason === 'disabled_by_operator') { + return PUBLIC_DISABLED_REASONS.disabledByOperator; + } + if (reason.startsWith('cron.invalid_schedule_state:')) { + return PUBLIC_DISABLED_REASONS.invalidScheduleState; + } + if (/^failed_[1-9][0-9]*_consecutive_times:lost_fire$/.test(reason)) { + return PUBLIC_DISABLED_REASONS.lostFireThresholdReached; + } + if (/^failed_[1-9][0-9]*_consecutive_times:/.test(reason)) { + return PUBLIC_DISABLED_REASONS.failureThresholdReached; + } + return PUBLIC_DISABLED_REASONS.disabled; +} + +interface FireRow { + scheduledId: bigint; + scheduledAt: unknown; + jobName: string; + generation: bigint; + targetAt: CronTimestamp | undefined; + recovery: FireRecovery | undefined; +} + +interface FireRecovery { + sequence: bigint; + scheduledFor: CronTimestamp; + error: string; +} + +interface RunRow { + invocationId: string; + jobName: string; + generation: bigint; + sequence: bigint; + scheduledFor: CronTimestamp; + completedAt: CronTimestamp; + status: { tag: RunStatus }; + error: string | undefined; +} + +interface ReconcileTickRow { + scheduledId: bigint; + scheduledAt: unknown; + key: string; +} + +type JobTable = TableView & { + name: UniqueAccessor; +}; + +type FireTable = TableView & { + jobName: UniqueAccessor; + scheduledId: UniqueAccessor; +}; + +type RunTable = TableView & { + invocationId: UniqueAccessor; + jobName: IndexAccessor; +}; + +type ReconcileTickTable = TableView & { + key: UniqueAccessor; +}; + +interface CronDatabase extends Record { + cronJob: JobTable; + cronRun: RunTable; + cronReconcileTick?: ReconcileTickTable; +} + +interface TransactionContext { + readonly db: CronDatabase; + readonly timestamp: CronTimestamp; + readonly sender: IdentityLike; + readonly databaseIdentity: IdentityLike; +} + +interface ProcedureContext { + readonly timestamp: CronTimestamp; + readonly sender: IdentityLike; + readonly databaseIdentity: IdentityLike; + withTx(body: (ctx: TransactionContext) => T): T; +} + +interface JobMetadata { + readonly handle: CronJobReference; + readonly argsType: DynamicSdkValue; + readonly hasArgs: boolean; + readonly fireTableName: string; + readonly fireAccessor: string; + readonly reducerName: string; + fire: DynamicSdkValue | undefined; + core: InternalCore | undefined; + registration: 'reducer' | 'procedure' | undefined; +} + +interface InternalCore extends CronCore { + readonly jobs: Map; + readonly historyCap: number; + readonly reconcileEverySeconds: number | undefined; + readonly reconcileTick: DynamicSdkValue | undefined; + reconcileRegistered: boolean; + publicViewsRegistered: boolean; +} + +interface PreparedFire { + readonly invocation: CronInvocation; + readonly args: unknown; +} + +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + 'then' in value && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +function invocationId( + jobName: string, + generation: bigint, + sequence: bigint +): string { + return `${jobName}:${generation}:${sequence}`; +} + +/** Create a cron factory bound to the consumer module's SDK instance. */ +export function spacetimeCron(sdk: CronSdk): CronApi { + const { ScheduleAt, Timestamp, SenderError, toCamelCase } = sdk; + const table = sdk.table as unknown as DynamicSdkValue; + const t = sdk.t as unknown as DynamicSdkValue; + const metadata = new WeakMap(); + let coreCreated = false; + + const cronScheduleType = t.enum('CronSchedule', { + cron: t.object('CronSpec', { + expression: t.string(), + timezone: t.string(), + }), + every: t.object('EverySpec', { + seconds: t.u32(), + }), + }); + const runStatusType = t.enum('CronRunStatus', ['Ok', 'Failed']); + const fireRecoveryType = t.object('CronFireRecovery', { + sequence: t.u64(), + scheduledFor: t.timestamp(), + error: t.string(), + }); + + function requireMetadata(job: CronJobReference): JobMetadata { + const value = metadata.get(job); + if (!value) throw new Error('cron.foreign_job_handle'); + return value; + } + + function requireCore(job: CronJobReference): InternalCore { + const value = requireMetadata(job).core; + if (!value) { + throw new Error( + `cron.not_wired:${job.jobName}:pass the job to createCron() first` + ); + } + return value; + } + + function requireRegisteredHandlers(core: InternalCore): void { + const missing = [...core.jobs.values()] + .filter(job => !job.registration) + .map(job => job.handle.jobName); + if (missing.length > 0) { + throw new Error(`cron.missing_handlers:${missing.join(',')}`); + } + } + + function requireFire(job: JobMetadata): DynamicSdkValue { + if (!job.fire) { + throw new Error( + `cron.not_wired:${job.handle.jobName}:pass the job to createCron() first` + ); + } + return job.fire; + } + + function asTransactionContext(ctx: unknown): TransactionContext { + return ctx as TransactionContext; + } + + function requireDatabaseCaller( + ctx: Pick + ): void { + if (ctx.sender.toHexString() !== ctx.databaseIdentity.toHexString()) { + throw new SenderError('cron.not_authorized'); + } + } + + function fireTable(ctx: TransactionContext, job: JobMetadata): FireTable { + const value = ctx.db[job.fireAccessor]; + if (!value) { + throw new Error( + `cron.missing_table:${job.fireTableName}:spread cron.tables into schema()` + ); + } + return value as FireTable; + } + + function throwInputError(error: unknown): never { + if (error instanceof CronInputError) { + throw new SenderError(error.message); + } + throw error; + } + + function pruneHistory( + ctx: TransactionContext, + core: InternalCore, + jobName: string + ): void { + const rows = [...ctx.db.cronRun.jobName.filter(jobName)].sort( + (left, right) => + left.sequence < right.sequence + ? -1 + : left.sequence > right.sequence + ? 1 + : 0 + ); + const removeCount = Math.max(0, rows.length - core.historyCap); + for (const row of rows.slice(0, removeCount)) { + ctx.db.cronRun.delete(row); + } + } + + function recordRun( + ctx: TransactionContext, + core: InternalCore, + invocation: CronInvocation, + status: RunStatus, + error: string | undefined + ): void { + if (core.historyCap === 0) return; + if (ctx.db.cronRun.invocationId.find(invocation.id)) return; + ctx.db.cronRun.insert({ + invocationId: invocation.id, + jobName: invocation.jobName, + generation: invocation.generation, + sequence: invocation.sequence, + scheduledFor: invocation.scheduledFor, + completedAt: ctx.timestamp, + status: { tag: status }, + error, + }); + pruneHistory(ctx, core, invocation.jobName); + } + + function disarmFire(ctx: TransactionContext, job: JobMetadata): void { + const tableView = fireTable(ctx, job); + const pending = tableView.jobName.find(job.handle.jobName); + if (pending) tableView.delete(pending); + } + + function insertFire( + ctx: TransactionContext, + job: JobMetadata, + row: JobRow, + targetMicros: bigint | undefined + ): CronTimestamp | undefined { + const tableView = fireTable(ctx, job); + if (row.schedule.tag === 'every') { + tableView.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval( + BigInt(row.schedule.value.seconds) * ONE_SECOND_MICROS + ), + jobName: row.name, + generation: row.generation, + targetAt: undefined, + recovery: undefined, + }); + return new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(row.schedule.value.seconds) * ONE_SECOND_MICROS + ); + } + if (targetMicros === undefined) return undefined; + const targetAt = new Timestamp(targetMicros); + tableView.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.time( + boundedScheduleTime(ctx.timestamp.microsSinceUnixEpoch, targetMicros) + ), + jobName: row.name, + generation: row.generation, + targetAt, + recovery: undefined, + }); + return targetAt; + } + + function replaceFire( + ctx: TransactionContext, + job: JobMetadata, + row: JobRow + ): JobRow { + disarmFire(ctx, job); + + let targetMicros: bigint | undefined; + if (row.schedule.tag === 'cron') { + try { + targetMicros = nextOccurrence( + row.schedule, + ctx.timestamp.microsSinceUnixEpoch + ); + } catch (error) { + return disableJob( + ctx, + job, + row, + `cron.invalid_schedule_state:${truncateError(error)}` + ); + } + if (targetMicros === undefined) { + return disableJob(ctx, job, row, 'cron.no_future_occurrence'); + } + } + + const nextRunAt = insertFire(ctx, job, row, targetMicros); + const updated = { ...row, nextRunAt }; + ctx.db.cronJob.name.update(updated); + return updated; + } + + function ensureReconcileTick( + ctx: TransactionContext, + core: InternalCore + ): void { + const everySeconds = core.reconcileEverySeconds; + if (everySeconds === undefined) return; + if (!core.reconcileRegistered) { + throw new Error( + 'cron.reconcile_reducer_not_registered:export cron.reconcileReducer()' + ); + } + const tick = ctx.db.cronReconcileTick; + if (!tick) { + throw new Error( + 'cron.missing_table:cron_reconcile_tick:spread cron.tables into schema()' + ); + } + if (tick.key.find(RECONCILE_TICK_KEY)) return; + tick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval( + BigInt(everySeconds) * ONE_SECOND_MICROS + ), + key: RECONCILE_TICK_KEY, + }); + } + + function reconcileLostFires( + ctx: TransactionContext, + core: InternalCore + ): void { + for (const job of core.jobs.values()) { + const row = ctx.db.cronJob.name.find(job.handle.jobName); + if (!row || !row.enabled) { + disarmFire(ctx, job); + continue; + } + if (row.args.tag !== job.handle.jobName) { + disableJob(ctx, job, row, 'cron.invalid_args_state'); + continue; + } + + const pending = fireTable(ctx, job).jobName.find(row.name); + const validPending = + pending?.generation === row.generation && + pending.recovery === undefined && + (row.schedule.tag === 'cron' + ? pending.targetAt !== undefined + : pending.targetAt === undefined); + if (validPending) continue; + + const sequence = row.fireCount + 1n; + const invocation: CronInvocation = { + id: invocationId(row.name, row.generation, sequence), + jobName: row.name, + generation: row.generation, + sequence, + scheduledFor: row.nextRunAt ?? ctx.timestamp, + }; + replaceFire(ctx, job, row); + applyOutcome(ctx, core, job, invocation, 'Failed', 'lost_fire', true); + } + } + + function disableJob( + ctx: TransactionContext, + job: JobMetadata, + row: JobRow, + reason: string + ): JobRow { + disarmFire(ctx, job); + const updated = { + ...row, + enabled: false, + nextRunAt: undefined, + disabledReason: truncateError(reason), + }; + ctx.db.cronJob.name.update(updated); + return updated; + } + + function applyOutcome( + ctx: TransactionContext, + core: InternalCore, + job: JobMetadata, + invocation: CronInvocation, + status: RunStatus, + error: string | undefined, + advanceFireCount: boolean + ): void { + if (ctx.db.cronRun.invocationId.find(invocation.id)) return; + + let row = ctx.db.cronJob.name.find(invocation.jobName); + if (row && row.generation === invocation.generation) { + if (advanceFireCount && invocation.sequence !== row.fireCount + 1n) { + return; + } + const consecutiveFailures = + status === 'Failed' + ? Math.min(row.consecutiveFailures + 1, MAX_FAILURES) + : 0; + row = { + ...row, + fireCount: + invocation.sequence > row.fireCount + ? invocation.sequence + : row.fireCount, + consecutiveFailures, + lastRunAt: ctx.timestamp, + }; + ctx.db.cronJob.name.update(row); + if ( + status === 'Failed' && + row.enabled && + row.maxFailures > 0 && + consecutiveFailures >= row.maxFailures + ) { + disableJob( + ctx, + job, + row, + `failed_${consecutiveFailures}_consecutive_times:${error ?? status}` + ); + } + } + + recordRun(ctx, core, invocation, status, error); + } + + function prepareFire( + ctx: TransactionContext, + job: JobMetadata, + arg: FireRow, + reserveSequence: boolean + ): PreparedFire | undefined { + let row = ctx.db.cronJob.name.find(job.handle.jobName); + if (!row || !row.enabled || row.generation !== arg.generation) return; + if (row.args.tag !== job.handle.jobName) { + disableJob(ctx, job, row, 'cron.invalid_args_state'); + return; + } + + const nowMicros = ctx.timestamp.microsSinceUnixEpoch; + let scheduledFor = ctx.timestamp; + if (row.schedule.tag === 'cron') { + if (!arg.targetAt) { + disableJob(ctx, job, row, 'cron.invalid_trigger:missing_target'); + return; + } + const tableView = fireTable(ctx, job); + const fired = tableView.scheduledId.find(arg.scheduledId); + if (fired) tableView.delete(fired); + + const targetMicros = arg.targetAt.microsSinceUnixEpoch; + if (targetMicros > nowMicros) { + const nextRunAt = insertFire(ctx, job, row, targetMicros); + ctx.db.cronJob.name.update({ ...row, nextRunAt }); + return; + } + + scheduledFor = arg.targetAt; + const nextAt = nextOccurrence(row.schedule, nowMicros); + if (nextAt === undefined) { + row = disableJob(ctx, job, row, 'cron.no_future_occurrence'); + } else { + const nextRunAt = insertFire(ctx, job, row, nextAt); + row = { ...row, nextRunAt }; + ctx.db.cronJob.name.update(row); + } + } else { + const nextRunAt = new Timestamp( + nowMicros + BigInt(row.schedule.value.seconds) * ONE_SECOND_MICROS + ); + row = { ...row, nextRunAt }; + ctx.db.cronJob.name.update(row); + } + + const sequence = row.fireCount + 1n; + if (reserveSequence) { + row = { ...row, fireCount: sequence }; + ctx.db.cronJob.name.update(row); + } + return { + args: row.args.value, + invocation: { + id: invocationId(row.name, row.generation, sequence), + jobName: row.name, + generation: row.generation, + sequence, + scheduledFor, + }, + }; + } + + function invokeHandler( + handler: InternalHandler, + ctx: unknown, + job: JobMetadata, + prepared: PreparedFire + ): unknown { + return job.hasArgs + ? handler(ctx, prepared.args, prepared.invocation) + : handler(ctx, prepared.invocation); + } + + function encodeFireArgument(job: JobMetadata, arg: FireRow): Uint8Array { + const rowType = requireFire(job).rowType as unknown as { + serialize(writer: BinaryWriter, value: FireRow): void; + }; + const writer = new BinaryWriter(256); + // A reducer with one row parameter has the same BSATN field sequence as + // the row itself. The SDK serializer keeps this encoding tied to the + // generated fire-table schema. + rowType.serialize(writer, arg); + return writer.getBuffer(); + } + + function scheduleRecovery( + job: JobMetadata, + arg: FireRow, + invocation: CronInvocation, + error: string + ): void { + const recoveryArg: FireRow = { + ...arg, + recovery: { + sequence: invocation.sequence, + scheduledFor: invocation.scheduledFor, + error, + }, + }; + volatile_nonatomic_schedule_immediate( + job.reducerName, + encodeFireArgument(job, recoveryArg) + ); + } + + function executeReducer( + rawCtx: unknown, + core: InternalCore, + job: JobMetadata, + arg: FireRow, + handler: InternalHandler + ): void { + const ctx = asTransactionContext(rawCtx); + requireDatabaseCaller(ctx); + if (arg.recovery) { + recoverFailure(ctx, core, job, arg); + return; + } + const prepared = prepareFire(ctx, job, arg, false); + if (!prepared) return; + + try { + const result = invokeHandler(handler, rawCtx as Ctx, job, prepared); + if (isThenable(result)) { + throw new Error( + 'cron.async_reducer_handler:reducers must complete synchronously' + ); + } + } catch (error) { + const detail = truncateError(error); + try { + scheduleRecovery(job, arg, prepared.invocation, detail); + } catch { + // Volatile recovery is best effort. Reconciliation repairs a missing + // calendar fire if the host loses this request. + } + throw error; + } + + applyOutcome(ctx, core, job, prepared.invocation, 'Ok', undefined, true); + } + + function executeProcedure( + rawCtx: unknown, + core: InternalCore, + job: JobMetadata, + arg: FireRow, + handler: InternalHandler + ): void { + const ctx = rawCtx as ProcedureContext; + requireDatabaseCaller(ctx); + if (arg.recovery) { + throw new SenderError('cron.invalid_procedure_recovery'); + } + const prepared = ctx.withTx(tx => prepareFire(tx, job, arg, true)); + if (!prepared) return; + + let error: string | undefined; + try { + const result = invokeHandler(handler, rawCtx as Ctx, job, prepared); + if (isThenable(result)) { + throw new Error( + 'cron.async_procedure_handler:procedures must complete synchronously' + ); + } + } catch (caught) { + error = truncateError(caught); + } + + ctx.withTx(tx => { + applyOutcome( + tx, + core, + job, + prepared.invocation, + error === undefined ? 'Ok' : 'Failed', + error, + false + ); + }); + } + + function recoverFailure( + ctx: TransactionContext, + core: InternalCore, + job: JobMetadata, + arg: FireRow + ): void { + const recovery = arg.recovery; + if (!recovery || arg.jobName !== job.handle.jobName) return; + const jobName = job.handle.jobName; + let row = ctx.db.cronJob.name.find(jobName); + if ( + !row || + !row.enabled || + row.generation !== arg.generation || + recovery.sequence !== row.fireCount + 1n + ) { + return; + } + + if (row.schedule.tag === 'cron') { + // Replace the fire unconditionally so stale visible state cannot block + // recovery. + row = replaceFire(ctx, job, row); + } else { + const nextRunAt = new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(row.schedule.value.seconds) * ONE_SECOND_MICROS + ); + row = { ...row, nextRunAt }; + ctx.db.cronJob.name.update(row); + } + + const invocation: CronInvocation = { + id: invocationId(jobName, arg.generation, recovery.sequence), + jobName, + generation: arg.generation, + sequence: recovery.sequence, + scheduledFor: recovery.scheduledFor, + }; + applyOutcome( + ctx, + core, + job, + invocation, + 'Failed', + truncateError(recovery.error), + true + ); + } + + function registerJob( + job: JobMetadata, + kind: 'reducer' | 'procedure' + ): InternalCore { + const core = requireCore(job.handle); + if (job.registration) { + throw new Error( + `cron.handler_already_registered:${job.handle.jobName}:${job.registration}` + ); + } + job.registration = kind; + return core; + } + + function cronTable< + const Name extends string, + ArgsBuilder extends CronArgsBuilder, + >( + opts: CronTableWithArgsOpts + ): CronJobHandle>; + function cronTable( + opts: CronTableOpts + ): CronJobHandle; + function cronTable( + opts: CronTableOpts | CronTableWithArgsOpts + ): CronJobReference { + const jobName = normalizeJobName(opts.name); + const hasArgs = 'args' in opts; + const argsType = hasArgs + ? (opts.args as unknown as DynamicSdkValue) + : t.unit(); + const handle = { + jobName, + cronReducer(spacetimedb: CronSchema, handler: InternalHandler) { + const job = requireMetadata(handle); + const core = registerJob(job, 'reducer'); + const fire = requireFire(job); + return (spacetimedb as RegistrationSchema).reducer( + { name: job.reducerName, onSchedule: fire }, + { arg: fire.rowType }, + (ctx: unknown, { arg }: { arg: FireRow }) => { + executeReducer(ctx, core, job, arg, handler); + } + ); + }, + cronProcedure(spacetimedb: CronSchema, handler: InternalHandler) { + const job = requireMetadata(handle); + const core = registerJob(job, 'procedure'); + const fire = requireFire(job); + return (spacetimedb as RegistrationSchema).procedure( + { name: job.reducerName, onSchedule: fire }, + { arg: fire.rowType }, + t.unit(), + (ctx: unknown, { arg }: { arg: FireRow }) => { + executeProcedure(ctx, core, job, arg, handler); + return {}; + } + ); + }, + }; + metadata.set(handle, { + handle, + argsType, + hasArgs, + fireTableName: `${jobName}_fire`, + fireAccessor: toCamelCase(`${jobName}_fire`), + reducerName: `${jobName}_cron`, + fire: undefined, + core: undefined, + registration: undefined, + }); + return handle; + } + + function createCron( + jobs: readonly CronJobReference[], + opts?: CreateCronOpts + ): CronCore { + if (coreCreated) throw new Error('cron.multiple_cores_not_supported'); + if (jobs.length === 0) throw new Error('cron.no_jobs'); + coreCreated = true; + const historyCap = normalizeHistoryCap(opts?.historyCap); + const isPublic = opts?.publicTables ?? false; + const reconcileEverySeconds = normalizeReconcileEverySeconds( + opts?.reconcileEverySeconds + ); + + const jobsByName = new Map(); + const argumentTypes: Record = {}; + const usedAccessors = new Set(['cronJob', 'cronRun', 'cronReconcileTick']); + const sortedJobs = [...jobs].sort((left, right) => + left.jobName < right.jobName ? -1 : left.jobName > right.jobName ? 1 : 0 + ); + for (const handle of sortedJobs) { + const job = requireMetadata(handle); + if (jobsByName.has(handle.jobName)) { + throw new Error(`cron.duplicate_job:${handle.jobName}`); + } + if (job.core) { + throw new Error(`cron.job_already_wired:${handle.jobName}`); + } + if (usedAccessors.has(job.fireAccessor)) { + throw new Error(`cron.table_key_collision:${job.fireAccessor}`); + } + usedAccessors.add(job.fireAccessor); + jobsByName.set(handle.jobName, job); + argumentTypes[handle.jobName] = job.argsType; + } + + const cronJobArgsType = t.enum('CronJobArgsValue', argumentTypes); + const cronJob = table( + { name: 'cron_job', public: false }, + { + name: t.string().primaryKey(), + schedule: cronScheduleType, + args: cronJobArgsType, + enabled: t.bool(), + maxFailures: t.u32(), + consecutiveFailures: t.u32(), + fireCount: t.u64(), + generation: t.u64(), + lastRunAt: t.option(t.timestamp()), + nextRunAt: t.option(t.timestamp()), + disabledReason: t.option(t.string()), + } + ); + const cronJobViewRow = t.row('CronJobView', { + name: t.string().primaryKey(), + schedule: cronScheduleType, + enabled: t.bool(), + maxFailures: t.u32(), + consecutiveFailures: t.u32(), + fireCount: t.u64(), + generation: t.u64(), + lastRunAt: t.option(t.timestamp()), + nextRunAt: t.option(t.timestamp()), + disabledReason: t.option(t.string()), + }); + const cronRun = table( + { name: 'cron_run', public: isPublic }, + { + invocationId: t.string().primaryKey(), + jobName: t.string().index(), + generation: t.u64(), + sequence: t.u64(), + scheduledFor: t.timestamp(), + completedAt: t.timestamp(), + status: runStatusType, + error: t.option(t.string()), + } + ); + + const tables: Record = { + cronJob: asTableDefinition(cronJob), + cronRun: asTableDefinition(cronRun), + }; + let reconcileTick: DynamicSdkValue | undefined; + if (reconcileEverySeconds !== undefined) { + reconcileTick = table( + { name: 'cron_reconcile_tick', public: isPublic }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + key: t.string().unique(), + } + ); + tables.cronReconcileTick = asTableDefinition(reconcileTick); + } + for (const job of jobsByName.values()) { + const fire = table( + { name: job.fireTableName, public: isPublic }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + jobName: t.string().unique(), + generation: t.u64(), + targetAt: t.option(t.timestamp()), + recovery: t.option(fireRecoveryType), + } + ); + job.fire = fire; + tables[job.fireAccessor] = asTableDefinition(fire); + } + + const core: InternalCore = { + tables, + jobs: jobsByName, + historyCap, + reconcileEverySeconds, + reconcileTick, + reconcileRegistered: false, + publicViewsRegistered: false, + reconcileReducer(spacetimedb: CronSchema) { + if (core.reconcileRegistered) { + throw new Error('cron.reconcile_reducer_already_registered'); + } + if ( + core.reconcileEverySeconds === undefined || + core.reconcileTick === undefined + ) { + throw new Error( + 'cron.reconcile_not_configured:set createCron({ reconcileEverySeconds })' + ); + } + requireRegisteredHandlers(core); + core.reconcileRegistered = true; + const tick = core.reconcileTick; + return (spacetimedb as RegistrationSchema).reducer( + { name: RECONCILE_REDUCER_NAME, onSchedule: tick }, + { arg: tick.rowType }, + (rawCtx: unknown) => { + const ctx = asTransactionContext(rawCtx); + requireDatabaseCaller(ctx); + reconcileLostFires(ctx, core); + } + ); + }, + publicViews(spacetimedb: CronSchema): CronCorePublicViews { + if (core.publicViewsRegistered) { + throw new Error('cron.public_views_already_registered'); + } + core.publicViewsRegistered = true; + const jobsView = (spacetimedb as RegistrationSchema).anonymousView( + { name: 'cron_jobs', public: true }, + t.array(cronJobViewRow), + (rawCtx: unknown) => { + const ctx = rawCtx as Pick; + return [...ctx.db.cronJob.iter()].map(row => ({ + name: row.name, + schedule: row.schedule, + enabled: row.enabled, + maxFailures: row.maxFailures, + consecutiveFailures: row.consecutiveFailures, + fireCount: row.fireCount, + generation: row.generation, + lastRunAt: row.lastRunAt, + nextRunAt: row.nextRunAt, + disabledReason: publicDisabledReason(row.disabledReason), + })); + } + ); + return { jobs: jobsView }; + }, + }; + for (const job of jobsByName.values()) job.core = core; + return core; + } + + function schedule( + rawCtx: Ctx, + handle: CronJobReference, + spec: ScheduleSpec, + opts?: RuntimeScheduleOpts + ): void { + const ctx = asTransactionContext(rawCtx); + const job = requireMetadata(handle); + const core = requireCore(handle); + requireRegisteredHandlers(core); + let normalized; + let maxFailures: number; + let args: unknown; + try { + normalized = normalizeSchedule( + spec, + opts, + ctx.timestamp.microsSinceUnixEpoch + ); + maxFailures = normalizeMaxFailures(opts?.maxFailures); + args = normalizeJobArgs(handle.jobName, job.hasArgs, opts); + } catch (error) { + throwInputError(error); + } + + ensureReconcileTick(ctx, core); + reconcileLostFires(ctx, core); + + const existing = ctx.db.cronJob.name.find(handle.jobName); + const generation = (existing?.generation ?? 0n) + 1n; + disarmFire(ctx, job); + + let row: JobRow = { + name: handle.jobName, + schedule: normalized.schedule, + args: { tag: handle.jobName, value: args }, + enabled: true, + maxFailures, + consecutiveFailures: 0, + fireCount: existing?.fireCount ?? 0n, + generation, + lastRunAt: existing?.lastRunAt, + nextRunAt: undefined, + disabledReason: undefined, + }; + if (existing) ctx.db.cronJob.name.update(row); + else ctx.db.cronJob.insert(row); + + const nextRunAt = insertFire(ctx, job, row, normalized.firstAt); + if (!nextRunAt) { + throw new SenderError('cron.unsatisfiable_expression'); + } + row = { ...row, nextRunAt }; + ctx.db.cronJob.name.update(row); + } + + function unschedule(rawCtx: Ctx, handle: CronJobReference): void { + const ctx = asTransactionContext(rawCtx); + const job = requireMetadata(handle); + const core = requireCore(handle); + requireRegisteredHandlers(core); + ensureReconcileTick(ctx, core); + reconcileLostFires(ctx, core); + const row = ctx.db.cronJob.name.find(handle.jobName); + if (!row) return; + disarmFire(ctx, job); + ctx.db.cronJob.name.update({ + ...row, + enabled: false, + generation: row.generation + 1n, + consecutiveFailures: 0, + nextRunAt: undefined, + disabledReason: 'disabled_by_operator', + }); + } + + return { cronTable, createCron, schedule, unschedule }; +} diff --git a/spacetime-cron-ts/src/index.ts b/spacetime-cron-ts/src/index.ts new file mode 100644 index 00000000000..7bcc31ec2a0 --- /dev/null +++ b/spacetime-cron-ts/src/index.ts @@ -0,0 +1,28 @@ +export { spacetimeCron } from './cron'; +export type { + CreateCronOpts, + CronApi, + CronArgsBuilder, + CronCore, + CronCorePublicViews, + CronInvocation, + CronJobHandle, + CronJobReference, + CronProcedureHandler, + CronReducerHandler, + CronSchedule, + CronSdk, + CronTableOpts, + CronTableWithArgsOpts, + CronTimestamp, + ScheduleOpts, + ScheduleSpec, +} from './types'; +export { + parseCronExpression, + nextFireAfter, + isValidTimezone, + MAX_CRON_EXPRESSION_LENGTH, + MAX_TIMEZONE_LENGTH, + type ParsedCron, +} from './parser'; diff --git a/spacetime-cron-ts/src/parser.ts b/spacetime-cron-ts/src/parser.ts new file mode 100644 index 00000000000..dc2e2f94a67 --- /dev/null +++ b/spacetime-cron-ts/src/parser.ts @@ -0,0 +1,58 @@ +import { CronExpressionParser } from 'cron-parser'; + +// Avoids cron-parser's Math.random fallback (blocked in STDB reducers). +const HASH_SEED = 'spacetimedb-cron-submodule'; +export const MAX_CRON_EXPRESSION_LENGTH = 256; +export const MAX_TIMEZONE_LENGTH = 128; + +export type ParsedCron = { expression: string }; + +export function parseCronExpression(expr: string): ParsedCron { + const expression = expr.trim(); + if (expression.length === 0) throw new Error('cron expression is empty'); + if (expression.length > MAX_CRON_EXPRESSION_LENGTH) { + throw new Error( + `cron expression exceeds ${MAX_CRON_EXPRESSION_LENGTH} characters` + ); + } + CronExpressionParser.parse(expression, { hashSeed: HASH_SEED }); + return { expression }; +} + +// JS Date max range. +const MAX_SAFE_DATE_MS = 8_640_000_000_000_000; + +export function nextFireAfter( + parsed: ParsedCron, + afterMicros: bigint, + timezone: string = 'UTC' +): bigint | undefined { + if (!isValidTimezone(timezone)) return undefined; + const afterMs = Number(afterMicros / 1000n); + if (!Number.isFinite(afterMs) || Math.abs(afterMs) > MAX_SAFE_DATE_MS) { + return undefined; + } + try { + const interval = CronExpressionParser.parse(parsed.expression, { + currentDate: new Date(afterMs), + tz: timezone, + hashSeed: HASH_SEED, + }); + return BigInt(interval.next().getTime()) * 1000n; + } catch { + return undefined; + } +} + +export function isValidTimezone(tz: string): boolean { + if (tz.length === 0 || tz.length > MAX_TIMEZONE_LENGTH || tz !== tz.trim()) { + return false; + } + if (tz === 'UTC') return true; + try { + new Intl.DateTimeFormat('en-US', { timeZone: tz }); + return true; + } catch { + return false; + } +} diff --git a/spacetime-cron-ts/src/schedule.ts b/spacetime-cron-ts/src/schedule.ts new file mode 100644 index 00000000000..c4ba7db575c --- /dev/null +++ b/spacetime-cron-ts/src/schedule.ts @@ -0,0 +1,184 @@ +import { + MAX_CRON_EXPRESSION_LENGTH, + MAX_TIMEZONE_LENGTH, + isValidTimezone, + nextFireAfter, + parseCronExpression, +} from './parser'; +import type { CronSchedule, ScheduleSpec } from './types'; + +export const ONE_SECOND_MICROS = 1_000_000n; +export const MAX_INTERVAL_SECONDS = 31_536_000; +export const MAX_HISTORY_CAP = 1_000; +export const MAX_FAILURES = 4_294_967_295; +export const MAX_ERROR_LENGTH = 1_024; + +// The host limit is roughly 795 days. Annual checkpoints leave ample room for +// execution delay and keep valid sparse schedules, such as February 29, armed. +export const CHECKPOINT_DELAY_MICROS = + 365n * 24n * 60n * 60n * ONE_SECOND_MICROS; + +const JOB_NAME_PATTERN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; +const MAX_JOB_NAME_LENGTH = 48; + +export class CronInputError extends Error {} + +export interface NormalizedSchedule { + schedule: CronSchedule; + firstAt: bigint | undefined; +} + +export function normalizeJobName(name: string): string { + const normalized = name.trim(); + if ( + normalized.length === 0 || + normalized.length > MAX_JOB_NAME_LENGTH || + !JOB_NAME_PATTERN.test(normalized) + ) { + throw new CronInputError( + 'cron.invalid_job_name:use 1-48 lowercase snake_case characters' + ); + } + return normalized; +} + +export function normalizeHistoryCap(value: number | undefined): number { + const cap = value ?? 5; + if (!Number.isSafeInteger(cap) || cap < 0 || cap > MAX_HISTORY_CAP) { + throw new CronInputError( + `cron.invalid_history_cap:must be an integer between 0 and ${MAX_HISTORY_CAP}` + ); + } + return cap; +} + +export function normalizeReconcileEverySeconds( + value: number | undefined +): number | undefined { + if (value === undefined) return undefined; + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > MAX_INTERVAL_SECONDS + ) { + throw new CronInputError( + `cron.invalid_reconcile_interval:seconds must be an integer between 1 and ${MAX_INTERVAL_SECONDS}` + ); + } + return value; +} + +export function normalizeMaxFailures(value: number | undefined): number { + const failures = value ?? 0; + if ( + !Number.isSafeInteger(failures) || + failures < 0 || + failures > MAX_FAILURES + ) { + throw new CronInputError( + `cron.invalid_max_failures:must be an integer between 0 and ${MAX_FAILURES}` + ); + } + return failures; +} + +export function normalizeJobArgs( + jobName: string, + hasArgs: boolean, + opts: { args?: unknown } | undefined +): unknown { + const supplied = Object.prototype.hasOwnProperty.call(opts ?? {}, 'args'); + if (hasArgs && !supplied) { + throw new CronInputError(`cron.missing_args:${jobName}`); + } + if (!hasArgs && supplied) { + throw new CronInputError(`cron.unexpected_args:${jobName}`); + } + return hasArgs ? opts?.args : {}; +} + +export function normalizeSchedule( + spec: ScheduleSpec, + opts: { timezone?: string } | undefined, + nowMicros: bigint +): NormalizedSchedule { + if (typeof spec !== 'string') { + const seconds = spec.everySeconds; + if ( + !Number.isSafeInteger(seconds) || + seconds < 1 || + seconds > MAX_INTERVAL_SECONDS + ) { + throw new CronInputError( + `cron.invalid_interval:seconds must be an integer between 1 and ${MAX_INTERVAL_SECONDS}` + ); + } + return { + schedule: { tag: 'every', value: { seconds } }, + firstAt: nowMicros + BigInt(seconds) * ONE_SECOND_MICROS, + }; + } + + const expression = spec.trim(); + const timezone = (opts?.timezone ?? 'UTC').trim() || 'UTC'; + if (expression.length === 0) { + throw new CronInputError('cron.invalid_expression:empty'); + } + if (expression.length > MAX_CRON_EXPRESSION_LENGTH) { + throw new CronInputError('cron.invalid_expression:too_long'); + } + if (timezone.length > MAX_TIMEZONE_LENGTH) { + throw new CronInputError('cron.invalid_timezone:too_long'); + } + if (!isValidTimezone(timezone)) { + throw new CronInputError(`cron.invalid_timezone:${timezone}`); + } + + let firstAt: bigint | undefined; + try { + firstAt = nextFireAfter( + parseCronExpression(expression), + nowMicros, + timezone + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new CronInputError(`cron.invalid_expression:${detail}`); + } + if (firstAt === undefined) { + throw new CronInputError('cron.unsatisfiable_expression'); + } + return { + schedule: { tag: 'cron', value: { expression, timezone } }, + firstAt, + }; +} + +export function nextOccurrence( + schedule: CronSchedule, + afterMicros: bigint +): bigint | undefined { + if (schedule.tag === 'every') { + return afterMicros + BigInt(schedule.value.seconds) * ONE_SECOND_MICROS; + } + return nextFireAfter( + parseCronExpression(schedule.value.expression), + afterMicros, + schedule.value.timezone + ); +} + +export function boundedScheduleTime( + nowMicros: bigint, + targetMicros: bigint +): bigint { + const checkpoint = nowMicros + CHECKPOINT_DELAY_MICROS; + return targetMicros < checkpoint ? targetMicros : checkpoint; +} + +export function truncateError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.length <= MAX_ERROR_LENGTH + ? message + : `${message.slice(0, MAX_ERROR_LENGTH - 3)}...`; +} diff --git a/spacetime-cron-ts/src/sys-abi.d.ts b/spacetime-cron-ts/src/sys-abi.d.ts new file mode 100644 index 00000000000..3e58a3ccfed --- /dev/null +++ b/spacetime-cron-ts/src/sys-abi.d.ts @@ -0,0 +1,10 @@ +// Ambient declaration for the host syscall used by the failure-reporting +// bridge. The SDK keeps this module internal, while the module bundler +// resolves the direct import inside the host. Keep this dependency isolated +// here so it can be replaced without changing the public cron API. +declare module 'spacetime:sys@2.0' { + export function volatile_nonatomic_schedule_immediate( + reducer_name: string, + args: Uint8Array + ): void; +} diff --git a/spacetime-cron-ts/src/types.ts b/spacetime-cron-ts/src/types.ts new file mode 100644 index 00000000000..94f19a19a30 --- /dev/null +++ b/spacetime-cron-ts/src/types.ts @@ -0,0 +1,164 @@ +import type { Timestamp } from 'spacetimedb'; +import type { + Infer, + ScheduleAt, + SenderError, + t, + table, + toCamelCase, + VariantsObj, +} from 'spacetimedb/server'; + +export type CronSchedule = + | { tag: 'cron'; value: { expression: string; timezone: string } } + | { tag: 'every'; value: { seconds: number } }; + +/** A cron expression or a fixed interval in seconds. */ +export type ScheduleSpec = string | { everySeconds: number }; + +interface SchedulePolicyOpts { + /** IANA timezone for cron expressions. Defaults to `UTC`. */ + timezone?: string; + /** Consecutive failures before automatic disablement. `0` disables this policy. */ + maxFailures?: number; +} + +type CronNoInfer = [T][T extends unknown ? 0 : never]; + +/** Schedule policy plus the durable arguments required by an argument-bearing job. */ +export type ScheduleOpts = SchedulePolicyOpts & + ([Args] extends [undefined] ? { args?: never } : { args: Args }); + +/** Any SpacetimeDB type builder accepted as a cron argument payload. */ +export type CronArgsBuilder = VariantsObj[string]; + +export interface CronTableOpts { + /** Stable snake_case job name. */ + name: Name; +} + +export interface CronTableWithArgsOpts< + Name extends string = string, + ArgsBuilder extends CronArgsBuilder = CronArgsBuilder, +> extends CronTableOpts { + /** Typed payload persisted with the schedule and copied into each invocation. */ + args: ArgsBuilder; +} + +export interface CreateCronOpts { + /** Completed run records retained per job. Defaults to `5`. */ + historyCap?: number; + /** Expose trigger and run tables to subscriptions. Defaults to `false`. */ + publicTables?: boolean; + /** + * Optional native interval that repairs enabled jobs with missing triggers. + * Management operations always run the same repair opportunistically. + */ + reconcileEverySeconds?: number; +} + +/** Structural view of the SpacetimeDB timestamp supplied to handlers. */ +export interface CronTimestamp { + readonly microsSinceUnixEpoch: bigint; + toISOString(): string; + toDate(): Date; + toMillis(): bigint; +} + +/** Stable metadata supplied to every cron invocation. */ +export interface CronInvocation { + /** Unique across every generation of every job. */ + readonly id: string; + readonly jobName: string; + readonly generation: bigint; + readonly sequence: bigint; + /** Logical calendar occurrence or interval fire time. */ + readonly scheduledFor: CronTimestamp; +} + +export type CronReducerHandler = [Args] extends [ + undefined, +] + ? (ctx: Ctx, invocation: CronInvocation) => void + : (ctx: Ctx, args: Args, invocation: CronInvocation) => void; + +export type CronProcedureHandler = [Args] extends [ + undefined, +] + ? (ctx: Ctx, invocation: CronInvocation) => void + : (ctx: Ctx, args: Args, invocation: CronInvocation) => void; + +// Schema and registration exports contain SDK-private symbols and exact +// application schema types. They remain opaque across the adapter boundary. +export type CronModuleExport = unknown; +export type CronTableDefinition = ReturnType; +export type CronSchema = unknown; + +export interface CronJobReference { + readonly jobName: Name; +} + +export interface CronJobHandle + extends CronJobReference { + cronReducer( + spacetimedb: CronSchema, + handler: CronReducerHandler + ): CronModuleExport; + cronProcedure( + spacetimedb: CronSchema, + handler: CronProcedureHandler + ): CronModuleExport; +} + +export interface CronCorePublicViews { + /** Sanitized job state. Typed application arguments remain private. */ + readonly jobs: CronModuleExport; +} + +export interface CronCore { + /** Spread into the consumer's `schema()` call. */ + readonly tables: Record; + /** Register and export the optional lost-trigger reconciliation sweep. */ + reconcileReducer(spacetimedb: CronSchema): CronModuleExport; + /** Register the optional public job-state view exactly once. */ + publicViews(spacetimedb: CronSchema): CronCorePublicViews; +} + +export interface CronSdk { + /** Consumer module's `table` value from `spacetimedb/server`. */ + table: typeof table; + /** Consumer module's `t` value from `spacetimedb/server`. */ + t: typeof t; + /** Consumer module's `toCamelCase` value from `spacetimedb/server`. */ + toCamelCase: typeof toCamelCase; + /** Consumer module's `ScheduleAt` value from `spacetimedb`. */ + ScheduleAt: typeof ScheduleAt; + /** Consumer module's `Timestamp` value from `spacetimedb`. */ + Timestamp: typeof Timestamp; + /** Consumer module's `SenderError` value from `spacetimedb/server`. */ + SenderError: typeof SenderError; +} + +export interface CronApi { + cronTable( + opts: CronTableWithArgsOpts + ): CronJobHandle>; + cronTable( + opts: CronTableOpts + ): CronJobHandle; + createCron( + jobs: readonly CronJobReference[], + opts?: CreateCronOpts + ): CronCore; + /** Create or replace a job schedule and arm its first trigger. */ + schedule( + ctx: Ctx, + job: CronJobHandle, + spec: ScheduleSpec, + ...options: [Args] extends [undefined] + ? [opts?: ScheduleOpts] + : [opts: ScheduleOpts>] + ): void; + /** Disable a job and remove its pending trigger. */ + unschedule(ctx: Ctx, job: CronJobReference): void; +} diff --git a/spacetime-cron-ts/tsconfig.json b/spacetime-cron-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-cron-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-crypto-ts/LICENSE.txt b/spacetime-crypto-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-crypto-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-crypto-ts/README.md b/spacetime-crypto-ts/README.md new file mode 100644 index 00000000000..7ad5febc853 --- /dev/null +++ b/spacetime-crypto-ts/README.md @@ -0,0 +1,71 @@ +# @spacetimedb/crypto + +SHA-256, HMAC-SHA256, encoding helpers, constant-time byte comparison, and +webhook-signature verification for SpacetimeDB TypeScript modules. Hashing is +implemented with `@noble/hashes`. + +## Install + +```bash +npm install @spacetimedb/crypto +``` + +For the surrounding SpacetimeDB module workflow, see +[Getting started](https://spacetimedb.com/docs/). + +## Usage + +### Integrate into an application + +This pure helper package supplies hashing, encoding, and signature verification +functions. Import the function needed by the host HTTP handler. Pass the exact +raw request bytes and deterministic module time, then parse the provider payload +after signature verification succeeds. + +Verify a Stripe webhook with the raw body and module time: + +```ts +import { verifyStripeSignature } from '@spacetimedb/crypto'; + +const valid = verifyStripeSignature({ + rawBody, + signatureHeader: request.headers.get('stripe-signature') ?? '', + secret: webhookSecret, + nowSeconds: Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n), +}); +``` + +Verify the raw webhook body before parsing it. Store webhook secrets in private +tables and keep them out of public rows and procedure results. + +## API + +- `sha256(data)` and `hmacSha256(key, message)` return `Uint8Array` digests. +- `timingSafeEqual(a, b)` compares every byte in equal-length arrays. +- `hexToBytes`, `bytesToHex`, and `base64ToBytes` convert common encodings. + Base64 accepts canonical padded or unpadded standard encoding and rejects + whitespace, misplaced padding, invalid lengths, and non-canonical pad bits. +- `verifyStripeSignature(options)` verifies Stripe's `v1` signature format. +- `verifySvixSignature(options)` verifies Svix-compatible signatures, including + Resend webhooks. +- `verifyGithubSignature(options)` verifies GitHub's SHA-256 webhook signature. + +Package entrypoints: + +- `@spacetimedb/crypto` exports hashing, encoding, comparison, and vendor + verification helpers. +- `@spacetimedb/crypto/vendors` is the focused webhook-verification + entrypoint. + +## Testing + +```bash +npm test --workspace @spacetimedb/crypto +npm run lint --workspace @spacetimedb/crypto +``` + +Tests use published vendor vectors and local fixtures; no network is required. + +## License + +BUSL-1.1. See [`LICENSE.txt`](./LICENSE.txt). diff --git a/spacetime-crypto-ts/package.json b/spacetime-crypto-ts/package.json new file mode 100644 index 00000000000..333aaeffd15 --- /dev/null +++ b/spacetime-crypto-ts/package.json @@ -0,0 +1,59 @@ +{ + "name": "@spacetimedb/crypto", + "description": "Deterministic hashing, encoding, and webhook-signature helpers for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./vendors": { + "types": "./src/vendors.ts", + "default": "./src/vendors.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-crypto-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-crypto-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "cryptography", + "webhooks", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test-vectors.ts" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "dependencies": { + "@noble/hashes": "^2.2.0" + } +} diff --git a/spacetime-crypto-ts/scripts/test-vectors.ts b/spacetime-crypto-ts/scripts/test-vectors.ts new file mode 100644 index 00000000000..cabc15d1ee3 --- /dev/null +++ b/spacetime-crypto-ts/scripts/test-vectors.ts @@ -0,0 +1,340 @@ +// Verify the pure-TS implementations against published test vectors. +// Run via: pnpm test +// +// Sources: +// SHA-256 vectors: NIST CAVS examples + RFC 6234 §8.5 +// HMAC-SHA256 vectors: RFC 4231 §4 + +import { sha256 } from '../src/sha256.ts'; +import { hmacSha256 } from '../src/hmac.ts'; +import { + bytesToHex, + hexToBytes, + timingSafeEqual, + base64ToBytes, +} from '../src/timing.ts'; +import { + verifyStripeSignature, + verifyGithubSignature, + verifySvixSignature, +} from '../src/vendors.ts'; + +const enc = new TextEncoder(); + +let pass = 0, + fail = 0; + +function assertEq(label: string, got: string, expected: string): void { + if (got === expected) { + process.stdout.write(` OK ${label}\n`); + pass++; + } else { + process.stdout.write( + ` FAIL ${label}\n got: ${got}\n expected: ${expected}\n` + ); + fail++; + } +} + +function assert(label: string, cond: boolean): void { + if (cond) { + process.stdout.write(` OK ${label}\n`); + pass++; + } else { + process.stdout.write(` FAIL ${label}\n`); + fail++; + } +} + +// SHA-256 +process.stdout.write('SHA-256\n'); +assertEq( + 'empty string', + bytesToHex(sha256(enc.encode(''))), + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' +); +assertEq( + '"abc"', + bytesToHex(sha256(enc.encode('abc'))), + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' +); +assertEq( + 'NIST 448-bit message', + bytesToHex( + sha256( + enc.encode('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq') + ) + ), + '248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1' +); +// One million "a" characters exercises message-length packing in the padding. +const millionA = new Uint8Array(1_000_000); +millionA.fill(0x61); +assertEq( + '1,000,000 × "a"', + bytesToHex(sha256(millionA)), + 'cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0' +); + +// HMAC-SHA256 (RFC 4231 test cases) +process.stdout.write('\nHMAC-SHA256 (RFC 4231)\n'); + +assertEq( + 'TC1: 20 × 0x0b key, "Hi There"', + bytesToHex( + hmacSha256( + hexToBytes('0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b'), + enc.encode('Hi There') + ) + ), + 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7' +); + +assertEq( + 'TC2: "Jefe" key, "what do ya want for nothing?"', + bytesToHex( + hmacSha256(enc.encode('Jefe'), enc.encode('what do ya want for nothing?')) + ), + '5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843' +); + +assertEq( + 'TC3: 20 × 0xaa key, 50 × 0xdd data', + bytesToHex( + hmacSha256( + hexToBytes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), + hexToBytes('dd'.repeat(50)) + ) + ), + '773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe' +); + +assertEq( + 'TC4: 25-byte key, 50 × 0xcd', + bytesToHex( + hmacSha256( + hexToBytes('0102030405060708090a0b0c0d0e0f10111213141516171819'), + hexToBytes('cd'.repeat(50)) + ) + ), + '82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b' +); + +assertEq( + 'TC6: 131-byte key (forces hash-down), short data', + bytesToHex( + hmacSha256( + hexToBytes('aa'.repeat(131)), + enc.encode('Test Using Larger Than Block-Size Key - Hash Key First') + ) + ), + '60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54' +); + +assertEq( + 'TC7: 131-byte key, long data', + bytesToHex( + hmacSha256( + hexToBytes('aa'.repeat(131)), + enc.encode( + 'This is a test using a larger than block-size key and a larger than block-size data. ' + + 'The key needs to be hashed before being used by the HMAC algorithm.' + ) + ) + ), + '9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2' +); + +// timingSafeEqual +process.stdout.write('\ntimingSafeEqual\n'); +assert( + 'equal returns true', + timingSafeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3])) +); +assert( + 'differing byte returns false', + !timingSafeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 4])) +); +assert( + 'different length returns false', + !timingSafeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3, 4])) +); +assert( + 'two empty returns true', + timingSafeEqual(new Uint8Array([]), new Uint8Array([])) +); + +// Base64 round-trip +process.stdout.write('\nbase64\n'); +const b64Cases: Array<[string, string]> = [ + ['', ''], + ['f', 'Zg=='], + ['fo', 'Zm8='], + ['foo', 'Zm9v'], + ['foob', 'Zm9vYg=='], + ['fooba', 'Zm9vYmE='], + ['foobar', 'Zm9vYmFy'], +]; +for (const [plain, b64] of b64Cases) { + const decoded = new TextDecoder().decode(base64ToBytes(b64)); + assertEq(`decode("${b64}")`, decoded, plain); +} +assertEq( + 'decode unpadded Zg', + new TextDecoder().decode(base64ToBytes('Zg')), + 'f' +); +for (const malformed of [ + 'A', + '=AAA', + 'A=AA', + 'AA=A', + 'AAAA=', + 'AA===', + 'Zh==', + 'Zm9=', + 'Zg==\n', +]) { + let threw = false; + try { + base64ToBytes(malformed); + } catch { + threw = true; + } + assert(`reject malformed base64 ${JSON.stringify(malformed)}`, threw); +} + +// Stripe signature round-trip +// Construct a header the way Stripe does, then verify it round-trips. +process.stdout.write('\nStripe webhook signature\n'); +const stripeSecret = 'whsec_test_secret_1234567890'; +const stripeBody = '{"id":"evt_test","type":"customer.created"}'; +const stripeTs = '1700000000'; +const stripeMac = hmacSha256( + enc.encode(stripeSecret), + enc.encode(`${stripeTs}.${stripeBody}`) +); +const stripeHeader = `t=${stripeTs},v1=${bytesToHex(stripeMac)}`; +assert( + 'valid signature passes (tolerance bypass)', + verifyStripeSignature({ + rawBody: stripeBody, + signatureHeader: stripeHeader, + secret: stripeSecret, + toleranceSeconds: Infinity, + }) +); +assert( + 'mutated body fails', + !verifyStripeSignature({ + rawBody: stripeBody + 'x', + signatureHeader: stripeHeader, + secret: stripeSecret, + toleranceSeconds: Infinity, + }) +); +assert( + 'wrong secret fails', + !verifyStripeSignature({ + rawBody: stripeBody, + signatureHeader: stripeHeader, + secret: 'whsec_wrong', + toleranceSeconds: Infinity, + }) +); +assert( + 'old timestamp rejected when tolerance enforced', + !verifyStripeSignature({ + rawBody: stripeBody, + signatureHeader: stripeHeader, + secret: stripeSecret, + toleranceSeconds: 300, + nowSeconds: Number(stripeTs) + 1000, + }) +); +assert( + 'multiple v1 entries: any match wins', + verifyStripeSignature({ + rawBody: stripeBody, + signatureHeader: `t=${stripeTs},v1=deadbeef,v1=${bytesToHex(stripeMac)}`, + secret: stripeSecret, + toleranceSeconds: Infinity, + }) +); + +// GitHub signature round-trip +process.stdout.write('\nGitHub webhook signature\n'); +const ghSecret = "It's a Secret to Everybody"; +const ghBody = 'Hello, World!'; +const ghMac = hmacSha256(enc.encode(ghSecret), enc.encode(ghBody)); +assert( + 'roundtrip passes', + verifyGithubSignature({ + rawBody: ghBody, + signatureHeader: `sha256=${bytesToHex(ghMac)}`, + secret: ghSecret, + }) +); +// Known vector from GitHub docs. +assertEq( + 'docs example body→hex', + bytesToHex(hmacSha256(enc.encode(ghSecret), enc.encode(ghBody))), + '757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17' +); + +// Svix (Resend) signature round-trip +process.stdout.write('\nsvix (Resend) signature\n'); +// Construct as svix would. +const svixSecretRaw = new Uint8Array(32); +svixSecretRaw.fill(0x42); +// Helper to base64-encode (mirror of base64ToBytes). +function bytesToBase64(b: Uint8Array): string { + const ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + let s = ''; + for (let i = 0; i < b.length; i += 3) { + const b0 = b[i]; + const b1 = i + 1 < b.length ? b[i + 1] : 0; + const b2 = i + 2 < b.length ? b[i + 2] : 0; + s += ALPHABET[b0 >> 2]; + s += ALPHABET[((b0 & 3) << 4) | (b1 >> 4)]; + s += i + 1 < b.length ? ALPHABET[((b1 & 0xf) << 2) | (b2 >> 6)] : '='; + s += i + 2 < b.length ? ALPHABET[b2 & 0x3f] : '='; + } + return s; +} +const svixSecret = 'whsec_' + bytesToBase64(svixSecretRaw); +const svixId = 'msg_test_123'; +const svixTs = '1700000000'; +const svixBody = '{"event":"email.delivered"}'; +const svixMac = hmacSha256( + svixSecretRaw, + enc.encode(`${svixId}.${svixTs}.${svixBody}`) +); +const svixSig = 'v1,' + bytesToBase64(svixMac); +assert( + 'valid svix signature passes', + verifySvixSignature({ + rawBody: svixBody, + svixId, + svixTimestamp: svixTs, + svixSignature: svixSig, + secret: svixSecret, + toleranceSeconds: Infinity, + }) +); +assert( + 'mutated svix body fails', + !verifySvixSignature({ + rawBody: svixBody + 'x', + svixId, + svixTimestamp: svixTs, + svixSignature: svixSig, + secret: svixSecret, + toleranceSeconds: Infinity, + }) +); + +// Summary +process.stdout.write(`\n${pass} pass, ${fail} fail\n`); +if (fail > 0) process.exit(1); diff --git a/spacetime-crypto-ts/src/hmac.ts b/spacetime-crypto-ts/src/hmac.ts new file mode 100644 index 00000000000..f4cb22e1f99 --- /dev/null +++ b/spacetime-crypto-ts/src/hmac.ts @@ -0,0 +1,8 @@ +// HMAC-SHA256 backed by @noble/hashes. + +import { hmac } from '@noble/hashes/hmac.js'; +import { sha256 } from '@noble/hashes/sha2.js'; + +export function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array { + return hmac(sha256, key, message); +} diff --git a/spacetime-crypto-ts/src/index.ts b/spacetime-crypto-ts/src/index.ts new file mode 100644 index 00000000000..f6a4c4b2409 --- /dev/null +++ b/spacetime-crypto-ts/src/index.ts @@ -0,0 +1,19 @@ +// Hashing, encoding, constant-time comparison, and webhook verification. + +export { sha256, SHA256_BYTES } from './sha256.ts'; +export { hmacSha256 } from './hmac.ts'; +export { + timingSafeEqual, + hexToBytes, + bytesToHex, + base64ToBytes, +} from './timing.ts'; + +export { + verifyStripeSignature, + verifySvixSignature, + verifyGithubSignature, + type StripeVerifyOpts, + type SvixVerifyOpts, + type GithubVerifyOpts, +} from './vendors.ts'; diff --git a/spacetime-crypto-ts/src/sha256.ts b/spacetime-crypto-ts/src/sha256.ts new file mode 100644 index 00000000000..4130ffe24f7 --- /dev/null +++ b/spacetime-crypto-ts/src/sha256.ts @@ -0,0 +1,10 @@ +// SHA-256 backed by @noble/hashes and exposed through the package API. + +import { sha256 as nobleSha256 } from '@noble/hashes/sha2.js'; + +export function sha256(data: Uint8Array): Uint8Array { + return nobleSha256(data); +} + +export const SHA256_BYTES = 32; +export const SHA256_INTERNAL_BLOCK_SIZE = 64; diff --git a/spacetime-crypto-ts/src/timing.ts b/spacetime-crypto-ts/src/timing.ts new file mode 100644 index 00000000000..83f2d020da2 --- /dev/null +++ b/spacetime-crypto-ts/src/timing.ts @@ -0,0 +1,113 @@ +// Constant-time byte comparison + small hex/base64 helpers. + +/** + * Compare two byte arrays in time independent of their content. Returns true + * iff both have the same length AND identical bytes. + * + * IMPORTANT: this is only constant-time when arrays are equal length. The + * length check is up front and leaks length info, which is fine for fixed-size + * tags (HMAC outputs, JWT signatures). For variable-length comparisons you'd + * need to also pad to a max length. + * + * Note: the JIT can sometimes shortcut bitwise OR chains. The accumulator + * pattern below is the standard timing-safe construction; it's about as + * practical in pure JS without going to WASM. + */ +export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff === 0; +} + +/** Decode `01ab23cd` into Uint8Array. Throws on odd length or non-hex. */ +export function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error('hex: odd length'); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + const hi = hexNibble(hex.charCodeAt(i * 2)); + const lo = hexNibble(hex.charCodeAt(i * 2 + 1)); + out[i] = (hi << 4) | lo; + } + return out; +} + +function hexNibble(code: number): number { + if (code >= 0x30 && code <= 0x39) return code - 0x30; + if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10; + if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10; + throw new Error(`hex: non-hex char ${String.fromCharCode(code)}`); +} + +/** Encode bytes as lowercase hex. */ +export function bytesToHex(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += bytes[i].toString(16).padStart(2, '0'); + } + return s; +} + +/** Decode standard base64 (with or without padding). Throws on invalid input. */ +export function base64ToBytes(b64: string): Uint8Array { + if (/\s/.test(b64)) throw new Error('base64: whitespace is not allowed'); + if (b64.length % 4 === 1) throw new Error('base64: invalid length'); + const firstPad = b64.indexOf('='); + if (firstPad >= 0) { + const padding = b64.length - firstPad; + if ( + padding > 2 || + b64.length % 4 !== 0 || + !/^=+$/.test(b64.slice(firstPad)) + ) { + throw new Error('base64: invalid padding'); + } + } + + let s = b64; + while (s.length % 4 !== 0) s += '='; + const ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const lookup = new Int8Array(256).fill(-1); + for (let i = 0; i < ALPHABET.length; i++) lookup[ALPHABET.charCodeAt(i)] = i; + lookup[0x3d /* '=' */] = 0; // pad treated as 0; we trim afterwards + + // Count pad to compute output length. + let pad = 0; + if (s.endsWith('==')) pad = 2; + else if (s.endsWith('=')) pad = 1; + + const out = new Uint8Array((s.length / 4) * 3 - pad); + let oi = 0; + for (let i = 0; i < s.length; i += 4) { + const c0 = lookup[s.charCodeAt(i)]; + const c1 = lookup[s.charCodeAt(i + 1)]; + const c2 = lookup[s.charCodeAt(i + 2)]; + const c3 = lookup[s.charCodeAt(i + 3)]; + if (c0 < 0 || c1 < 0 || c2 < 0 || c3 < 0) { + throw new Error('base64: invalid char'); + } + const finalQuartet = i + 4 === s.length; + if ( + s[i] === '=' || + s[i + 1] === '=' || + (!finalQuartet && (s[i + 2] === '=' || s[i + 3] === '=')) + ) { + throw new Error('base64: invalid padding'); + } + if (s[i + 2] === '=' && s[i + 3] !== '=') + throw new Error('base64: invalid padding'); + if (s[i + 2] === '=' && (c1 & 0x0f) !== 0) + throw new Error('base64: non-canonical padding'); + if (s[i + 3] === '=' && s[i + 2] !== '=' && (c2 & 0x03) !== 0) { + throw new Error('base64: non-canonical padding'); + } + const v = (c0 << 18) | (c1 << 12) | (c2 << 6) | c3; + if (oi < out.length) out[oi++] = (v >> 16) & 0xff; + if (oi < out.length) out[oi++] = (v >> 8) & 0xff; + if (oi < out.length) out[oi++] = v & 0xff; + } + return out; +} diff --git a/spacetime-crypto-ts/src/vendors.ts b/spacetime-crypto-ts/src/vendors.ts new file mode 100644 index 00000000000..01fefe12dd4 --- /dev/null +++ b/spacetime-crypto-ts/src/vendors.ts @@ -0,0 +1,163 @@ +// Vendor-specific webhook signature verifiers. Each wraps hmacSha256 + +// timingSafeEqual with the per-vendor framing. +// +// Reference docs: +// Stripe: https://docs.stripe.com/webhooks/signatures +// Resend (svix): https://docs.svix.com/receiving/verifying-payloads/how-manual +// GitHub: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries + +import { hmacSha256 } from './hmac.ts'; +import { timingSafeEqual, hexToBytes, base64ToBytes } from './timing.ts'; + +const enc = new TextEncoder(); + +// Stripe + +export interface StripeVerifyOpts { + /** The raw request body, exactly as received (do NOT re-stringify JSON). */ + rawBody: string; + /** Value of the `stripe-signature` request header. */ + signatureHeader: string; + /** The webhook signing secret, e.g. `whsec_...`. */ + secret: string; + /** + * Maximum age of the signed timestamp, in seconds. Stripe recommends 300 + * (5 minutes) to protect against replay. Pass `Infinity` to skip the check + * (only for tests). + */ + toleranceSeconds?: number; + /** Current Unix time in seconds. Defaults to `Date.now()/1000` but the + * STDB module runtime should pass `ctx.timestamp` converted to seconds. */ + nowSeconds?: number; +} + +/** + * Verify a Stripe webhook signature. Returns true iff the signature header + * contains at least one valid v1 signature AND the timestamp is within + * tolerance. + */ +export function verifyStripeSignature(opts: StripeVerifyOpts): boolean { + const tolerance = opts.toleranceSeconds ?? 300; + const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000); + + // Parse "t=1709836800,v1=abcdef,v1=12345..." into a map. + // Multiple v1 entries are possible after key rotation; any match wins. + const fields: Record = {}; + for (const part of opts.signatureHeader.split(',')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + const k = part.slice(0, eq).trim(); + const v = part.slice(eq + 1).trim(); + (fields[k] ??= []).push(v); + } + + const tStr = fields.t?.[0]; + const v1List = fields.v1; + if (!tStr || !v1List || v1List.length === 0) return false; + + const t = Number.parseInt(tStr, 10); + if (!Number.isFinite(t)) return false; + if (tolerance !== Infinity && Math.abs(now - t) > tolerance) return false; + + const signed = enc.encode(`${tStr}.${opts.rawBody}`); + const expected = hmacSha256(enc.encode(opts.secret), signed); + + for (const v1Hex of v1List) { + let candidate: Uint8Array; + try { + candidate = hexToBytes(v1Hex); + } catch { + continue; + } + if (timingSafeEqual(expected, candidate)) return true; + } + return false; +} + +// Resend webhooks use Svix signatures. + +export interface SvixVerifyOpts { + rawBody: string; + /** `svix-id` header. */ + svixId: string; + /** `svix-timestamp` header (Unix seconds as string). */ + svixTimestamp: string; + /** `svix-signature` header, space-separated list like `v1,base64sig`. */ + svixSignature: string; + /** Endpoint secret, in the form `whsec_`. */ + secret: string; + toleranceSeconds?: number; + nowSeconds?: number; +} + +/** + * Verify a svix-style webhook signature (Resend, Clerk, FormBricks, …). + */ +export function verifySvixSignature(opts: SvixVerifyOpts): boolean { + const tolerance = opts.toleranceSeconds ?? 5 * 60; + const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000); + + const ts = Number.parseInt(opts.svixTimestamp, 10); + if (!Number.isFinite(ts)) return false; + if (tolerance !== Infinity && Math.abs(now - ts) > tolerance) return false; + + // Strip the `whsec_` prefix, base64-decode the rest. + const secretBody = opts.secret.startsWith('whsec_') + ? opts.secret.slice('whsec_'.length) + : opts.secret; + let secretBytes: Uint8Array; + try { + secretBytes = base64ToBytes(secretBody); + } catch { + return false; + } + + const signed = enc.encode( + `${opts.svixId}.${opts.svixTimestamp}.${opts.rawBody}` + ); + const expected = hmacSha256(secretBytes, signed); + + // Header is `v1, v1, ...`. Any match wins. + for (const part of opts.svixSignature.split(' ')) { + const comma = part.indexOf(','); + if (comma < 0) continue; + const version = part.slice(0, comma); + if (version !== 'v1') continue; + const sigB64 = part.slice(comma + 1); + let candidate: Uint8Array; + try { + candidate = base64ToBytes(sigB64); + } catch { + continue; + } + if (timingSafeEqual(expected, candidate)) return true; + } + return false; +} + +// GitHub + +export interface GithubVerifyOpts { + rawBody: string; + /** `x-hub-signature-256` header, of form `sha256=`. */ + signatureHeader: string; + /** Webhook secret as configured in the GitHub repo/org webhook settings. */ + secret: string; +} + +/** Verify a GitHub webhook signature (HMAC-SHA256 of body, hex-encoded). */ +export function verifyGithubSignature(opts: GithubVerifyOpts): boolean { + const prefix = 'sha256='; + if (!opts.signatureHeader.startsWith(prefix)) return false; + let candidate: Uint8Array; + try { + candidate = hexToBytes(opts.signatureHeader.slice(prefix.length)); + } catch { + return false; + } + const expected = hmacSha256( + enc.encode(opts.secret), + enc.encode(opts.rawBody) + ); + return timingSafeEqual(expected, candidate); +} diff --git a/spacetime-crypto-ts/tsconfig.json b/spacetime-crypto-ts/tsconfig.json new file mode 100644 index 00000000000..e6a8236bbab --- /dev/null +++ b/spacetime-crypto-ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +} diff --git a/spacetime-files-ts/LICENSE.txt b/spacetime-files-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-files-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-files-ts/README.md b/spacetime-files-ts/README.md new file mode 100644 index 00000000000..072aff20c9c --- /dev/null +++ b/spacetime-files-ts/README.md @@ -0,0 +1,259 @@ +# @spacetimedb/files + +File storage primitives for SpacetimeDB modules: upload, list, delete, and serve +byte blobs with per-file visibility, SHA-256 ETags, and an HTTP handler factory +that streams cached responses through the module's route. + +--- + +## Install + +```bash +npm install @spacetimedb/files @spacetimedb/crypto spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +Bytes live in the module's `file` table as transactional application state. + +## Usage + +### Integrate into an application + +For a new application, mount the submodule first. The host must derive an owner +from its own identity or session model and expose narrow wrappers around the +file helpers. Keep the file table private. + +```ts +import { schema, t } from 'spacetimedb/server'; +import * as files from '@spacetimedb/files/submodule'; + +const spacetimedb = schema({ files }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + files.installFiles(ctx.as.files); +}); + +export const upload_file = spacetimedb.procedure( + files.uploadFileParams, + t.u64(), + (ctx, args) => + files.uploadFileImpl(ctx.as.files, args, ctx.sender.toHexString()) +); +``` + +The host module owns `init`, derives owners from its auth model, and wraps the +helper procedures and HTTP handler with `ctx.as.files`. +See the +[Vault host module](./example/spacetimedb/) +for upload wrappers, scoped metadata views, and an HTTP download route. + +### Standalone table builders + +Use the lower-level row and implementation exports only when the host needs to +own the file tables directly: + +```ts +import { fileRow } from '@spacetimedb/files/rows'; +``` + +| Field | Type | Notes | +| ------------------------- | ----------------- | -------------------------------------------------------------- | +| `id` | `u64` PK auto-inc | | +| `ownerPathKey` | `string` unique | Collision-safe internal owner/path key | +| `path` | `string` indexed | Canonical caller-supplied path, up to 1024 chars | +| `ownerUserId` | `string` indexed | Opaque identity, application user ID, or host-defined actor ID | +| `mimeType` | `string` | | +| `size` | `u64` | | +| `sha256Hex` | `string` | Lowercase hex of `SHA-256(bytes)`; used as strong ETag | +| `visibility` | `string` indexed | `FILE_VISIBILITY_OWNER` or `FILE_VISIBILITY_PUBLIC` | +| `createdAt` / `updatedAt` | `timestamp` | | + +The private `file_blob` table stores `{ fileId, bytes }` separately. Metadata +lookups, `HEAD`, and conditional `304` responses therefore avoid reading or +copying the blob. `GET` and authenticated byte procedures load it after access +checks pass. + +The mounted table is private. Host views should return `fileSummary` rows so +subscriptions carry safe metadata fields. + +The package also exports `fileSummary`, a safe metadata shape that omits +`ownerUserId`, `ownerPathKey`, and blob bytes, for use in procedure and view +return types. + +After generating bindings, upload through the host wrapper and subscribe to a +host view that returns file summaries: + +```ts +const fileId = await conn.procedures.uploadFile({ + path: '/avatars/me.png', + mimeType: 'image/png', + bytes: pngBytes, + visibility: 'owner', +}); + +conn.subscriptionBuilder().subscribe(['SELECT * FROM my_file_summaries']); +``` + +## API + +Each `*Impl` takes `(ctx, args, owner)` so the submodule stays identity-scheme-agnostic. Wrap them with thin reducers in your app module that derive `owner` however you want (caller `Identity`, a session lookup through a mounted auth namespace, etc). + +Package entrypoints: + +- `@spacetimedb/files/submodule` supplies the mountable namespace and all + host integration helpers. +- `@spacetimedb/files` exports the lower-level rows, validation, + procedures, constants, and HTTP handler. +- `@spacetimedb/files/procedures` exports operation parameters, return + types, and implementations. +- `@spacetimedb/files/handlers` exports public-file HTTP serving. +- `@spacetimedb/files/rows` exports table row builders. +- `@spacetimedb/files/constants` is safe to import in browser code. + +Validation exports include `validateFileOwner`, `validateFilePath`, +`validateFilePrefix`, `validateMimeType`, `safeMimeType`, `ownerPathKey`, and +`FileValidationError`. + +### `uploadFile` + +```ts +import { + uploadFileParams, + uploadFileImpl, +} from '@spacetimedb/files/procedures'; +``` + +- Args: `path`, `mimeType`, `bytes` (`u8[]`), `visibility`. +- Returns: `bigint` (the file `id`). +- Upserts by the owner/path pair. Different owners may use the same path. +- Requires an absolute canonical path such as `/images/avatar.png`. +- Enforces `bytes.length <= FILE_BYTES_MAX` (4 MB) and `path.length <= 1024`. +- Accepts a media type such as `image/png` or `image/svg+xml`. Parameters and + control characters are rejected. +- Computes the authoritative `sha256Hex` ETag server-side. + +### `deleteFile` + +- Args: `path`. +- Owner-gated through the owner/path key. +- Returns nothing when the caller has no file at that path. + +### `listFiles` + +```ts +import { + listFilesParams, + listFilesReturn, + listFilesImpl, +} from '@spacetimedb/files/procedures'; +``` + +- Args: `prefix`, optional `cursor`, and optional `limit` from 1 to 200. +- Returns: `{ files, nextCursor }`, ordered by `path`. Pass `nextCursor` into + the next call until it is absent. `bytes` is omitted. +- Scopes to the caller's own files. + +### `setFileVisibility` + +- Args: `path`, `visibility`. +- Owner-gated. + +### `readFileBytes` + +```ts +import { + readFileBytesParams, + readFileBytesReturn, + readFileBytesImpl, +} from '@spacetimedb/files/procedures'; +``` + +- Args: `path`. Returns: `{ bytes: u8[], mimeType: string }`. +- Owner-gated. Throws `files.not_found` / `files.not_owner`. +- **Private files use an authenticated procedure.** SpacetimeDB HTTP route + handlers see the _module's_ identity, so `makeFileServeImpl` serves public + files. Procedures receive the authenticated sender. Wrap `readFileBytesImpl` + in a procedure for private previews and downloads, and use HTTP for cacheable + public files. + +```ts +export const read_file_bytes = spacetimedb.procedure( + readFileBytesParams, + readFileBytesReturn, + (ctx, args) => readFileBytesImpl(ctx, args, ctx.sender.toHexString()) +); +``` + +## HTTP serve handler + +```ts +import { makeFileServeImpl } from '@spacetimedb/files/handlers'; +``` + +Wire a handler into your module's HTTP routes: + +```ts +const serveFile = makeFileServeImpl({ + getOwner: _ctx => undefined, +}); +``` + +Mount it under a route like `/files/*` from your module. The handler: + +- Accepts `GET` and `HEAD` only; everything else 405s. +- Reads the stable file ID from `?id=`. +- Returns 404 for an unknown file and 403 when an owner-only file has a different owner. +- Sends `etag: ""` and honors `If-None-Match` with 304. +- Sets `cache-control: public, max-age=300, must-revalidate` for public files; `private, max-age=60, must-revalidate` for owner files. +- `HEAD` returns headers only; `GET` returns the full body. + +`getOwner` is the host's authentication hook. Return the authenticated owner +value when private HTTP reads are supported. Returning `undefined` limits the +route to public files. + +## Constants + +| Constant | Value | +| ------------------------ | ----------- | +| `FILE_BYTES_MAX` | `4_000_000` | +| `FILE_PATH_MAX` | `1024` | +| `FILE_MIME_TYPE_MAX` | `127` | +| `FILE_LIST_PAGE_MAX` | `200` | +| `FILE_VISIBILITY_OWNER` | `'owner'` | +| `FILE_VISIBILITY_PUBLIC` | `'public'` | + +The limits also ship from the browser-safe `./constants` subpath. It has no +server imports, so clients can pre-validate uploads with the same values. + +## Errors + +All thrown as `SenderError` with stable codes: + +- `files.invalid_path` - non-canonical, unsafe, or longer than 1024 +- `files.invalid_prefix` / `files.invalid_cursor` - invalid listing position +- `files.invalid_page_size` - listing limit outside 1 to 200 +- `files.invalid_mime_type` - invalid or unsafe HTTP media type +- `files.invalid_visibility:` - not in `{owner, public}` +- `files.too_large:/` - body exceeds `FILE_BYTES_MAX` +- `files.not_found:` - `setFileVisibility` or `readFileBytes` on a missing row + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +Build the +[example host module](./example/spacetimedb/) +to verify the +mounted submodule and generated bindings together. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-files-ts/example/.env.example b/spacetime-files-ts/example/.env.example new file mode 100644 index 00000000000..fee6c6aecb1 --- /dev/null +++ b/spacetime-files-ts/example/.env.example @@ -0,0 +1,7 @@ +# Copy to .env. The example server loads this on startup. + +HOST=127.0.0.1 +PORT=8799 +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_APP_DATABASE=spacetime-files-example diff --git a/spacetime-files-ts/example/.gitignore b/spacetime-files-ts/example/.gitignore new file mode 100644 index 00000000000..2c226008d16 --- /dev/null +++ b/spacetime-files-ts/example/.gitignore @@ -0,0 +1,3 @@ +.env +*.log +node_modules diff --git a/spacetime-files-ts/example/README.md b/spacetime-files-ts/example/README.md new file mode 100644 index 00000000000..74b765c61f6 --- /dev/null +++ b/spacetime-files-ts/example/README.md @@ -0,0 +1,164 @@ +# Vault files example + +Vault is a small Drive-style file manager built with +[`@spacetimedb/files`](../). File bytes and file records live in the mounted +Files component; the host module adds identity-owned folder metadata and scoped +views. + +## What this demonstrates + +- Uploading, moving, renaming, listing, downloading, and deleting files. +- Identity-owned folders and caller-scoped file-summary subscriptions. +- Keeping file bytes out of realtime subscriptions. +- Reading private bytes through a sender-aware procedure. +- Serving explicitly public files through the component HTTP handler. +- Drag-and-drop uploads, folder traversal, search, previews, bulk actions, and ZIP + downloads in a browser client. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity for publishing the example. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-files-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open and upload a small image or text file. + +`build:module:fresh` deletes and recreates only the local `spacetime-files-example` +database. Use `pnpm run build:module` when existing local files must be preserved. + +## Use in your project + +This workspace tests the component source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/files @spacetimedb/crypto spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +owner-derivation, scoped-view, and download-handler patterns; the folder model +and file-manager UI are application code in the example. + +## Configuration + +| Variable | Default | Purpose | +| ------------------- | ------------------------- | ------------------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8799` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | Upstream endpoint for public file HTTP requests. | +| `STDB_APP_DATABASE` | `spacetime-files-example` | Published database name. | + +The Node server hosts the bundle and proxies `/files?id=` to the module HTTP router. +It receives metadata for authorization decisions. Private bytes travel through +the authenticated SpacetimeDB connection. + +## Read and write paths + +The browser subscribes to `my_folders` and `my_file_summaries`. These views are +filtered by the connection identity, and summaries omit `bytes`. Folder and file +mutations use reducers. + +Private content is returned by the `read_file_bytes` procedure. Procedures retain +the real caller in `ctx.sender`, allowing the host module to enforce ownership +before returning bytes over the authenticated SpacetimeDB connection. HTTP +handlers execute with the module route context and serve public files. + +Files marked public can use `/files?id=` for direct HTTP reads. Making a file public +changes its confidentiality and creates a public download path. + +## Paths and limits + +- Paths are absolute and slash-prefixed, for example `/docs/readme.txt`. +- File and folder paths are owner-scoped. Two identities can each use `/docs` + and `/docs/readme.txt`. +- Public links use the stable numeric file ID. +- The component stores bytes in SpacetimeDB rows and caps each file at 4 MB. +- Vault demonstrates in-row storage for small assets. Use dedicated infrastructure + for streaming uploads, media transformation, backups, and CDN delivery. + +The browser stores its development SpacetimeDB identity token so files remain +associated with the same identity after reload. If a fresh database rejects the +token, the client obtains a new anonymous identity. Existing data remains with +its original identity. + +## Security and deployment boundaries + +- Reducers, views, and private-byte procedures enforce ownership. Browser controls + provide presentation only. +- Validate path normalization, MIME metadata, file size, and ownership before + accepting writes or moves. +- Treat uploaded bytes as untrusted. Production systems need content-disposition + policy, safe MIME handling, malware scanning where appropriate, and defenses + against active HTML/SVG content. +- Public file URLs are bearer-readable by design. Do not expose confidential files + by marking them public. +- The example buffers whole files and generated ZIPs in memory. Production limits + should account for per-file size, concurrent requests, and aggregate memory. +- The included proxy is a local development server. Production needs TLS, explicit + binding, request limits, origin policy, and process supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run check +pnpm run build +``` + +For a release smoke test, use two independent browser identities and verify: + +1. Upload, preview, download, rename, move, and delete each supported small file + type. +2. Folder drag-and-drop and multi-selection perform the intended operation once. +3. Private file bytes and summaries are invisible to the other identity. +4. A public file is reachable through `/files?id=`; an owner-only file returns 403. +5. Oversized uploads and invalid or conflicting paths fail atomically. +6. Refresh preserves the owning development identity unless the database was + deliberately reset. + +## Troubleshooting + +- **A preview is empty:** inspect the procedure failure and verify the connected + identity owns the file. +- **A public link returns an error:** confirm `STDB_HTTP` and `STDB_APP_DATABASE` + target the database used by `STDB_URI`. +- **An upload exceeds the limit:** keep example files below 4 MB; use an external + object store for larger production assets. +- **Files disappear after a fresh publish:** `build:module:fresh` deliberately + replaces the local database and all of its rows. + +## Important files + +- `spacetimedb/src/index.ts` - Files mount, folders, scoped views, and private reads. +- `src/app.ts` - file-manager state, uploads, previews, downloads, and subscriptions. +- `server.ts` - static development server and public-file proxy. +- `public/index.html` - Vault interface. +- `public/styles.css` - Vault presentation. diff --git a/spacetime-files-ts/example/package.json b/spacetime-files-ts/example/package.json new file mode 100644 index 00000000000..334a6a562e5 --- /dev/null +++ b/spacetime-files-ts/example/package.json @@ -0,0 +1,29 @@ +{ + "name": "spacetime-files-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "check": "tsc --noEmit", + "test:unit": "tsx scripts/test-downloads.ts && tsx scripts/test-selection.ts", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/files": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-files-ts/example/public/assets/brand.svg b/spacetime-files-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-files-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-files-ts/example/public/assets/logo.svg b/spacetime-files-ts/example/public/assets/logo.svg new file mode 100644 index 00000000000..adaf36cbf9c --- /dev/null +++ b/spacetime-files-ts/example/public/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/spacetime-files-ts/example/public/index.html b/spacetime-files-ts/example/public/index.html new file mode 100644 index 00000000000..05086e1cb76 --- /dev/null +++ b/spacetime-files-ts/example/public/index.html @@ -0,0 +1,594 @@ + + + + + + + SpacetimeDB Vault + + + + + + +
+
+
+ +
+

Vault

+ File manager +
+
+
+ +
+
+
+

Folders

+ +
+
+
    +
    +
    +
    + +
    +
    +
    + + +
    + + + + + + +
    +
    + +
    +
    + + + + + + +
    +
    +
      + +
      +
      + +
      +
      +

      Details

      + +
      +
      +
      +
      + + +
      + +
      + +
      + +
      + Drop to upload to/ +
      + +
      + + + +
      + + + + diff --git a/spacetime-files-ts/example/public/styles.css b/spacetime-files-ts/example/public/styles.css new file mode 100644 index 00000000000..7c1c0ca354e --- /dev/null +++ b/spacetime-files-ts/example/public/styles.css @@ -0,0 +1,1268 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap'); + +:root { + /* Tokens mirror spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-ibm: 'IBM Plex Mono', ui-monospace, monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-blue: #02befa; + --color-purple: #a880ff; + --color-red: #ff4c4c; + --color-red-10: #ff4c4c1a; + --color-red-20: #ff4c4c33; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + + /* One source of truth for the file-list grid so headers align with rows. */ + --row-grid: 26px minmax(0, 1fr) 76px 92px 112px auto; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-white); + background: var(--color-shade7); + overflow: hidden; + -webkit-font-smoothing: antialiased; +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} +a { + color: var(--color-green); + text-decoration: none; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) transparent; +} +*::-webkit-scrollbar { + width: 7px; + height: 7px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 4px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade1); +} + +button, +input, +select { + font: inherit; +} +button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade7); + color: var(--color-n2); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background 0.16s, + border-color 0.16s, + color 0.16s; +} +button:hover:not(:disabled) { + background: var(--color-shade4); + color: var(--color-white); +} +button:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +button.primary { + background: var(--color-n3); + border-color: var(--color-n3); + color: var(--color-n8); +} +button.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); +} +button.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} +button.danger { + color: var(--color-n3); +} +button.danger:hover:not(:disabled) { + background: var(--color-red-10); + border-color: var(--color-red-20); + color: var(--color-red); +} +button.icon { + min-height: 30px; + width: 30px; + padding: 0; + color: var(--color-n4); +} +button.icon:hover:not(:disabled) { + color: var(--color-white); +} +button.icon.danger:hover:not(:disabled) { + color: var(--color-red); +} +button.icon.active { + color: var(--color-green); + border-color: #1f4a34; + background: var(--color-green-10); +} +button svg, +.badge svg, +.brand-mark svg { + width: 16px; + height: 16px; + flex: 0 0 auto; +} +.ico { + width: 16px; + height: 16px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +input, +select { + width: 100%; + min-height: 38px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade8); + color: var(--color-white); + padding: 0 12px; + outline: none; + transition: + border-color 0.15s, + box-shadow 0.15s; +} +input::placeholder { + color: var(--color-n4); +} +input:focus, +select:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-10); +} +input[type='checkbox'] { + width: 15px; + height: 15px; + min-height: 0; + margin: 0; + accent-color: var(--color-green); + cursor: pointer; +} + +.shell { + width: min(1320px, calc(100% - 28px)); + height: calc(100dvh - 28px); + margin: 14px auto; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 12px; +} + +/* Topbar */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #0d1920, #0b1319); + box-shadow: inset 0 1px 0 #26435166; + padding: 11px 16px; +} +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.brand-mark { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: var(--radius); + display: grid; + place-items: center; + color: var(--color-green); + background: var(--color-green-10); + border: 1px solid var(--color-green-20); +} +.brand-mark svg { + width: 19px; + height: 19px; +} +.brand-text { + display: grid; + gap: 1px; + min-width: 0; +} +.brand-text h1 { + margin: 0; + font-size: 15px; + font-weight: 700; + line-height: 1.2; + color: var(--color-n1); +} +.brand-text span { + color: var(--color-n4); + font-size: 12px; +} + +/* Layout */ +.main { + min-height: 0; + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 12px; +} +.main.details-open { + grid-template-columns: 280px minmax(0, 1fr) 300px; +} +.main:not(.details-open) .details-panel { + display: none; +} +.panel { + min-height: 0; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + display: flex; + flex-direction: column; + overflow: hidden; +} +.panel-head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 13px 14px; + border-bottom: 1px solid var(--color-shade4); +} +.panel-head h2 { + margin: 0; + font-family: var(--font-ibm); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--color-n4); +} +.panel-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 10px; +} +.storage { + flex: 0 0 auto; + padding: 10px 14px; + border-top: 1px solid var(--color-shade4); + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; +} + +.toolbar { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 11px 14px; + border-bottom: 1px solid var(--color-shade4); + min-height: 57px; +} +.toolbar-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex: 1; + min-width: 0; +} +.crumbs { + min-width: 0; + flex: 1; + display: flex; + align-items: center; + gap: 2px; + overflow: hidden; + white-space: nowrap; +} +.crumbs button { + min-height: 28px; + padding: 0 8px; + border-color: transparent; + background: transparent; + color: var(--color-n4); + font-weight: 600; +} +.crumbs button:hover { + background: var(--color-shade4); + color: var(--color-white); +} +.crumbs button:last-child { + color: var(--color-white); +} +.crumbs .sep { + color: var(--color-n5); + font-family: var(--font-ibm); +} +.crumbs .search-label { + color: var(--color-n3); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; +} +.actions { + display: inline-flex; + align-items: center; + gap: 8px; +} +.search { + width: 190px; + min-height: 32px; + flex: 0 1 auto; +} +.zoom { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 0 10px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade8); + color: var(--color-n4); +} +.zoom[hidden] { + display: none; +} +.zoom .z-sm { + width: 9px; + height: 9px; + flex: 0 0 auto; +} +.zoom .z-lg { + width: 14px; + height: 14px; + flex: 0 0 auto; +} +.tile-slider { + appearance: auto; + width: 92px; + min-height: 0; + height: 16px; + padding: 0; + border: 0; + background: transparent; + accent-color: var(--color-green); + cursor: pointer; +} +.tile-slider:focus { + box-shadow: none; +} + +/* Bulk-selection bar (swaps in for the toolbar content) */ +.bulkbar { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} +.bulkbar[hidden] { + display: none; +} +.bulk-count { + color: var(--color-green); + font-family: var(--font-ibm); + font-size: 12px; + white-space: nowrap; + margin-right: 4px; +} +.bulkbar .spacer { + flex: 1; +} +.bulkbar button { + min-height: 30px; + padding: 0 11px; + font-size: 12px; +} + +/* Folder tree */ +.tree-list, +.file-list { + list-style: none; + margin: 0; + padding: 0; +} +.tree-btn { + width: 100%; + justify-content: flex-start; + min-height: 32px; + border-color: transparent; + background: transparent; + color: var(--color-n3); + font-weight: 600; +} +.tree-btn:hover { + background: var(--color-shade4); + color: var(--color-white); +} +.tree-btn.active { + color: var(--color-green); + background: var(--color-green-10); +} +.tree-btn .ico { + color: var(--color-yellow); +} +.tree-btn.active .ico { + color: var(--color-green); +} +.tree-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tree-btn.drag-over, +.row.drag-over, +.tile.drag-over { + background: var(--color-green-10); + box-shadow: inset 0 0 0 1px var(--color-green); +} + +/* File list header (sortable) */ +.list-head { + flex: 0 0 auto; + display: grid; + grid-template-columns: var(--row-grid); + gap: 10px; + align-items: center; + padding: 6px 20px 6px 20px; + border-bottom: 1px solid var(--color-shade4); +} +.list-head.grid-mode { + display: none; +} +.list-head .sel { + display: inline-flex; + justify-content: center; +} +.list-head button { + justify-content: flex-start; + min-height: 26px; + padding: 0 4px; + border-color: transparent; + background: transparent; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.list-head button:hover { + color: var(--color-n2); + background: transparent; +} +.list-head button.sorted { + color: var(--color-green); +} + +/* File rows (list view) */ +.row { + display: grid; + grid-template-columns: var(--row-grid); + gap: 10px; + align-items: center; + min-height: 52px; + border-radius: var(--radius-sm); + padding: 6px 10px; + transition: background 0.14s; + cursor: default; +} +.row:hover { + background: var(--color-shade4); +} +.row.focused { + background: var(--color-shade4); +} +.row.selected { + background: var(--color-green-10); +} +.row .sel { + display: inline-flex; + justify-content: center; + visibility: hidden; +} +.row:hover .sel, +.row.selected .sel, +.list.has-selection .row .sel { + visibility: visible; +} +@media (hover: none) { + .row .sel { + visibility: visible; + } +} +.row-name { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--color-white); + font-weight: 600; +} +.row-name .label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kind { + width: 30px; + height: 30px; + flex: 0 0 auto; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); +} +.kind.folder { + color: var(--color-yellow); +} +.kind.image { + color: var(--color-blue); +} +.kind.text { + color: var(--color-green); +} +.kind.media { + color: var(--color-purple); +} +.kind.generic { + color: var(--color-n3); +} +.meta { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 0 9px; + border-radius: 999px; + border: 1px solid var(--color-shade4); + background: var(--color-shade7); + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + transition: + background 0.14s, + border-color 0.14s, + color 0.14s; +} +.badge svg { + width: 13px; + height: 13px; +} +.badge:hover { + border-color: var(--color-n5); + color: var(--color-n2); +} +.badge.public { + color: var(--color-green); + border-color: #1f4a34; + background: var(--color-green-10); +} +.badge.private { + color: var(--color-n3); +} +.row-actions { + display: inline-flex; + justify-content: flex-end; + gap: 3px; +} +/* Secondary actions reveal on hover on pointer devices; always shown on touch. */ +.row-actions .secondary { + opacity: 0; + transition: opacity 0.14s; +} +.row:hover .row-actions .secondary, +.row:focus-within .row-actions .secondary { + opacity: 1; +} +@media (hover: none) { + .row-actions .secondary { + opacity: 1; + } +} + +/* Grid view */ +.list.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--tile, 150px), 1fr)); + gap: 10px; + align-content: start; +} +.tile { + position: relative; + display: grid; + gap: 0; + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + background: var(--color-shade7); + overflow: hidden; + transition: + border-color 0.14s, + background 0.14s; + cursor: default; +} +.tile:hover { + border-color: var(--color-shade1); +} +.tile.focused { + border-color: var(--color-n5); + background: var(--color-shade4); +} +.tile.selected { + border-color: #1f4a34; + background: var(--color-green-10); +} +.tile .sel { + position: absolute; + top: 7px; + left: 7px; + z-index: 2; + visibility: hidden; +} +.tile:hover .sel, +.tile.selected .sel, +.list.has-selection .tile .sel { + visibility: visible; +} +@media (hover: none) { + .tile .sel { + visibility: visible; + } +} +.tile .vis-dot { + position: absolute; + top: 7px; + right: 7px; + z-index: 2; + width: 22px; + height: 22px; + border-radius: 50%; + display: grid; + place-items: center; + background: rgba(6, 10, 12, 0.72); + color: var(--color-n3); +} +.tile .vis-dot svg { + width: 12px; + height: 12px; +} +.tile .vis-dot.public { + color: var(--color-green); +} +.thumb { + aspect-ratio: 4 / 3; + display: grid; + place-items: center; + background: var(--color-shade8); + border-bottom: 1px solid var(--color-shade4); + overflow: hidden; +} +.thumb svg { + width: 34px; + height: 34px; + opacity: 0.85; +} +.thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.tile-name { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + padding: 8px 10px; + font-size: 12px; + font-weight: 600; +} +.tile-name svg { + width: 14px; + height: 14px; + flex: 0 0 auto; +} +.tile-name .label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tile-name.folder svg { + color: var(--color-yellow); +} +.tile-name.image svg { + color: var(--color-blue); +} +.tile-name.text svg { + color: var(--color-green); +} +.tile-name.media svg { + color: var(--color-purple); +} +.tile-name.generic svg { + color: var(--color-n3); +} + +/* Context menu */ +.ctx { + position: fixed; + z-index: 65; + min-width: 190px; + display: none; + padding: 5px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + background: var(--color-shade5); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55); +} +.ctx.open { + display: grid; +} +.ctx button { + justify-content: flex-start; + min-height: 32px; + padding: 0 10px; + border-color: transparent; + background: transparent; + font-size: 13px; + font-weight: 500; + gap: 10px; +} +.ctx button svg { + color: var(--color-n4); +} +.ctx button:hover { + background: var(--color-shade4); +} +.ctx button.danger:hover { + background: var(--color-red-10); + color: var(--color-red); +} +.ctx button.danger:hover svg { + color: var(--color-red); +} +.ctx .sep { + height: 1px; + background: var(--color-shade4); + margin: 4px 2px; +} + +.empty { + margin: 8px; + border: 1px dashed var(--color-shade4); + border-radius: var(--radius); + padding: 34px 20px; + display: grid; + justify-items: center; + gap: 12px; + color: var(--color-n4); + text-align: center; + font-size: 13px; + line-height: 1.5; +} +.empty svg { + width: 34px; + height: 34px; + color: var(--color-n5); + stroke-width: 1.5; +} +.empty strong { + color: var(--color-n2); + font-weight: 600; + font-size: 14px; +} + +/* Details panel (Drive-style info sidebar, toggled) */ +.d-thumb { + aspect-ratio: 4 / 3; + display: grid; + place-items: center; + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + background: var(--color-shade8); + overflow: hidden; + margin-bottom: 12px; +} +.d-thumb svg { + width: 38px; + height: 38px; + opacity: 0.85; +} +.d-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.d-name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-weight: 700; + color: var(--color-n1); + margin-bottom: 12px; + overflow-wrap: anywhere; +} +.d-name svg { + width: 16px; + height: 16px; + flex: 0 0 auto; + color: var(--color-n3); +} +.details { + display: grid; + gap: 8px; +} +.details div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 10px; + font-size: 12px; +} +.details span { + flex: 0 0 auto; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.details b { + color: var(--color-n2); + font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.details b.mono { + font-family: var(--font-ibm); + font-size: 11px; +} +.details button.linkish { + min-height: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--color-green); + font-size: 12px; + font-weight: 500; +} +.details button.linkish:hover { + background: transparent; + text-decoration: underline; +} + +/* Viewer stage content (full-screen preview overlay) */ +.lb-stage pre { + margin: 0; + width: min(860px, calc(100vw - 48px)); + max-height: calc(100vh - 140px); + overflow: auto; + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + padding: 18px; + background: var(--color-shade6); + color: var(--color-n2); + font: 13px/1.6 var(--font-ibm); + white-space: pre-wrap; +} +.lb-stage audio { + width: min(480px, calc(100vw - 48px)); +} +.lb-stage video { + max-width: calc(100vw - 48px); + max-height: calc(100vh - 140px); + border-radius: var(--radius); + background: var(--color-shade8); +} +.lb-stage iframe.pdf { + width: min(900px, calc(100vw - 48px)); + height: calc(100vh - 130px); + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + background: #fff; +} +.lb-stage .notice { + display: grid; + gap: 14px; + justify-items: center; + padding: 34px 44px; + border: 1px dashed var(--color-shade1); + border-radius: var(--radius-lg); + background: var(--color-shade6); + color: var(--color-n3); + font-size: 14px; + text-align: center; +} +.lb-stage .notice svg { + width: 34px; + height: 34px; + color: var(--color-n4); +} + +/* Dialog */ +.dialog { + position: fixed; + inset: 0; + display: none; + place-items: center; + background: rgba(3, 8, 10, 0.62); + backdrop-filter: blur(2px); + z-index: 30; +} +.dialog.open { + display: grid; +} +.card { + width: min(480px, calc(100% - 32px)); + border: 1px solid var(--color-shade1); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.55); + animation: card-in 0.18s ease-out; +} +@keyframes card-in { + from { + opacity: 0; + transform: translateY(-8px) scale(0.99); + } + to { + opacity: 1; + transform: none; + } +} +.card-head { + padding: 16px; + border-bottom: 1px solid var(--color-shade4); + font-weight: 700; + color: var(--color-n1); +} +.card-body { + padding: 16px; + display: grid; + gap: 12px; +} +.card-body p { + margin: 0; + color: var(--color-n3); + font-size: 13px; + line-height: 1.5; +} +.card-body.wide { + max-height: min(70vh, 520px); + overflow: auto; +} +.card-actions { + padding: 16px; + display: flex; + justify-content: flex-end; + gap: 8px; + border-top: 1px solid var(--color-shade4); +} +label { + display: grid; + gap: 7px; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* OS-file drag: viewport ring + target pill (UI stays visible so folders + can be targeted directly). */ +body.dragging-files::after { + content: ''; + position: fixed; + inset: 6px; + z-index: 39; + border: 2px dashed var(--color-green); + border-radius: var(--radius-lg); + pointer-events: none; +} +.drop-hint { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + z-index: 40; + display: none; + align-items: center; + gap: 8px; + padding: 9px 15px; + border: 1px solid #1f4a34; + border-radius: 999px; + background: var(--color-shade5); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5); + color: var(--color-green); + font-size: 13px; + font-weight: 600; + pointer-events: none; + white-space: nowrap; +} +.drop-hint b { + font-family: var(--font-ibm); + font-weight: 500; + color: var(--color-n2); +} +body.dragging-files .drop-hint { + display: inline-flex; +} + +/* Lightbox (image zoom) */ +.lightbox { + position: fixed; + inset: 0; + display: none; + z-index: 60; + background: rgba(3, 8, 10, 0.88); + backdrop-filter: blur(3px); +} +.lightbox.open { + display: block; +} +.lb-controls { + position: absolute; + top: 14px; + right: 14px; + z-index: 2; + display: flex; + gap: 6px; + padding: 6px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + background: var(--color-shade6); +} +.lb-controls button { + min-height: 30px; + padding: 0 11px; + font-size: 12px; +} +.lb-name { + position: absolute; + top: 16px; + left: 18px; + z-index: 2; + display: grid; + gap: 3px; + max-width: 44vw; +} +.lb-name strong { + color: var(--color-n1); + font-size: 14px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lb-name span { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lb-stage { + position: absolute; + inset: 0; + overflow: auto; + display: grid; + place-items: center; + padding: 24px; +} +.lb-stage img { + display: block; +} +.lb-stage img.fit { + max-width: calc(100vw - 48px); + max-height: calc(100vh - 48px); +} + +/* Toast */ +#toast { + position: fixed; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + z-index: 70; + display: grid; + gap: 8px; +} +.toast { + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + background: var(--color-shade5); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5); + padding: 11px 14px; + color: var(--color-n2); + font-size: 13px; + animation: card-in 0.18s ease-out; +} +.toast.ok { + border-color: #1f4a34; + color: var(--color-green); +} +.toast.err { + border-color: var(--color-red-20); + color: var(--color-red); +} + +/* Built-on footer */ +.built-on { + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + min-height: 52px; + border-top: 1px solid var(--color-shade4); + color: var(--color-n4); +} +.built-on span { + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.built-on img { + height: 26px; + opacity: 0.85; +} + +@media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} + +@media (max-width: 1080px) { + .main, + .main.details-open { + grid-template-columns: 240px minmax(0, 1fr); + } + .details-panel { + display: none; + } +} +@media (max-width: 760px) { + body { + overflow: auto; + } + .shell { + height: auto; + min-height: calc(100dvh - 20px); + width: calc(100% - 20px); + margin: 10px auto; + } + .main { + grid-template-columns: 1fr; + } + .panel { + min-height: 260px; + } + :root { + --row-grid: 26px minmax(0, 1fr) auto; + } + .row .meta, + .row .badge-cell, + .list-head .meta-col { + display: none; + } + .toolbar-main { + flex-wrap: wrap; + } + .search { + width: 100%; + order: 3; + } +} diff --git a/spacetime-files-ts/example/scripts/test-downloads.ts b/spacetime-files-ts/example/scripts/test-downloads.ts new file mode 100644 index 00000000000..c5cdabbdf2d --- /dev/null +++ b/spacetime-files-ts/example/scripts/test-downloads.ts @@ -0,0 +1,120 @@ +import * as assert from 'node:assert/strict'; +import type { FileSummary } from '../src/codegen/app/types'; +import { + ARCHIVE_ENTRY_COUNT_MAX, + ARCHIVE_FILE_COUNT_MAX, + ARCHIVE_TOTAL_BYTES_MAX, + archiveSelectionError, +} from '../src/downloads'; +import { FileViewer } from '../src/viewer'; + +const fileWithSize = (size: bigint): FileSummary => ({ size }) as FileSummary; + +assert.equal(archiveSelectionError([fileWithSize(1024n)]), undefined); +assert.match( + archiveSelectionError( + Array.from({ length: ARCHIVE_FILE_COUNT_MAX + 1 }, () => fileWithSize(0n)) + ) ?? '', + /at most 250 files/ +); +assert.match( + archiveSelectionError([], ARCHIVE_ENTRY_COUNT_MAX + 1) ?? '', + /1000 entry limit/ +); +assert.match( + archiveSelectionError([fileWithSize(BigInt(ARCHIVE_TOTAL_BYTES_MAX) + 1n)]) ?? + '', + /64 MiB limit/ +); + +console.log('files download tests passed'); + +type TestElement = { + classList: { + add(name: string): void; + remove(name: string): void; + contains(name: string): boolean; + }; + innerHTML: string; + style: { display: string }; + textContent: string | null; + title: string; + disabled: boolean; + querySelector(): null; +}; + +function testElement(): TestElement { + const classes = new Set(); + return { + classList: { + add: name => classes.add(name), + remove: name => classes.delete(name), + contains: name => classes.has(name), + }, + innerHTML: '', + style: { display: '' }, + textContent: null, + title: '', + disabled: false, + querySelector: () => null, + }; +} + +async function testClosingViewerCancelsPendingLoad(): Promise { + const ids = [ + 'lightbox', + 'lb-stage', + 'lb-title', + 'lb-meta', + 'lb-prev', + 'lb-next', + 'lb-out', + 'lb-in', + 'lb-fit', + 'lb-full', + ]; + const elements = new Map(ids.map(id => [id, testElement()])); + const previousDocument = globalThis.document; + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + getElementById(id: string) { + return elements.get(id) ?? null; + }, + }, + }); + + let resolveBlob: ((blob: Blob) => void) | undefined; + const viewer = new FileViewer({ + loadBlob: () => + new Promise(resolve => { + resolveBlob = resolve; + }), + download: async () => undefined, + iconHtml: () => '', + }); + const row = { + path: '/notes.txt', + mimeType: 'text/plain', + size: 5n, + visibility: 'owner', + updatedAt: { microsSinceUnixEpoch: 1_000n }, + } as FileSummary; + + try { + const opening = viewer.open(row.path, [row]); + viewer.close(); + resolveBlob?.(new Blob(['hello'], { type: 'text/plain' })); + await opening; + assert.equal(elements.get('lb-stage')?.innerHTML, ''); + assert.equal(viewer.path, null); + } finally { + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: previousDocument, + }); + } +} + +await testClosingViewerCancelsPendingLoad(); +console.log('files viewer tests passed'); diff --git a/spacetime-files-ts/example/scripts/test-selection.ts b/spacetime-files-ts/example/scripts/test-selection.ts new file mode 100644 index 00000000000..488ddbb5540 --- /dev/null +++ b/spacetime-files-ts/example/scripts/test-selection.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { VaultSelection } from '../src/selection'; + +const selection = new VaultSelection(); +selection.setEntries([ + { type: 'folder', path: '/docs' }, + { type: 'file', path: '/a.txt' }, + { type: 'file', path: '/b.txt' }, + { type: 'file', path: '/c.txt' }, +]); + +assert.equal(selection.focus('/a.txt'), true); +assert.equal(selection.focusPath, '/a.txt'); +assert.equal(selection.focus('/a.txt'), false); + +selection.toggle('/a.txt'); +selection.selectRange('/c.txt'); +assert.deepEqual([...selection.selected], ['/a.txt', '/b.txt', '/c.txt']); + +selection.toggle('/b.txt'); +assert.deepEqual([...selection.selected], ['/a.txt', '/c.txt']); + +selection.selected.clear(); +selection.setAnchor('/c.txt'); +selection.selectRange('/a.txt'); +assert.deepEqual([...selection.selected], ['/a.txt', '/b.txt', '/c.txt']); + +selection.selected.clear(); +selection.selected.add('/a.txt'); +selection.selected.add('/c.txt'); +selection.setAnchor('/missing.txt'); +selection.selectRange('/b.txt'); +assert.deepEqual([...selection.selected], ['/a.txt', '/c.txt', '/b.txt']); + +selection.focus('/docs'); +selection.prune( + new Set(['/b.txt', '/c.txt']), + new Set(['/docs', '/b.txt', '/c.txt']) +); +assert.deepEqual([...selection.selected], ['/c.txt', '/b.txt']); +assert.equal(selection.focusPath, '/docs'); + +selection.prune(new Set(['/b.txt', '/c.txt']), new Set(['/b.txt', '/c.txt'])); +assert.equal(selection.focusPath, null); + +selection.clearFocus(); +assert.equal(selection.focusPath, null); + +console.log('files selection tests passed'); diff --git a/spacetime-files-ts/example/server.ts b/spacetime-files-ts/example/server.ts new file mode 100644 index 00000000000..4fcdf7dc3a5 --- /dev/null +++ b/spacetime-files-ts/example/server.ts @@ -0,0 +1,104 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8799', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-files-example'; + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +function proxyStdbRoute(prefix: string) { + return async (req: Request, res: Response) => { + const mountedUrl = req.url.startsWith('/?') ? req.url.slice(1) : req.url; + let fullPath = `${prefix}${mountedUrl}`; + if (prefix === '/files' && mountedUrl.startsWith('/')) { + const qIdx = mountedUrl.indexOf('?'); + const rawPath = qIdx < 0 ? mountedUrl : mountedUrl.slice(0, qIdx); + const originalQuery = qIdx < 0 ? '' : mountedUrl.slice(qIdx + 1); + const pathQuery = `path=${encodeURIComponent(decodeURIComponent(rawPath))}`; + fullPath = `/files?${originalQuery ? `${pathQuery}&${originalQuery}` : pathQuery}`; + } + const qIdx = fullPath.indexOf('?'); + const routePath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${routePath}${query}`; + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') headers[key] = value; + else if (Array.isArray(value)) headers[key] = value.join(', '); + } + delete headers.host; + delete headers['content-length']; + + try { + const upstream = await fetch(upstreamUrl, { + method: req.method, + headers, + redirect: 'manual', + }); + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === 'transfer-encoding' || lower === 'content-encoding') + return; + res.setHeader(key, value); + }); + if (req.method === 'HEAD') { + res.end(); + return; + } + res.send(Buffer.from(await upstream.arrayBuffer())); + } catch (err) { + res.status(502).json({ + error: 'upstream_unreachable', + detail: (err as Error).message, + }); + } + }; +} + +app.use('/files', proxyStdbRoute('/files')); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, app: STDB_APP_DB }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ stdbUri: STDB_URI, appDatabase: STDB_APP_DB }); +}); + +app.listen(PORT, HOST, () => { + console.log(`Vault test app running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxy /files/*)`); + console.log(` Database -> ${STDB_APP_DB}`); +}); diff --git a/spacetime-files-ts/example/spacetimedb/package.json b/spacetime-files-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..d24a0ce1fe1 --- /dev/null +++ b/spacetime-files-ts/example/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-files-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-files-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-files-example" + }, + "dependencies": { + "@spacetimedb/files": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-files-ts/example/spacetimedb/src/index.ts b/spacetime-files-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..3c031b3b24b --- /dev/null +++ b/spacetime-files-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,444 @@ +import { + Router, + SenderError, + schema, + table, + t, + type InferSchema, + type ReducerCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, + FILE_BYTES_MAX, + fileSummary, + fileSha256Hex, + makeFileServeImpl, + ownerPathKey, + readFileBytesParams, + readFileBytesReturn, + readFileBytesImpl, + validateMimeType, +} from '@spacetimedb/files/submodule'; +import * as files from '@spacetimedb/files/submodule'; + +const PATH_MAX = 1024; +const NAME_MAX = 128; +const VALID_VISIBILITIES = new Set([ + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, +]); + +const folder = table( + { + name: 'folder', + public: false, + indexes: [ + { + accessor: 'ownerPath', + algorithm: 'btree', + columns: ['ownerUserId', 'path'] as const, + }, + ] as const, + }, + { + // Reducers enforce per-owner folder uniqueness. + id: t.u64().primaryKey().autoInc(), + ownerUserId: t.string().index(), + path: t.string(), + name: t.string(), + parentPath: t.string().index(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +const spacetimedb = schema({ + files, + folder, +}); +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx; + +const FolderRow = folder.rowType; + +function senderError(message: string): never { + throw new SenderError(message); +} + +function ownerUserId(ctx: { sender: { toHexString(): string } }): string { + return ctx.sender.toHexString(); +} + +function normalizePath(input: string, kind: 'file' | 'folder'): string { + let path = input.trim().replace(/\\/g, '/').replace(/\/+/g, '/'); + if (!path.startsWith('/')) path = `/${path}`; + if (path.length > 1 && path.endsWith('/')) + senderError('vault.invalid_path:trailing_slash'); + if (path.length === 0 || path.length > PATH_MAX) + senderError('vault.invalid_path:length'); + if (kind === 'file' && path === '/') senderError('vault.invalid_file_path'); + const parts = path.split('/').filter(Boolean); + for (const part of parts) { + if (part === '.' || part === '..') + senderError('vault.invalid_path:segment'); + if (part.trim() !== part || part.length === 0 || part.length > NAME_MAX) { + senderError('vault.invalid_path:segment'); + } + } + return path; +} + +function parentPathFor(path: string): string { + if (path === '/') return '/'; + const idx = path.lastIndexOf('/'); + return idx <= 0 ? '/' : path.slice(0, idx); +} + +function basename(path: string): string { + if (path === '/') return '/'; + return path.slice(path.lastIndexOf('/') + 1); +} + +function findOwnedFolder(tx: Tx, path: string, owner: string) { + for (const row of tx.db.folder.ownerPath.filter([owner, path])) return row; + return undefined; +} + +function assertParentFolderExists(tx: Tx, path: string, owner: string): void { + const parent = parentPathFor(path); + if (parent === '/') return; + if (!findOwnedFolder(tx, parent, owner)) + senderError(`vault.parent_not_found:${parent}`); +} + +function assertNoFolderCollision(tx: Tx, path: string, owner: string): void { + if (findOwnedFolder(tx, path, owner)) + senderError(`vault.folder_exists:${path}`); +} + +function assertNoOwnedFileCollision(tx: Tx, path: string, owner: string): void { + if (tx.db.files.file.ownerPathKey.find(ownerPathKey(owner, path))) { + senderError(`vault.file_exists:${path}`); + } +} + +function requireOwnedFolder(tx: Tx, path: string, owner: string) { + const row = findOwnedFolder(tx, path, owner); + if (!row) senderError(`vault.folder_not_found:${path}`); + return row; +} + +function requireOwnedFile(tx: Tx, path: string, owner: string) { + const row = tx.db.files.file.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) senderError(`vault.file_not_found:${path}`); + return row; +} + +function childPrefix(path: string): string { + return path === '/' ? '/' : `${path}/`; +} + +function folderHasChildren(tx: Tx, path: string, owner: string): boolean { + for (const row of tx.db.folder.parentPath.filter(path)) { + if (row.ownerUserId === owner) return true; + } + const prefix = childPrefix(path); + for (const row of tx.db.files.file.ownerUserId.filter(owner)) { + if (row.path.startsWith(prefix)) return true; + } + return false; +} + +function renameOwnedFile( + tx: Tx, + owner: string, + oldPath: string, + newPath: string +): void { + if (oldPath === newPath) return; + const row = requireOwnedFile(tx, oldPath, owner); + assertParentFolderExists(tx, newPath, owner); + assertNoFolderCollision(tx, newPath, owner); + assertNoOwnedFileCollision(tx, newPath, owner); + tx.db.files.file.id.update({ + ...row, + ownerPathKey: ownerPathKey(owner, newPath), + path: newPath, + updatedAt: tx.timestamp, + }); +} + +export const myFolders = spacetimedb.view( + { name: 'my_folders', public: true }, + t.array(FolderRow), + (ctx: ViewCtx) => { + const owner = ownerUserId(ctx); + return [...ctx.db.folder.ownerUserId.filter(owner)].sort((a, b) => + a.path.localeCompare(b.path) + ); + } +); + +export const myFileSummaries = spacetimedb.view( + { name: 'my_file_summaries', public: true }, + t.array(fileSummary), + (ctx: ViewCtx) => { + const owner = ownerUserId(ctx); + const out = []; + for (const row of ctx.db.files.file.ownerUserId.filter(owner)) { + out.push({ + id: row.id, + path: row.path, + mimeType: row.mimeType, + size: row.size, + sha256Hex: row.sha256Hex, + visibility: row.visibility, + updatedAt: row.updatedAt, + }); + } + out.sort((a, b) => a.path.localeCompare(b.path)); + return out; + } +); + +export const create_folder = spacetimedb.reducer( + { path: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'folder'); + if (path === '/') return; + assertParentFolderExists(ctx, path, owner); + assertNoFolderCollision(ctx, path, owner); + assertNoOwnedFileCollision(ctx, path, owner); + ctx.db.folder.insert({ + id: 0n, + path, + ownerUserId: owner, + name: basename(path), + parentPath: parentPathFor(path), + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } +); + +export const delete_folder = spacetimedb.reducer( + { path: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'folder'); + if (path === '/') senderError('vault.cannot_delete_root'); + const row = requireOwnedFolder(ctx, path, owner); + if (folderHasChildren(ctx, path, owner)) + senderError(`vault.folder_not_empty:${path}`); + ctx.db.folder.delete(row); + } +); + +export const rename_folder = spacetimedb.reducer( + { path: t.string(), newName: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const oldPath = normalizePath(args.path, 'folder'); + if (oldPath === '/') senderError('vault.cannot_rename_root'); + const newName = args.newName.trim(); + if (newName.length === 0 || newName.includes('/')) + senderError('vault.invalid_path:segment'); + const parent = parentPathFor(oldPath); + const newPath = parent === '/' ? `/${newName}` : `${parent}/${newName}`; + // Require the name to survive path normalization unchanged. + if (normalizePath(newPath, 'folder') !== newPath) + senderError('vault.invalid_path:segment'); + if (newPath === oldPath) return; + + const row = requireOwnedFolder(ctx, oldPath, owner); + assertNoFolderCollision(ctx, newPath, owner); + assertNoOwnedFileCollision(ctx, newPath, owner); + + // Validate every re-pathed descendant before mutating anything. + const prefix = childPrefix(oldPath); + const rePath = (p: string) => newPath + p.slice(oldPath.length); + const childFolders = [...ctx.db.folder.ownerUserId.filter(owner)].filter( + f => f.path.startsWith(prefix) + ); + const childFiles = [...ctx.db.files.file.ownerUserId.filter(owner)].filter( + f => f.path.startsWith(prefix) + ); + for (const f of childFolders) { + const p = rePath(f.path); + if (p.length > PATH_MAX) senderError('vault.invalid_path:length'); + assertNoOwnedFileCollision(ctx, p, owner); + } + for (const f of childFiles) { + const p = rePath(f.path); + if (p.length > PATH_MAX) senderError('vault.invalid_path:length'); + assertNoOwnedFileCollision(ctx, p, owner); + } + + ctx.db.folder.id.update({ + ...row, + path: newPath, + name: newName, + updatedAt: ctx.timestamp, + }); + for (const f of childFolders) { + const p = rePath(f.path); + ctx.db.folder.id.update({ + ...f, + path: p, + parentPath: parentPathFor(p), + updatedAt: ctx.timestamp, + }); + } + for (const f of childFiles) { + const path = rePath(f.path); + ctx.db.files.file.id.update({ + ...f, + ownerPathKey: ownerPathKey(owner, path), + path, + updatedAt: ctx.timestamp, + }); + } + } +); + +export const upload_file = spacetimedb.reducer( + { + path: t.string(), + mimeType: t.string(), + bytes: t.array(t.u8()), + visibility: t.string(), + }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'file'); + if (!VALID_VISIBILITIES.has(args.visibility)) + senderError(`vault.invalid_visibility:${args.visibility}`); + if (args.bytes.length > FILE_BYTES_MAX) + senderError(`files.too_large:${args.bytes.length}/${FILE_BYTES_MAX}`); + assertParentFolderExists(ctx, path, owner); + assertNoFolderCollision(ctx, path, owner); + const key = ownerPathKey(owner, path); + const existing = ctx.db.files.file.ownerPathKey.find(key); + let mimeType: string; + try { + mimeType = validateMimeType(args.mimeType || 'application/octet-stream'); + } catch (error) { + senderError( + error instanceof Error ? error.message : 'files.invalid_mime_type' + ); + } + const sha256Hex = fileSha256Hex(args.bytes); + if (existing) { + ctx.db.files.file.id.update({ + ...existing, + mimeType, + size: BigInt(args.bytes.length), + sha256Hex, + visibility: args.visibility, + updatedAt: ctx.timestamp, + }); + ctx.db.files.fileBlob.fileId.update({ + fileId: existing.id, + bytes: args.bytes, + }); + return; + } + const row = ctx.db.files.file.insert({ + id: 0n, + ownerPathKey: key, + path, + ownerUserId: owner, + mimeType, + size: BigInt(args.bytes.length), + sha256Hex, + visibility: args.visibility, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.files.fileBlob.insert({ fileId: row.id, bytes: args.bytes }); + } +); + +export const delete_file = spacetimedb.reducer( + { path: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'file'); + const row = ctx.db.files.file.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) return; + const blob = ctx.db.files.fileBlob.fileId.find(row.id); + if (blob) ctx.db.files.fileBlob.delete(blob); + ctx.db.files.file.id.delete(row.id); + } +); + +export const rename_file = spacetimedb.reducer( + { oldPath: t.string(), newPath: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const oldPath = normalizePath(args.oldPath, 'file'); + const newPath = normalizePath(args.newPath, 'file'); + renameOwnedFile(ctx, owner, oldPath, newPath); + } +); + +export const move_file = spacetimedb.reducer( + { oldPath: t.string(), targetFolderPath: t.string() }, + (ctx, args) => { + const targetFolderPath = normalizePath(args.targetFolderPath, 'folder'); + const oldPath = normalizePath(args.oldPath, 'file'); + const filename = basename(oldPath); + const newPath = + targetFolderPath === '/' + ? `/${filename}` + : `${targetFolderPath}/${filename}`; + renameOwnedFile(ctx, ownerUserId(ctx), oldPath, newPath); + } +); + +export const set_file_visibility = spacetimedb.reducer( + { path: t.string(), visibility: t.string() }, + (ctx, args) => { + if (!VALID_VISIBILITIES.has(args.visibility)) + senderError(`files.invalid_visibility:${args.visibility}`); + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'file'); + const row = ctx.db.files.file.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) senderError(`files.not_found:${path}`); + ctx.db.files.file.id.update({ + ...row, + visibility: args.visibility, + updatedAt: ctx.timestamp, + }); + } +); + +// Private bytes travel over the authenticated connection. HTTP handlers +// never see the caller's identity. +export const read_file_bytes = spacetimedb.procedure( + readFileBytesParams, + readFileBytesReturn, + (ctx, args) => + readFileBytesImpl( + ctx, + { path: normalizePath(args.path, 'file') }, + ctx.sender.toHexString() + ) +); + +const fileServeImpl = makeFileServeImpl({ + getOwner: ctx => ctx.identity?.toHexString?.(), +}); + +export const file_serve = spacetimedb.httpHandler((ctx, req) => { + return fileServeImpl(ctx, req); +}); + +export const router = spacetimedb.httpRouter( + new Router().get('/files', file_serve).head('/files', file_serve) +); diff --git a/spacetime-files-ts/example/spacetimedb/tsconfig.json b/spacetime-files-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..f004a6cbc79 --- /dev/null +++ b/spacetime-files-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-files-ts/example/src/app.ts b/spacetime-files-ts/example/src/app.ts new file mode 100644 index 00000000000..9a1b0d74236 --- /dev/null +++ b/spacetime-files-ts/example/src/app.ts @@ -0,0 +1,1195 @@ +// SpacetimeDB connection and file-manager UI composition. +import { DbConnection, type ErrorContext } from './codegen/app'; +import type { FileSummary, Folder } from './codegen/app/types'; +import { + loadToken, + saveToken, + clearToken, + parentPath, + baseName, + joinPath, + childPrefix, + fileUrl, + fmtSize, + tsMs, + escapeHtml, + humanError, + type Visibility, + type ServerConfig, +} from './utils'; +import { + downloadArchive, + downloadFile as saveDownloadedFile, + getFileBlob as loadFileBlob, +} from './downloads'; +import { zipStamp } from './zip'; +import { FileViewer } from './viewer'; +import { DialogController } from './dialog'; +import { collectDropped, UploadController } from './uploads'; +import { ContextMenu } from './context-menu'; +import { bindListActions as bindListInteractions } from './list-actions'; +import { handleListKey } from './keyboard'; +import { + uploadDropped, + wireFolderDropTarget as wireDropTarget, +} from './drop-target'; +import { + createVaultRendering, + fileDetailsHtml, + folderDetailsHtml, + icon, + selectionDetailsHtml, + type SortKey, +} from './rendering'; +import { VaultSelection } from './selection'; + +let conn: DbConnection | null = null; +let authToken: string | undefined = loadToken(); + +async function loadConfig(): Promise { + const res = await fetch('/api/config'); + if (!res.ok) throw new Error(`/api/config returned ${res.status}`); + return (await res.json()) as ServerConfig; +} + +function connect(config: ServerConfig): Promise { + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(config.stdbUri) + .withDatabaseName(config.appDatabase) + .withToken(authToken) + .onConnect((c, _identity, token) => { + authToken = token; + saveToken(token); + resolve(c); + }) + .onDisconnect((_ctx, err) => { + conn = null; + toast( + 'err', + err?.message ? `Connection lost: ${err.message}` : 'Connection lost' + ); + }) + .onConnectError((_ctx, err) => { + reject(err); + }) + .build(); + }); +} + +// The bridge the UI talks to; also exposed as window.vault for console tinkering. +const vault = { + createFolder: (path: string) => conn!.reducers.createFolder({ path }), + deleteFolder: (path: string) => conn!.reducers.deleteFolder({ path }), + uploadFile: (args: { + path: string; + mimeType: string; + bytes: Uint8Array; + visibility: Visibility; + }) => conn!.reducers.uploadFile(args), + deleteFile: (path: string) => conn!.reducers.deleteFile({ path }), + renameFile: (oldPath: string, newPath: string) => + conn!.reducers.renameFile({ oldPath, newPath }), + renameFolder: (path: string, newName: string) => + conn!.reducers.renameFolder({ path, newName }), + moveFile: (oldPath: string, targetFolderPath: string) => + conn!.reducers.moveFile({ oldPath, targetFolderPath }), + setFileVisibility: (path: string, visibility: Visibility) => + conn!.reducers.setFileVisibility({ path, visibility }), + /** Reads file bytes over the authenticated connection (works for private files). */ + readFileBytes: async ( + path: string + ): Promise<{ bytes: Uint8Array; mimeType: string }> => { + const result = await conn!.procedures.readFileBytes({ path }); + // Bytes may arrive as a plain number[] over the wire; normalize. + return { bytes: new Uint8Array(result.bytes), mimeType: result.mimeType }; + }, + getToken: () => authToken, +}; + +declare global { + interface Window { + vault?: typeof vault; + } +} + +function requireVault(): typeof vault | null { + if (!conn) { + toast('err', 'Vault is not connected yet.'); + return null; + } + return vault; +} + +// UI state + +const $ = (id: string): T => + document.getElementById(id) as T; + +// Persisted view preferences +const PREFS_KEY = 'vault:prefs'; +interface Prefs { + viewMode?: 'list' | 'grid'; + tileSize?: number | 's' | 'm' | 'l'; + sortKey?: SortKey; + sortDir?: 1 | -1; + detailsOpen?: boolean; +} +function loadPrefs(): Prefs { + try { + return ( + (JSON.parse(localStorage.getItem(PREFS_KEY) ?? 'null') as Prefs) ?? {} + ); + } catch { + return {}; + } +} +function savePrefs(): void { + try { + localStorage.setItem( + PREFS_KEY, + JSON.stringify({ viewMode, tileSize, sortKey, sortDir, detailsOpen }) + ); + } catch { + /* ignore */ + } +} +const prefs = loadPrefs(); + +let folders: Folder[] = []; +let files: FileSummary[] = []; +let currentPath = '/'; +let uploading = false; +let dragDepth = 0; +let sortKey: SortKey = prefs.sortKey ?? 'name'; +let sortDir: 1 | -1 = prefs.sortDir ?? 1; +let viewMode: 'list' | 'grid' = prefs.viewMode ?? 'list'; +// Normalize stored tile-size aliases to pixels. +let tileSize: number = + typeof prefs.tileSize === 'number' + ? prefs.tileSize + : ({ s: 110, m: 150, l: 205 }[prefs.tileSize ?? 'm'] ?? 150); +let detailsOpen: boolean = prefs.detailsOpen ?? false; +let searchQuery = ''; +const selection = new VaultSelection(); +const selected = selection.selected; + +const { + allFolderPaths, + fileRowHtml, + fileTileHtml, + folderRowHtml, + folderTileHtml, + immediateFolders, + subtreeStats, + visibleEntries, +} = createVaultRendering(() => ({ + folders, + files, + currentPath, + searchQuery, + sortKey, + sortDir, + selected, + focusPath: selection.focusPath, +})); + +// Returns the candidate path when available, otherwise adds a numeric suffix. +function freeName(candidate: string): string { + const taken = (p: string) => + files.some(f => f.path === p) || folders.some(f => f.path === p); + if (!taken(candidate)) return candidate; + const dir = parentPath(candidate); + const base = baseName(candidate); + const dot = base.lastIndexOf('.'); + const stem = dot > 0 ? base.slice(0, dot) : base; + const ext = dot > 0 ? base.slice(dot) : ''; + for (let k = 1; k < 1000; k++) { + const next = joinPath(dir, `${stem} (${k})${ext}`); + if (!taken(next)) return next; + } + return candidate; +} + +function toast(kind: 'ok' | 'err', message: string): void { + const el = $('toast'); + const node = document.createElement('div'); + node.className = `toast ${kind}`; + node.textContent = message; + el.appendChild(node); + setTimeout(() => node.remove(), 3600); +} + +const dialogs = new DialogController(error => toast('err', humanError(error))); +const openDialog = dialogs.open; +const closeDialog = dialogs.close; +const commitDialog = dialogs.commit; +const confirmDialog = dialogs.confirm.bind(dialogs); + +const uploads = new UploadController({ + ready: () => requireVault() != null, + files: () => files, + createFolder: path => vault.createFolder(path), + uploadFile: args => vault.uploadFile(args), + freeName, + openDialog, + setProgress: (next, label) => { + uploading = next; + $('upload').disabled = next; + $('upload-label').textContent = next && label ? label : 'Upload'; + }, + toast, +}); + +const contextMenu = new ContextMenu({ + file: path => files.find(file => file.path === path), + preview: path => void openViewer(path), + viewDetails: path => { + setFocus(path); + if (!detailsOpen) { + detailsOpen = true; + savePrefs(); + } + render(); + }, + downloadFile: file => void downloadFile(file), + copyLink, + duplicateFile: path => void duplicateFile(path), + renameFile: openRename, + moveFile: path => openMove([path]), + toggleVisibility: path => void toggleVisibility(path), + deleteFile: confirmDeleteFile, + openFolder: path => { + currentPath = path; + selection.clearFocus(); + clearSearch(); + render(); + }, + downloadFolder: path => void downloadFolderZip(path), + renameFolder: openRenameFolder, + deleteFolder: confirmDeleteFolder, + newFolder: openNewFolder, + chooseFiles: () => $('file-input').click(), + chooseFolder: () => $('folder-input').click(), +}); +const openCtxMenu = contextMenu.open; +const closeCtxMenu = contextMenu.close; + +const downloadServices = { + readFileBytes: vault.readFileBytes, + toast, +}; + +function getFileBlob(row: FileSummary): Promise { + return loadFileBlob(row, downloadServices); +} + +// Thumbnails (grid view + details panel) + +const thumbCache = new Map(); +let thumbGeneration = 0; +// Object-URL cache keyed by path@mtime; older revisions revoked on refresh. +async function getThumbUrl(row: FileSummary): Promise { + const key = `${row.path}@${tsMs(row.updatedAt)}`; + const cached = thumbCache.get(key); + if (cached) return cached; + const url = URL.createObjectURL(await getFileBlob(row)); + for (const [k, v] of thumbCache) { + if (k.startsWith(row.path + '@') && k !== key) { + URL.revokeObjectURL(v); + thumbCache.delete(k); + } + } + thumbCache.set(key, url); + return url; +} +async function loadThumbs(): Promise { + const gen = ++thumbGeneration; + const slots = [...document.querySelectorAll('[data-thumb]')]; + for (const slot of slots) { + if (gen !== thumbGeneration) return; // a newer render superseded us + const row = files.find(f => f.path === slot.dataset.thumb); + if (!row) continue; + let url: string; + try { + url = await getThumbUrl(row); + } catch { + continue; + } + if (gen !== thumbGeneration) return; + const img = document.createElement('img'); + img.src = url; + img.alt = ''; + slot.replaceChildren(img); + } +} + +// Rendering + +function renderCrumbs(): void { + if (searchQuery) { + $('crumbs').innerHTML = + `${icon('search')} Search results`; + return; + } + const parts = + currentPath === '/' ? [] : currentPath.split('/').filter(Boolean); + let acc = ''; + const html = [``]; + for (const part of parts) { + acc += '/' + part; + html.push( + `/` + ); + } + $('crumbs').innerHTML = html.join(''); + $('crumbs') + .querySelectorAll('[data-cd]') + .forEach(btn => { + btn.addEventListener('click', () => { + currentPath = btn.dataset.cd!; + clearSearch(); + render(); + }); + }); +} +function renderTree(): void { + const rows: Array<{ path: string; name: string; depth: number }> = [ + { path: '/', name: 'Root', depth: 0 }, + ]; + (function walk(path: string, depth: number) { + for (const f of immediateFolders(path).sort((a, b) => + a.name.localeCompare(b.name) + )) { + rows.push({ path: f.path, name: f.name, depth }); + walk(f.path, depth + 1); + } + })('/', 1); + $('tree').innerHTML = rows + .map( + row => ` +
    • + +
    • ` + ) + .join(''); + $('tree') + .querySelectorAll('[data-path]') + .forEach(btn => { + btn.addEventListener('click', () => { + currentPath = btn.dataset.path!; + clearSearch(); + render(); + }); + btn.addEventListener('contextmenu', e => + openCtxMenu(e, { type: 'folder', path: btn.dataset.path! }) + ); + wireFolderDropTarget(btn, btn.dataset.path!); + }); +} +function renderHead(): void { + $('list-head').classList.toggle('grid-mode', viewMode === 'grid'); + $('list-head') + .querySelectorAll('[data-sort]') + .forEach(btn => { + const active = btn.dataset.sort === sortKey; + btn.classList.toggle('sorted', active); + const label = + btn.dataset.label ?? (btn.dataset.label = btn.textContent!.trim()); + btn.textContent = active ? `${label} ${sortDir > 0 ? '^' : 'v'}` : label; + }); + const { fs } = visibleEntries(); + const all = fs.length > 0 && fs.every(f => selected.has(f.path)); + $('select-all').checked = all; + // View controls + $('view-toggle').innerHTML = icon(viewMode === 'grid' ? 'list' : 'grid'); + $('view-toggle').title = viewMode === 'grid' ? 'List view' : 'Grid view'; + $('zoom-ctl').hidden = viewMode !== 'grid'; + $('tile-slider').value = String(tileSize); +} +function renderList(): void { + const { dirs, fs } = visibleEntries(); + selection.setEntries([ + ...dirs.map(f => ({ type: 'folder' as const, path: f.path })), + ...fs.map(f => ({ type: 'file' as const, path: f.path })), + ]); + const isEmpty = selection.entries.length === 0; + $('empty').hidden = !isEmpty; + if (isEmpty) { + $('empty').innerHTML = searchQuery + ? `
      ${icon('search')}No matches
      Nothing named "${escapeHtml(searchQuery)}".
      ` + : `
      ${icon('upload')}This folder is empty
      Drag files anywhere on the page, or hit Upload.
      `; + } + const list = $('list'); + list.classList.toggle('grid', viewMode === 'grid'); + list.classList.toggle('has-selection', selected.size > 0); + list.style.setProperty('--tile', `${tileSize}px`); + list.innerHTML = + viewMode === 'grid' + ? [...dirs.map(folderTileHtml), ...fs.map(fileTileHtml)].join('') + : [...dirs.map(folderRowHtml), ...fs.map(fileRowHtml)].join(''); + bindListActions(); + if (viewMode === 'grid') void loadThumbs(); +} +function renderBulkbar(): void { + const n = selected.size; + $('bulkbar').hidden = n === 0; + $('toolbar-main').style.display = n === 0 ? '' : 'none'; + if (n) $('bulk-count').textContent = `${n} selected`; +} +function renderStorage(): void { + const total = files.reduce((sum, f) => sum + Number(f.size), 0); + $('storage').textContent = files.length + ? `${files.length} file${files.length === 1 ? '' : 's'} | ${fmtSize(total)} stored` + : 'No files stored yet'; +} + +// Details panel +function wireDetailsNav(scope: HTMLElement): void { + scope.querySelectorAll('[data-goto]').forEach(btn => + btn.addEventListener('click', () => { + currentPath = btn.dataset.goto!; + clearSearch(); + selected.clear(); + render(); + }) + ); +} +async function loadDetailsThumb(row: FileSummary): Promise { + const slot = document.querySelector( + `[data-dthumb="${CSS.escape(row.path)}"]` + ); + if (!slot) return; + let url: string; + try { + url = await getThumbUrl(row); + } catch { + return; + } + if (!document.body.contains(slot)) return; + const img = document.createElement('img'); + img.src = url; + img.alt = ''; + slot.replaceChildren(img); +} +function renderFileDetails(body: HTMLElement, row: FileSummary): void { + const isImage = (row.mimeType || '').startsWith('image/'); + body.innerHTML = fileDetailsHtml(row); + wireDetailsNav(body); + if (isImage) void loadDetailsThumb(row); +} +function renderFolderDetails(body: HTMLElement, folderPath: string): void { + const isRoot = folderPath === '/'; + const row = isRoot ? null : folders.find(f => f.path === folderPath); + body.innerHTML = folderDetailsHtml( + folderPath, + row ?? undefined, + subtreeStats(folderPath) + ); + wireDetailsNav(body); +} +function renderDetails(): void { + document + .querySelector('.main')! + .classList.toggle('details-open', detailsOpen); + $('details-toggle').classList.toggle('active', detailsOpen); + if (!detailsOpen) return; + const body = $('details-body'); + if (selected.size > 1) { + const rows = files.filter(f => selected.has(f.path)); + body.innerHTML = selectionDetailsHtml(rows); + return; + } + if (selected.size === 1) { + const row = files.find(f => f.path === [...selected][0]); + if (row) return renderFileDetails(body, row); + } + if (selection.focusPath) { + const row = files.find(f => f.path === selection.focusPath); + if (row) return renderFileDetails(body, row); + if (folders.some(f => f.path === selection.focusPath)) + return renderFolderDetails(body, selection.focusPath); + } + // Nothing focused or selected: summarize the current folder (or root). + renderFolderDetails(body, currentPath); +} +function render(): void { + if (currentPath !== '/' && !folders.some(f => f.path === currentPath)) + currentPath = '/'; + renderCrumbs(); + renderTree(); + renderHead(); + renderList(); + renderBulkbar(); + renderStorage(); + renderDetails(); +} + +// Selection & focus + +function setFocus(path: string): void { + // No re-render if already focused: dblclick's second click must hit the same node. + if (selection.focus(path)) render(); +} +function toggleSelect(path: string): void { + selection.toggle(path); + render(); +} +function rangeSelect(path: string): void { + selection.selectRange(path); + render(); +} + +function bindListActions(): void { + bindListInteractions($('list'), { + selected, + files: () => files, + setAnchor: path => selection.setAnchor(path), + toggleSelect, + rangeSelect, + focus: setFocus, + openFile: path => void openViewer(path), + openFolder: path => { + currentPath = path; + selection.clearFocus(); + clearSearch(); + render(); + }, + openContext: openCtxMenu, + render, + toggleVisibility: path => void toggleVisibility(path), + copyLink, + downloadFile: file => void downloadFile(file), + downloadFolder: path => void downloadFolderZip(path), + renameFile: openRename, + renameFolder: openRenameFolder, + moveFile: path => openMove([path]), + deleteFile: confirmDeleteFile, + deleteFolder: confirmDeleteFolder, + wireFolderDropTarget, + }); +} + +function toggleVisibility(path: string): Promise | void { + const row = files.find(f => f.path === path); + const v = requireVault(); + if (!row || !v) return; + return runAction('Visibility updated', () => + v.setFileVisibility(path, row.visibility === 'public' ? 'owner' : 'public') + ); +} +function confirmDeleteFile(path: string): void { + confirmDialog( + 'Delete file', + `Delete "${baseName(path)}"? This can't be undone.`, + async () => { + await runAction('File deleted', () => vault.deleteFile(path)); + if (viewer.path === path) closeViewer(); + } + ); +} +function confirmDeleteFolder(path: string): void { + confirmDialog( + 'Delete folder', + `Delete folder "${baseName(path)}"? It must be empty.`, + () => runAction('Folder deleted', () => vault.deleteFolder(path)) + ); +} + +// Internal drag-to-move + OS-file drop onto folders + +function wireFolderDropTarget(el: HTMLElement, folderPath: string): void { + wireDropTarget(el, folderPath, { + currentPath: () => currentPath, + endFileDrag, + upload: (dataTransfer, path) => + uploadDropped(dataTransfer, path, (entries, target) => + uploads.upload(entries, target) + ), + move: moveFiles, + }); +} +async function moveFiles(paths: string[], targetFolder: string): Promise { + const v = requireVault(); + if (!paths.length || !v) return; + const toMove = paths.filter(p => parentPath(p) !== targetFolder); + await bulkOp(toMove, p => v.moveFile(p, targetFolder), 'moved'); + selected.clear(); + // No-op moves produce no data event, so sync the bulk bar here. + render(); +} + +function openNewFolder(): void { + openDialog( + 'New folder', + ``, + async () => { + const name = $('folder-name').value.trim(); + if (!name) throw new Error('vault.invalid_path:name'); + await vault.createFolder(joinPath(currentPath, name)); + toast('ok', 'Folder created'); + } + ); +} +function openRename(path: string): void { + openDialog( + 'Rename file', + ``, + async () => { + const name = $('rename-name').value.trim(); + if (!name) throw new Error('vault.invalid_file_path'); + await vault.renameFile(path, joinPath(parentPath(path), name)); + toast('ok', 'File renamed'); + } + ); +} +function openRenameFolder(path: string): void { + openDialog( + 'Rename folder', + ``, + async () => { + const name = $('rename-name').value.trim(); + if (!name) throw new Error('vault.invalid_path:name'); + await vault.renameFolder(path, name); + // Follow a rename within the active subtree. + const newPath = joinPath(parentPath(path), name); + if (currentPath === path) currentPath = newPath; + else if (currentPath.startsWith(childPrefix(path))) + currentPath = newPath + currentPath.slice(path.length); + toast('ok', 'Folder renamed'); + } + ); +} +function openMove(paths: string[]): void { + const from = paths.length === 1 ? parentPath(paths[0]!) : null; + openDialog( + paths.length === 1 ? 'Move file' : `Move ${paths.length} files`, + ` + `, + () => moveFiles(paths, $('move-target').value) + ); +} + +// Copy link (visibility-aware) + +async function writeClipboard(text: string): Promise { + await navigator.clipboard.writeText(text); +} +function copyLink(path: string): void { + const row = files.find(f => f.path === path); + if (!row) return; + const url = location.origin + fileUrl(row.id); + if (row && row.visibility === 'public') { + void writeClipboard(url) + .then(() => toast('ok', 'Public link copied')) + .catch(() => toast('err', 'Copy failed. Select the URL manually.')); + return; + } + // A private link is dead even for the owner (HTTP has no caller identity). + openDialog( + 'Copy link', + `

      This file is private. Public links require public visibility. Make it public and copy the link?

      `, + async () => { + await vault.setFileVisibility(path, 'public'); + try { + await writeClipboard(url); + toast('ok', 'File made public, link copied'); + } catch { + toast('err', 'File made public, but the link could not be copied.'); + } + }, + { okLabel: 'Make public & copy' } + ); +} + +// Actions + +async function runAction( + okMessage: string, + fn: () => Promise +): Promise { + if (!requireVault()) return; + try { + await fn(); + toast('ok', okMessage); + } catch (err) { + toast('err', humanError(err)); + } +} +async function duplicateFile(path: string): Promise { + const row = files.find(f => f.path === path); + const v = requireVault(); + if (!row || !v) return; + try { + const { bytes, mimeType } = await v.readFileBytes(path); + const base = baseName(path); + const dot = base.lastIndexOf('.'); + const copyName = + dot > 0 + ? `${base.slice(0, dot)} (copy)${base.slice(dot)}` + : `${base} (copy)`; + const target = freeName(joinPath(parentPath(path), copyName)); + await v.uploadFile({ + path: target, + mimeType, + bytes, + visibility: row.visibility as Visibility, + }); + toast('ok', `Copied to ${baseName(target)}`); + } catch (err) { + toast('err', humanError(err)); + } +} + +// Upload (files, folders, conflicts) + +async function downloadFile(row: FileSummary): Promise { + return saveDownloadedFile(row, downloadServices); +} + +async function zipAndSave( + fileRows: FileSummary[], + dirNames: Array<{ name: string; mtimeMs: number }>, + entryName: (f: FileSummary) => string, + zipName: string +): Promise { + return downloadArchive( + { fileRows, dirNames, entryName, zipName }, + downloadServices + ); +} +function downloadFolderZip(folderPath: string): Promise { + const prefix = childPrefix(folderPath); + // Name the root archive explicitly to avoid paths that start with "//". + const root = folderPath === '/' ? 'vault' : baseName(folderPath); + const inFiles = files.filter(f => f.path.startsWith(prefix)); + // Directory entries preserve empty folders inside the zip. + const inDirs = folders + .filter(f => f.path !== folderPath && f.path.startsWith(prefix)) + .map(f => ({ + name: `${root}/${f.path.slice(prefix.length)}/`, + mtimeMs: tsMs(f.updatedAt), + })); + return zipAndSave( + inFiles, + inDirs, + f => `${root}/${f.path.slice(prefix.length)}`, + `${root}-${zipStamp()}.zip` + ); +} +function downloadSelectionZip(): Promise { + const rows = files.filter(f => selected.has(f.path)); + // Keep full vault paths so structure survives a mixed selection. + return zipAndSave( + rows, + [], + f => f.path.slice(1), + `vault-download-${zipStamp()}.zip` + ); +} + +const viewer = new FileViewer({ + loadBlob: getFileBlob, + download: downloadFile, + iconHtml: icon, +}); + +function viewerOpen(): boolean { + return viewer.isOpen(); +} +function openViewer(path: string): Promise { + return viewer.open(path, visibleEntries().fs); +} +function vStep(delta: number): void { + viewer.step(delta); +} +function closeViewer(): void { + viewer.close(); +} + +// Search + +function clearSearch(): void { + if (!searchQuery) return; + searchQuery = ''; + $('search').value = ''; +} + +// Bulk actions + +// Continue-on-error loop: one summary toast, one toast per failure. +async function bulkOp( + paths: string[], + op: (path: string) => Promise, + okVerb: string +): Promise { + let done = 0; + const failures: string[] = []; + for (const p of paths) { + try { + await op(p); + done++; + } catch (err) { + failures.push(`${baseName(p)}: ${humanError(err)}`); + } + } + if (done) toast('ok', `${done} file${done === 1 ? '' : 's'} ${okVerb}`); + for (const msg of failures) toast('err', msg); + return done; +} +function bulkDelete(): void { + const paths = [...selected]; + if (!paths.length) return; + confirmDialog( + `Delete ${paths.length} file${paths.length === 1 ? '' : 's'}`, + `Delete ${paths.length} file${paths.length === 1 ? '' : 's'}? This can't be undone.`, + async () => { + await bulkOp(paths, p => vault.deleteFile(p), 'deleted'); + selected.clear(); + if (viewer.path && paths.includes(viewer.path)) closeViewer(); + } + ); +} +async function bulkVisibility(visibility: Visibility): Promise { + // Visibility changes preserve the current selection because files stay in place. + await bulkOp( + [...selected], + p => vault.setFileVisibility(p, visibility), + `made ${visibility === 'public' ? 'public' : 'private'}` + ); +} + +function handleListKeys(e: KeyboardEvent): void { + handleListKey(e, { + entries: () => selection.entries, + focusPath: () => selection.focusPath, + selected, + visibleFilePaths: () => visibleEntries().fs.map(file => file.path), + setFocus, + clearFocus: () => { + selection.clearFocus(); + }, + openFolder: path => { + currentPath = path; + selection.clearFocus(); + clearSearch(); + render(); + }, + openFile: path => { + selection.setAnchor(path); + void openViewer(path); + }, + toggleSelect, + deleteSelection: bulkDelete, + deleteFile: confirmDeleteFile, + deleteFolder: confirmDeleteFolder, + hasSearch: () => Boolean(searchQuery), + clearSearch, + render, + }); +} + +// One-time wiring + +function endFileDrag(): void { + dragDepth = 0; + document.body.classList.remove('dragging-files'); +} + +function wireUi(): void { + $('new-folder').addEventListener('click', openNewFolder); + $('upload').addEventListener('click', () => { + if (!uploading) $('file-input').click(); + }); + $('file-input').addEventListener('change', e => { + void (async () => { + const input = e.target as HTMLInputElement; + const picked = [...(input.files ?? [])]; + input.value = ''; + if (picked.length) + await uploads.upload( + { files: picked.map(f => ({ file: f, rel: f.name })), dirs: [] }, + currentPath + ); + })(); + }); + $('folder-input').addEventListener('change', e => { + void (async () => { + const input = e.target as HTMLInputElement; + const picked = [...(input.files ?? [])]; + input.value = ''; + if (picked.length) { + await uploads.upload( + { + files: picked.map(f => ({ + file: f, + rel: f.webkitRelativePath || f.name, + })), + dirs: [], + }, + currentPath + ); + } + })(); + }); + + // Search + $('search').addEventListener('input', () => { + searchQuery = $('search').value.trim(); + render(); + }); + $('search').addEventListener('keydown', e => { + if ((e as KeyboardEvent).key === 'Escape') { + clearSearch(); + render(); + } + }); + + // Sorting + view controls + $('list-head') + .querySelectorAll('[data-sort]') + .forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sort as SortKey; + if (sortKey === key) sortDir = sortDir === 1 ? -1 : 1; + else { + sortKey = key; + sortDir = 1; + } + savePrefs(); + render(); + }); + }); + $('view-toggle').addEventListener('click', () => { + viewMode = viewMode === 'grid' ? 'list' : 'grid'; + savePrefs(); + render(); + }); + $('details-toggle').addEventListener('click', () => { + detailsOpen = !detailsOpen; + savePrefs(); + render(); + }); + $('details-close').addEventListener('click', () => { + detailsOpen = false; + savePrefs(); + render(); + }); + // Live-resize tiles while dragging by updating the CSS variable. + $('tile-slider').addEventListener('input', () => { + tileSize = Number($('tile-slider').value); + $('list').style.setProperty('--tile', `${tileSize}px`); + savePrefs(); + }); + $('select-all').addEventListener('change', () => { + const { fs } = visibleEntries(); + if ($('select-all').checked) + fs.forEach(f => selected.add(f.path)); + else fs.forEach(f => selected.delete(f.path)); + render(); + }); + + // Bulk actions + $('bulk-clear').addEventListener('click', () => { + selected.clear(); + render(); + }); + $('bulk-move').addEventListener('click', () => openMove([...selected])); + $('bulk-download').addEventListener( + 'click', + () => void downloadSelectionZip() + ); + $('bulk-delete').addEventListener('click', bulkDelete); + $('bulk-public').addEventListener( + 'click', + () => void bulkVisibility('public') + ); + $('bulk-private').addEventListener( + 'click', + () => void bulkVisibility('owner') + ); + + // One-time list-background handlers (the
        persists across renders). + $('list').addEventListener('contextmenu', e => { + if ((e.target as HTMLElement).closest('[data-file], [data-folder]')) return; + openCtxMenu(e, { type: 'background' }); + }); + $('list').addEventListener('click', e => { + if (e.target === $('list') && selection.focusPath) { + selection.clearFocus(); + render(); + } + }); + + // Context menu dismissal + document.addEventListener('click', e => { + if (!(e.target as HTMLElement).closest('#ctx')) closeCtxMenu(); + }); + document.addEventListener('scroll', closeCtxMenu, true); + + // Viewer controls + $('lb-prev').addEventListener('click', () => vStep(-1)); + $('lb-next').addEventListener('click', () => vStep(1)); + $('lb-in').addEventListener('click', () => viewer.zoom(1.25)); + $('lb-out').addEventListener('click', () => viewer.zoom(0.8)); + $('lb-fit').addEventListener('click', () => viewer.fit()); + $('lb-full').addEventListener('click', () => viewer.fullSize()); + $('lb-download').addEventListener('click', () => { + const row = viewer.currentFile(); + if (row) void downloadFile(row); + }); + $('lb-close').addEventListener('click', closeViewer); + $('lb-stage').addEventListener('click', e => { + if (e.target === $('lb-stage')) closeViewer(); + }); + $('lightbox').addEventListener( + 'wheel', + e => { + if (!$('lb-stage').querySelector('img')) return; + e.preventDefault(); + viewer.zoom(e.deltaY < 0 ? 1.1 : 0.9); + }, + { passive: false } + ); + + // Dialog controls + keyboard + $('dialog-ok').addEventListener('click', () => void commitDialog()); + $('dialog-cancel').addEventListener('click', closeDialog); + $('dialog').addEventListener('click', e => { + if (e.target === $('dialog')) closeDialog(); + }); + document.addEventListener('keydown', e => { + if (viewerOpen()) { + if (e.key === 'Escape') return closeViewer(); + if (e.key === 'ArrowLeft') return vStep(-1); + if (e.key === 'ArrowRight') return vStep(1); + return; + } + if ($('ctx').classList.contains('open') && e.key === 'Escape') + return closeCtxMenu(); + if ($('dialog').classList.contains('open')) { + if (e.key === 'Escape') closeDialog(); + else if ( + e.key === 'Enter' && + (e.target as HTMLElement).tagName !== 'TEXTAREA' + ) { + e.preventDefault(); + void commitDialog(); + } + return; + } + handleListKeys(e); + }); + + // OS-file drag only ('Files' type); internal row drags carry a custom type. + const hasFiles = (e: DragEvent) => + [...(e.dataTransfer?.types ?? [])].includes('Files'); + window.addEventListener('dragenter', e => { + if (!hasFiles(e)) return; + e.preventDefault(); + dragDepth++; + $('drop-path').textContent = currentPath; + document.body.classList.add('dragging-files'); + }); + window.addEventListener('dragover', e => { + if (!hasFiles(e)) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = 'copy'; + }); + window.addEventListener('dragleave', e => { + if (!hasFiles(e)) return; + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) document.body.classList.remove('dragging-files'); + }); + window.addEventListener('drop', e => { + void (async () => { + if (!hasFiles(e)) return; + e.preventDefault(); + endFileDrag(); + const dropped = await collectDropped(e.dataTransfer!); + await uploads.upload(dropped, currentPath); + })(); + }); +} + +// Data flow: subscriptions -> state -> render + +function refreshData(): void { + if (!conn) return; + folders = [...conn.db.myFolders.iter()]; + files = [...conn.db.myFileSummaries.iter()]; + const filePaths = new Set(files.map(file => file.path)); + selection.prune( + filePaths, + new Set([...filePaths, ...folders.map(folder => folder.path)]) + ); + render(); + // Close the viewer if the file it's showing was deleted out from under it. + if (viewer.path && viewerOpen() && !files.some(f => f.path === viewer.path)) + closeViewer(); +} + +// Row callbacks fire synchronously per transaction; coalesce the burst into one render. +let refreshScheduled = false; +function scheduleRefresh(): void { + if (refreshScheduled) return; + refreshScheduled = true; + queueMicrotask(() => { + refreshScheduled = false; + refreshData(); + }); +} + +async function main(): Promise { + wireUi(); + render(); + let config: ServerConfig; + try { + config = await loadConfig(); + try { + conn = await connect(config); + } catch (err) { + // Stale stored token (server wiped/rekeyed): drop it, retry anonymously. + if (!authToken) throw err; + authToken = undefined; + clearToken(); + conn = await connect(config); + } + } catch (err) { + toast('err', `Couldn't connect: ${humanError(err)}`); + return; + } + + conn + .subscriptionBuilder() + .onApplied(() => refreshData()) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe(['SELECT * FROM my_folders', 'SELECT * FROM my_file_summaries']); + + conn.db.myFolders.onInsert(scheduleRefresh); + conn.db.myFolders.onUpdate(scheduleRefresh); + conn.db.myFolders.onDelete(scheduleRefresh); + conn.db.myFileSummaries.onInsert(scheduleRefresh); + conn.db.myFileSummaries.onUpdate(scheduleRefresh); + conn.db.myFileSummaries.onDelete(scheduleRefresh); + + window.vault = vault; +} + +main().catch(err => { + console.error(err); + toast('err', humanError(err)); +}); diff --git a/spacetime-files-ts/example/src/codegen/app/create_folder_reducer.ts b/spacetime-files-ts/example/src/codegen/app/create_folder_reducer.ts new file mode 100644 index 00000000000..8a2a9b08001 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/create_folder_reducer.ts @@ -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 { + path: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/codegen/app/delete_file_reducer.ts b/spacetime-files-ts/example/src/codegen/app/delete_file_reducer.ts new file mode 100644 index 00000000000..8a2a9b08001 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/delete_file_reducer.ts @@ -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 { + path: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/codegen/app/delete_folder_reducer.ts b/spacetime-files-ts/example/src/codegen/app/delete_folder_reducer.ts new file mode 100644 index 00000000000..8a2a9b08001 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/delete_folder_reducer.ts @@ -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 { + path: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/codegen/app/files/types.ts b/spacetime-files-ts/example/src/codegen/app/files/types.ts new file mode 100644 index 00000000000..a8336b9566f --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/files/types.ts @@ -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"; + +export const File = __t.object("File", { + id: __t.u64(), + ownerPathKey: __t.string(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const FileBlob = __t.object("FileBlob", { + fileId: __t.u64(), + bytes: __t.byteArray(), +}); +export type FileBlob = __Infer; + diff --git a/spacetime-files-ts/example/src/codegen/app/index.ts b/spacetime-files-ts/example/src/codegen/app/index.ts new file mode 100644 index 00000000000..9f863bb3fde --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/index.ts @@ -0,0 +1,142 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 CreateFolderReducer from "./create_folder_reducer"; +import DeleteFileReducer from "./delete_file_reducer"; +import DeleteFolderReducer from "./delete_folder_reducer"; +import MoveFileReducer from "./move_file_reducer"; +import RenameFileReducer from "./rename_file_reducer"; +import RenameFolderReducer from "./rename_folder_reducer"; +import SetFileVisibilityReducer from "./set_file_visibility_reducer"; +import UploadFileReducer from "./upload_file_reducer"; + +// Import all procedure arg schemas +import * as ReadFileBytesProcedure from "./read_file_bytes_procedure"; + +// Import all table schema definitions +import MyFileSummariesRow from "./my_file_summaries_table"; +import MyFoldersRow from "./my_folders_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({ + myFileSummaries: __table({ + name: 'my_file_summaries', + indexes: [ + ], + constraints: [ + ], + }, MyFileSummariesRow), + myFolders: __table({ + name: 'my_folders', + indexes: [ + ], + constraints: [ + ], + }, MyFoldersRow), +}); + +/** 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( + __reducerSchema("create_folder", CreateFolderReducer), + __reducerSchema("delete_file", DeleteFileReducer), + __reducerSchema("delete_folder", DeleteFolderReducer), + __reducerSchema("move_file", MoveFileReducer), + __reducerSchema("rename_file", RenameFileReducer), + __reducerSchema("rename_folder", RenameFolderReducer), + __reducerSchema("set_file_visibility", SetFileVisibilityReducer), + __reducerSchema("upload_file", UploadFileReducer), +); + +/** 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("read_file_bytes", ReadFileBytesProcedure.params, ReadFileBytesProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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 = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The procedures available in this remote SpacetimeDB module. */ +export const procedures = __convertToAccessorMap(proceduresSchema.procedures); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-files-ts/example/src/codegen/app/move_file_reducer.ts b/spacetime-files-ts/example/src/codegen/app/move_file_reducer.ts new file mode 100644 index 00000000000..39be1981f74 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/move_file_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + oldPath: __t.string(), + targetFolderPath: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/codegen/app/my_file_summaries_table.ts b/spacetime-files-ts/example/src/codegen/app/my_file_summaries_table.ts new file mode 100644 index 00000000000..52eae105682 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/my_file_summaries_table.ts @@ -0,0 +1,21 @@ +// 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(), + path: __t.string(), + mimeType: __t.string().name("mime_type"), + size: __t.u64(), + sha256Hex: __t.string().name("sha_256_hex"), + visibility: __t.string(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-files-ts/example/src/codegen/app/my_folders_table.ts b/spacetime-files-ts/example/src/codegen/app/my_folders_table.ts new file mode 100644 index 00000000000..dc7c48b3c85 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/my_folders_table.ts @@ -0,0 +1,21 @@ +// 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().primaryKey(), + ownerUserId: __t.string().name("owner_user_id"), + path: __t.string(), + name: __t.string(), + parentPath: __t.string().name("parent_path"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-files-ts/example/src/codegen/app/read_file_bytes_procedure.ts b/spacetime-files-ts/example/src/codegen/app/read_file_bytes_procedure.ts new file mode 100644 index 00000000000..902cf595193 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/read_file_bytes_procedure.ts @@ -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"; + +import { + FileBytes, +} from "./types"; + +export const params = { + path: __t.string(), +}; +export const returnType = FileBytes \ No newline at end of file diff --git a/spacetime-files-ts/example/src/codegen/app/rename_file_reducer.ts b/spacetime-files-ts/example/src/codegen/app/rename_file_reducer.ts new file mode 100644 index 00000000000..50145dc0f1d --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/rename_file_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + oldPath: __t.string(), + newPath: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/codegen/app/rename_folder_reducer.ts b/spacetime-files-ts/example/src/codegen/app/rename_folder_reducer.ts new file mode 100644 index 00000000000..83dd1a2c22b --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/rename_folder_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + path: __t.string(), + newName: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/codegen/app/set_file_visibility_reducer.ts b/spacetime-files-ts/example/src/codegen/app/set_file_visibility_reducer.ts new file mode 100644 index 00000000000..f6ffa1c4f04 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/set_file_visibility_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + path: __t.string(), + visibility: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/codegen/app/types.ts b/spacetime-files-ts/example/src/codegen/app/types.ts new file mode 100644 index 00000000000..0d89dc6fd01 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/types.ts @@ -0,0 +1,46 @@ +// 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 const FileBytes = __t.object("FileBytes", { + bytes: __t.byteArray(), + mimeType: __t.string(), +}); +export type FileBytes = __Infer; + +export const FileSummary = __t.object("FileSummary", { + id: __t.u64(), + path: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + updatedAt: __t.timestamp(), +}); +export type FileSummary = __Infer; + +export const Folder = __t.object("Folder", { + id: __t.u64(), + ownerUserId: __t.string(), + path: __t.string(), + name: __t.string(), + parentPath: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Folder = __Infer; + +export const MyFileSummaries = __t.object("MyFileSummaries", {}); +export type MyFileSummaries = __Infer; + +export const MyFolders = __t.object("MyFolders", {}); +export type MyFolders = __Infer; + diff --git a/spacetime-files-ts/example/src/codegen/app/types/procedures.ts b/spacetime-files-ts/example/src/codegen/app/types/procedures.ts new file mode 100644 index 00000000000..f6c24f082b1 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/types/procedures.ts @@ -0,0 +1,13 @@ +// 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 ReadFileBytesProcedure from "../read_file_bytes_procedure"; + +export type ReadFileBytesArgs = __Infer; +export type ReadFileBytesResult = __Infer; + diff --git a/spacetime-files-ts/example/src/codegen/app/types/reducers.ts b/spacetime-files-ts/example/src/codegen/app/types/reducers.ts new file mode 100644 index 00000000000..4584b8cc134 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/types/reducers.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import CreateFolderReducer from "../create_folder_reducer"; +import DeleteFileReducer from "../delete_file_reducer"; +import DeleteFolderReducer from "../delete_folder_reducer"; +import MoveFileReducer from "../move_file_reducer"; +import RenameFileReducer from "../rename_file_reducer"; +import RenameFolderReducer from "../rename_folder_reducer"; +import SetFileVisibilityReducer from "../set_file_visibility_reducer"; +import UploadFileReducer from "../upload_file_reducer"; + +export type CreateFolderParams = __Infer; +export type DeleteFileParams = __Infer; +export type DeleteFolderParams = __Infer; +export type MoveFileParams = __Infer; +export type RenameFileParams = __Infer; +export type RenameFolderParams = __Infer; +export type SetFileVisibilityParams = __Infer; +export type UploadFileParams = __Infer; + diff --git a/spacetime-files-ts/example/src/codegen/app/upload_file_reducer.ts b/spacetime-files-ts/example/src/codegen/app/upload_file_reducer.ts new file mode 100644 index 00000000000..9d3d5519978 --- /dev/null +++ b/spacetime-files-ts/example/src/codegen/app/upload_file_reducer.ts @@ -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 { + path: __t.string(), + mimeType: __t.string(), + bytes: __t.byteArray(), + visibility: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/context-menu.ts b/spacetime-files-ts/example/src/context-menu.ts new file mode 100644 index 00000000000..aaeedf8f070 --- /dev/null +++ b/spacetime-files-ts/example/src/context-menu.ts @@ -0,0 +1,135 @@ +import type { FileSummary } from './codegen/app/types'; +import { icon } from './rendering'; +import { escapeHtml } from './utils'; + +export type ContextTarget = + | { type: 'file' | 'folder'; path: string } + | { type: 'background' }; + +type ContextItem = { + label: string; + iconName: string; + run: () => void; + danger?: boolean; +} | null; + +export interface ContextMenuServices { + file(path: string): FileSummary | undefined; + preview(path: string): void; + viewDetails(path: string): void; + downloadFile(file: FileSummary): void; + copyLink(path: string): void; + duplicateFile(path: string): void; + renameFile(path: string): void; + moveFile(path: string): void; + toggleVisibility(path: string): void; + deleteFile(path: string): void; + openFolder(path: string): void; + downloadFolder(path: string): void; + renameFolder(path: string): void; + deleteFolder(path: string): void; + newFolder(): void; + chooseFiles(): void; + chooseFolder(): void; +} + +const item = ( + label: string, + iconName: string, + run: () => void, + danger = false +): ContextItem => ({ label, iconName, run, danger }); + +export class ContextMenu { + constructor(private readonly services: ContextMenuServices) {} + + open = (event: MouseEvent, target: ContextTarget): void => { + event.preventDefault(); + event.stopPropagation(); + const items = this.itemsFor(target); + if (!items) return; + const menu = document.getElementById('ctx')!; + menu.innerHTML = items + .map(entry => + entry === null + ? '
        ' + : `` + ) + .join(''); + const buttons = [...menu.querySelectorAll('button')]; + let buttonIndex = 0; + for (const entry of items) { + if (!entry) continue; + buttons[buttonIndex++]!.addEventListener('click', () => { + this.close(); + entry.run(); + }); + } + menu.classList.add('open'); + const rect = menu.getBoundingClientRect(); + menu.style.left = `${Math.min(event.clientX, window.innerWidth - rect.width - 8)}px`; + menu.style.top = `${Math.min(event.clientY, window.innerHeight - rect.height - 8)}px`; + }; + + close = (): void => { + document.getElementById('ctx')!.classList.remove('open'); + }; + + private itemsFor(target: ContextTarget): ContextItem[] | undefined { + if (target.type === 'file') { + const file = this.services.file(target.path); + if (!file) return undefined; + const isPublic = file.visibility === 'public'; + return [ + item('Preview', 'eye', () => this.services.preview(target.path)), + item('View details', 'info', () => + this.services.viewDetails(target.path) + ), + item('Download', 'download', () => this.services.downloadFile(file)), + item('Copy link', 'link', () => this.services.copyLink(target.path)), + item('Make a copy', 'copy', () => + this.services.duplicateFile(target.path) + ), + null, + item('Rename', 'pencil', () => this.services.renameFile(target.path)), + item('Move...', 'move', () => this.services.moveFile(target.path)), + item( + isPublic ? 'Make private' : 'Make public', + isPublic ? 'lock' : 'globe', + () => this.services.toggleVisibility(target.path) + ), + null, + item( + 'Delete', + 'trash', + () => this.services.deleteFile(target.path), + true + ), + ]; + } + if (target.type === 'folder') { + return [ + item('Open', 'folder', () => this.services.openFolder(target.path)), + item('View details', 'info', () => + this.services.viewDetails(target.path) + ), + item('Download as zip', 'download', () => + this.services.downloadFolder(target.path) + ), + null, + item('Rename', 'pencil', () => this.services.renameFolder(target.path)), + item( + 'Delete', + 'trash', + () => this.services.deleteFolder(target.path), + true + ), + ]; + } + return [ + item('New folder', 'plus', () => this.services.newFolder()), + item('Upload files', 'upload', () => this.services.chooseFiles()), + item('Upload folder', 'folder-up', () => this.services.chooseFolder()), + ]; + } +} diff --git a/spacetime-files-ts/example/src/dialog.ts b/spacetime-files-ts/example/src/dialog.ts new file mode 100644 index 00000000000..4beadcfeb3c --- /dev/null +++ b/spacetime-files-ts/example/src/dialog.ts @@ -0,0 +1,97 @@ +import { escapeHtml } from './utils'; + +const element = (id: string): T => + document.getElementById(id) as T; + +export interface DialogOptions { + okLabel?: string; + danger?: boolean; + altLabel?: string | null; + onAlt?: (() => void | Promise) | null; +} + +export class DialogController { + private onSave: (() => void | Promise) | null = null; + + constructor(private readonly reportError: (error: unknown) => void) {} + + open = ( + title: string, + bodyHtml: string, + onSave: (() => void | Promise) | null, + options: DialogOptions = {} + ): void => { + const { + okLabel = 'Save', + danger = false, + altLabel = null, + onAlt = null, + } = options; + element('dialog-title').textContent = title; + element('dialog-body').innerHTML = bodyHtml; + const ok = element('dialog-ok'); + ok.textContent = okLabel; + ok.classList.toggle('danger', danger); + ok.classList.toggle('primary', !danger); + const alt = element('dialog-alt'); + alt.hidden = !altLabel; + if (altLabel) { + alt.textContent = altLabel; + alt.onclick = () => { + void (async () => { + try { + await onAlt?.(); + this.close(); + } catch (error) { + this.reportError(error); + } + })(); + }; + } + this.onSave = onSave; + element('dialog').classList.add('open'); + setTimeout( + () => + element('dialog-body') + .querySelector('input,select,button') + ?.focus(), + 20 + ); + }; + + close = (): void => { + element('dialog').classList.remove('open'); + this.resetChrome(); + this.onSave = null; + }; + + commit = async (): Promise => { + if (!this.onSave) return this.close(); + try { + await this.onSave(); + this.close(); + } catch (error) { + this.reportError(error); + } + }; + + confirm( + title: string, + message: string, + onConfirm: () => void | Promise + ): void { + this.open(title, `

        ${escapeHtml(message)}

        `, onConfirm, { + okLabel: 'Delete', + danger: true, + }); + } + + private resetChrome(): void { + element('dialog-body').className = 'card-body'; + element('dialog-ok').style.display = ''; + element('dialog-cancel').textContent = 'Cancel'; + const alt = element('dialog-alt'); + alt.hidden = true; + alt.onclick = null; + } +} diff --git a/spacetime-files-ts/example/src/downloads.ts b/spacetime-files-ts/example/src/downloads.ts new file mode 100644 index 00000000000..3e4f161dcd6 --- /dev/null +++ b/spacetime-files-ts/example/src/downloads.ts @@ -0,0 +1,126 @@ +import type { FileSummary } from './codegen/app/types'; +import { buildZip, type ZipEntry } from './zip'; +import { baseName, fileUrl, humanError, tsMs } from './utils'; + +export const ARCHIVE_FILE_COUNT_MAX = 250; +export const ARCHIVE_ENTRY_COUNT_MAX = 1_000; +export const ARCHIVE_TOTAL_BYTES_MAX = 64 * 1024 * 1024; + +export interface DownloadServices { + readFileBytes(path: string): Promise<{ bytes: Uint8Array; mimeType: string }>; + toast(kind: 'ok' | 'err', message: string): void; +} + +export interface ArchiveRequest { + fileRows: FileSummary[]; + dirNames: Array<{ name: string; mtimeMs: number }>; + entryName(file: FileSummary): string; + zipName: string; +} + +export function archiveSelectionError( + fileRows: readonly FileSummary[], + directoryCount = 0 +): string | undefined { + if (fileRows.length > ARCHIVE_FILE_COUNT_MAX) { + return `Select at most ${ARCHIVE_FILE_COUNT_MAX} files for one archive.`; + } + if (fileRows.length + directoryCount > ARCHIVE_ENTRY_COUNT_MAX) { + return `Archive contents exceed the ${ARCHIVE_ENTRY_COUNT_MAX} entry limit.`; + } + const totalBytes = fileRows.reduce((total, file) => total + file.size, 0n); + if (totalBytes > BigInt(ARCHIVE_TOTAL_BYTES_MAX)) { + return `Archive contents exceed the ${ARCHIVE_TOTAL_BYTES_MAX / 1024 / 1024} MiB limit.`; + } + return undefined; +} + +export async function getFileBlob( + row: FileSummary, + services: DownloadServices +): Promise { + if (row.visibility === 'public') { + try { + const response = await fetch(fileUrl(row.id)); + if (response.ok) return await response.blob(); + } catch { + // The authenticated procedure below also serves public files. + } + } + const { bytes, mimeType } = await services.readFileBytes(row.path); + return new Blob([bytes as BlobPart], { + type: mimeType || row.mimeType || 'application/octet-stream', + }); +} + +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 4000); +} + +export async function downloadFile( + row: FileSummary, + services: DownloadServices +): Promise { + try { + saveBlob(await getFileBlob(row, services), baseName(row.path)); + } catch (error) { + services.toast('err', humanError(error)); + } +} + +export async function downloadArchive( + request: ArchiveRequest, + services: DownloadServices +): Promise { + const { fileRows, dirNames, entryName, zipName } = request; + if (fileRows.length === 0 && dirNames.length === 0) { + services.toast('err', 'Nothing to download.'); + return; + } + const selectionError = archiveSelectionError(fileRows, dirNames.length); + if (selectionError) { + services.toast('err', selectionError); + return; + } + if (fileRows.length > 3) + services.toast('ok', `Zipping ${fileRows.length} files...`); + + const entries: ZipEntry[] = dirNames.map(directory => ({ + name: directory.name, + isDir: true, + mtimeMs: directory.mtimeMs, + })); + const failures: string[] = []; + let loadedBytes = 0; + for (const file of fileRows) { + try { + const blob = await getFileBlob(file, services); + loadedBytes += blob.size; + if (loadedBytes > ARCHIVE_TOTAL_BYTES_MAX) { + services.toast( + 'err', + `Downloaded contents exceed the ${ARCHIVE_TOTAL_BYTES_MAX / 1024 / 1024} MiB limit.` + ); + return; + } + entries.push({ + name: entryName(file), + bytes: new Uint8Array(await blob.arrayBuffer()), + mtimeMs: tsMs(file.updatedAt), + }); + } catch (error) { + failures.push(`${baseName(file.path)}: ${humanError(error)}`); + } + } + for (const message of failures) services.toast('err', message); + if (entries.length === 0) return; + saveBlob(buildZip(entries), zipName); + services.toast('ok', `${zipName} ready`); +} diff --git a/spacetime-files-ts/example/src/drop-target.ts b/spacetime-files-ts/example/src/drop-target.ts new file mode 100644 index 00000000000..847c1acdc41 --- /dev/null +++ b/spacetime-files-ts/example/src/drop-target.ts @@ -0,0 +1,68 @@ +import { collectDropped } from './uploads'; + +export interface FolderDropServices { + currentPath(): string; + endFileDrag(): void; + upload(dataTransfer: DataTransfer, folderPath: string): Promise; + move(paths: string[], folderPath: string): Promise; +} + +export function wireFolderDropTarget( + element: HTMLElement, + folderPath: string, + services: FolderDropServices +): void { + const dropPath = document.getElementById('drop-path')!; + element.addEventListener('dragover', event => { + const types = [...event.dataTransfer!.types]; + if (!types.includes('application/x-vault-path') && !types.includes('Files')) + return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer!.dropEffect = types.includes('Files') ? 'copy' : 'move'; + element.classList.add('drag-over'); + if (types.includes('Files')) dropPath.textContent = folderPath; + }); + element.addEventListener('dragleave', () => { + element.classList.remove('drag-over'); + dropPath.textContent = services.currentPath(); + }); + element.addEventListener('drop', event => { + void (async () => { + const types = [...event.dataTransfer!.types]; + if ( + !types.includes('application/x-vault-path') && + !types.includes('Files') + ) + return; + event.preventDefault(); + event.stopPropagation(); + element.classList.remove('drag-over'); + services.endFileDrag(); + if (types.includes('Files')) { + await services.upload(event.dataTransfer!, folderPath); + return; + } + let paths: string[] = []; + try { + paths = JSON.parse( + event.dataTransfer!.getData('application/x-vault-path') + ) as string[]; + } catch { + // Ignore malformed drag data. + } + await services.move(paths, folderPath); + })(); + }); +} + +export async function uploadDropped( + dataTransfer: DataTransfer, + folderPath: string, + upload: ( + entries: Awaited>, + path: string + ) => Promise +): Promise { + await upload(await collectDropped(dataTransfer), folderPath); +} diff --git a/spacetime-files-ts/example/src/keyboard.ts b/spacetime-files-ts/example/src/keyboard.ts new file mode 100644 index 00000000000..8a6beca0620 --- /dev/null +++ b/spacetime-files-ts/example/src/keyboard.ts @@ -0,0 +1,96 @@ +import type { Entry } from './rendering'; + +export interface KeyboardServices { + entries(): readonly Entry[]; + focusPath(): string | null; + selected: Set; + visibleFilePaths(): string[]; + setFocus(path: string): void; + clearFocus(): void; + openFolder(path: string): void; + openFile(path: string): void; + toggleSelect(path: string): void; + deleteSelection(): void; + deleteFile(path: string): void; + deleteFolder(path: string): void; + hasSearch(): boolean; + clearSearch(): void; + render(): void; +} + +function focusedEntry(services: KeyboardServices): Entry | undefined { + const focusPath = services.focusPath(); + return focusPath + ? services.entries().find(entry => entry.path === focusPath) + : undefined; +} + +function moveFocus(services: KeyboardServices, delta: number): void { + const entries = services.entries(); + if (entries.length === 0) return; + const currentIndex = entries.findIndex( + entry => entry.path === services.focusPath() + ); + const nextIndex = + currentIndex < 0 + ? delta > 0 + ? 0 + : entries.length - 1 + : Math.min(entries.length - 1, Math.max(0, currentIndex + delta)); + const entry = entries[nextIndex]!; + services.setFocus(entry.path); + document + .querySelector( + entry.type === 'file' + ? `[data-file="${CSS.escape(entry.path)}"]` + : `[data-folder="${CSS.escape(entry.path)}"]` + ) + ?.scrollIntoView({ block: 'nearest' }); +} + +export function handleListKey( + event: KeyboardEvent, + services: KeyboardServices +): void { + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if ( + document.getElementById('dialog')!.classList.contains('open') || + document.getElementById('lightbox')!.classList.contains('open') || + document.getElementById('ctx')!.classList.contains('open') + ) + return; + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault(); + for (const path of services.visibleFilePaths()) services.selected.add(path); + services.render(); + return; + } + const entry = focusedEntry(services); + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { + event.preventDefault(); + moveFocus(services, 1); + } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { + event.preventDefault(); + moveFocus(services, -1); + } else if (event.key === 'Enter' && entry) { + event.preventDefault(); + if (entry.type === 'folder') services.openFolder(entry.path); + else services.openFile(entry.path); + } else if (event.key === ' ' && entry?.type === 'file') { + event.preventDefault(); + services.toggleSelect(entry.path); + } else if (event.key === 'Delete') { + event.preventDefault(); + if (services.selected.size > 0) services.deleteSelection(); + else if (entry?.type === 'file') services.deleteFile(entry.path); + else if (entry) services.deleteFolder(entry.path); + } else if (event.key === 'Escape') { + if (services.selected.size > 0) services.selected.clear(); + else if (services.focusPath()) services.clearFocus(); + else if (services.hasSearch()) services.clearSearch(); + else return; + services.render(); + } +} diff --git a/spacetime-files-ts/example/src/list-actions.ts b/spacetime-files-ts/example/src/list-actions.ts new file mode 100644 index 00000000000..09cec484005 --- /dev/null +++ b/spacetime-files-ts/example/src/list-actions.ts @@ -0,0 +1,157 @@ +import type { FileSummary } from './codegen/app/types'; +import type { ContextTarget } from './context-menu'; + +export interface ListActionServices { + selected: Set; + files(): readonly FileSummary[]; + setAnchor(path: string): void; + toggleSelect(path: string): void; + rangeSelect(path: string): void; + focus(path: string): void; + openFile(path: string): void; + openFolder(path: string): void; + openContext(event: MouseEvent, target: ContextTarget): void; + render(): void; + toggleVisibility(path: string): void; + copyLink(path: string): void; + downloadFile(file: FileSummary): void; + downloadFolder(path: string): void; + renameFile(path: string): void; + renameFolder(path: string): void; + moveFile(path: string): void; + deleteFile(path: string): void; + deleteFolder(path: string): void; + wireFolderDropTarget(element: HTMLElement, path: string): void; +} + +export function bindListActions( + list: HTMLElement, + services: ListActionServices +): void { + list + .querySelectorAll('[data-file], [data-folder]') + .forEach(row => { + row.addEventListener('click', event => { + if ( + (event.target as HTMLElement).closest( + '.row-actions, .badge, .sel, .badge-cell, .vis-dot' + ) + ) + return; + const file = row.dataset.file; + if (!file) return services.focus(row.dataset.folder!); + if (event.ctrlKey || event.metaKey) return services.toggleSelect(file); + if (event.shiftKey) return services.rangeSelect(file); + services.focus(file); + }); + row.addEventListener('dblclick', event => { + if ( + (event.target as HTMLElement).closest( + '.row-actions, .badge, .sel, .badge-cell, .vis-dot' + ) + ) + return; + if (row.dataset.file) services.openFile(row.dataset.file); + else services.openFolder(row.dataset.folder!); + }); + row.addEventListener('contextmenu', event => { + const file = row.dataset.file; + services.openContext( + event, + file + ? { type: 'file', path: file } + : { type: 'folder', path: row.dataset.folder! } + ); + }); + }); + + list.querySelectorAll('[data-select]').forEach(checkbox => { + checkbox.addEventListener('change', () => { + const path = checkbox.dataset.select!; + if (checkbox.checked) services.selected.add(path); + else services.selected.delete(path); + services.setAnchor(path); + services.render(); + }); + }); + list + .querySelectorAll('[data-visibility]') + .forEach(button => + button.addEventListener('click', () => + services.toggleVisibility(button.dataset.visibility!) + ) + ); + list + .querySelectorAll('[data-link]') + .forEach(button => + button.addEventListener('click', () => + services.copyLink(button.dataset.link!) + ) + ); + list.querySelectorAll('[data-download]').forEach(button => { + button.addEventListener('click', () => { + const file = services + .files() + .find(row => row.path === button.dataset.download); + if (file) services.downloadFile(file); + }); + }); + list.querySelectorAll('[data-download-folder]').forEach(button => + button.addEventListener('click', event => { + event.stopPropagation(); + services.downloadFolder(button.dataset.downloadFolder!); + }) + ); + list + .querySelectorAll('[data-rename]') + .forEach(button => + button.addEventListener('click', () => + services.renameFile(button.dataset.rename!) + ) + ); + list.querySelectorAll('[data-rename-folder]').forEach(button => + button.addEventListener('click', event => { + event.stopPropagation(); + services.renameFolder(button.dataset.renameFolder!); + }) + ); + list + .querySelectorAll('[data-move]') + .forEach(button => + button.addEventListener('click', () => + services.moveFile(button.dataset.move!) + ) + ); + list + .querySelectorAll('[data-delete-file]') + .forEach(button => + button.addEventListener('click', () => + services.deleteFile(button.dataset.deleteFile!) + ) + ); + list.querySelectorAll('[data-delete-folder]').forEach(button => + button.addEventListener('click', event => { + event.stopPropagation(); + services.deleteFolder(button.dataset.deleteFolder!); + }) + ); + + list.querySelectorAll('[data-drag]').forEach(row => { + row.addEventListener('dragstart', event => { + const path = row.dataset.drag!; + const paths = services.selected.has(path) + ? [...services.selected] + : [path]; + event.dataTransfer!.setData( + 'application/x-vault-path', + JSON.stringify(paths) + ); + event.dataTransfer!.effectAllowed = 'move'; + }); + }); + list + .querySelectorAll('[data-drop-folder]') + .forEach(row => + services.wireFolderDropTarget(row, row.dataset.dropFolder!) + ); +} diff --git a/spacetime-files-ts/example/src/rendering.ts b/spacetime-files-ts/example/src/rendering.ts new file mode 100644 index 00000000000..a9d24384063 --- /dev/null +++ b/spacetime-files-ts/example/src/rendering.ts @@ -0,0 +1,227 @@ +import type { FileSummary, Folder } from './codegen/app/types'; +import { + baseName, + childPrefix, + escapeHtml, + fmtSize, + fmtWhen, + kindClass, + parentPath, + tsMs, +} from './utils'; + +export type SortKey = 'name' | 'size' | 'updated' | 'visibility'; +export type Entry = { type: 'file' | 'folder'; path: string }; + +export interface VaultRenderState { + folders: Folder[]; + files: FileSummary[]; + currentPath: string; + searchQuery: string; + sortKey: SortKey; + sortDir: 1 | -1; + selected: ReadonlySet; + focusPath: string | null; +} + +export const icon = (name: string): string => + ``; + +export function createVaultRendering(getState: () => VaultRenderState) { + const immediateFolders = (path: string): Folder[] => + getState().folders.filter(folder => folder.parentPath === path); + + const immediateFiles = (path: string): FileSummary[] => + getState().files.filter(file => parentPath(file.path) === path); + + const allFolderPaths = (): string[] => [ + '/', + ...getState() + .folders.map(folder => folder.path) + .sort(), + ]; + + const fileCmp = (a: FileSummary, b: FileSummary): number => { + const { sortKey, sortDir } = getState(); + let result = 0; + if (sortKey === 'size') result = Number(a.size) - Number(b.size); + else if (sortKey === 'updated') + result = tsMs(a.updatedAt) - tsMs(b.updatedAt); + else if (sortKey === 'visibility') + result = a.visibility.localeCompare(b.visibility); + if (result === 0) result = baseName(a.path).localeCompare(baseName(b.path)); + return result * sortDir; + }; + + const folderCmp = (a: Folder, b: Folder): number => { + const { sortKey, sortDir } = getState(); + let result = + sortKey === 'updated' ? tsMs(a.updatedAt) - tsMs(b.updatedAt) : 0; + if (result === 0) result = a.name.localeCompare(b.name); + return result * sortDir; + }; + + const visibleEntries = (): { dirs: Folder[]; fs: FileSummary[] } => { + const { folders, files, currentPath, searchQuery } = getState(); + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return { + dirs: folders + .filter(folder => folder.name.toLowerCase().includes(query)) + .sort(folderCmp), + fs: files + .filter(file => baseName(file.path).toLowerCase().includes(query)) + .sort(fileCmp), + }; + } + return { + dirs: immediateFolders(currentPath).sort(folderCmp), + fs: immediateFiles(currentPath).sort(fileCmp), + }; + }; + + const stateClasses = (path: string, selectable: boolean): string => { + const { selected, focusPath } = getState(); + return `${selectable && selected.has(path) ? 'selected' : ''} ${path === focusPath ? 'focused' : ''}`; + }; + + const fileLiOpen = (file: FileSummary, kind: 'row' | 'tile'): string => + `
      • `; + + const folderLiOpen = (folder: Folder, kind: 'row' | 'tile'): string => + `
      • `; + + const selectionCheckbox = (file: FileSummary): string => + ``; + + const fileRowHtml = (file: FileSummary): string => { + const kind = kindClass(file.mimeType); + const isPublic = file.visibility === 'public'; + return ` + ${fileLiOpen(file, 'row')} + ${selectionCheckbox(file)} + ${icon(kind.ico)}${escapeHtml(baseName(file.path))} + ${getState().searchQuery ? escapeHtml(parentPath(file.path)) : fmtSize(file.size)} + ${fmtWhen(file.updatedAt)} + + + + + + + + + + +
      • `; + }; + + const folderRowHtml = (folder: Folder): string => ` + ${folderLiOpen(folder, 'row')} + + ${icon('folder')}${escapeHtml(folder.name)} + ${getState().searchQuery ? escapeHtml(parentPath(folder.path)) : 'Folder'} + ${fmtWhen(folder.updatedAt)} + + + + + + + `; + + const fileTileHtml = (file: FileSummary): string => { + const kind = kindClass(file.mimeType); + const isPublic = file.visibility === 'public'; + const isImage = (file.mimeType || '').startsWith('image/'); + return ` + ${fileLiOpen(file, 'tile')} + ${selectionCheckbox(file)} + ${icon(isPublic ? 'globe' : 'lock')} +
        ${icon(kind.ico)}
        +
        ${icon(kind.ico)}${escapeHtml(baseName(file.path))}
        + `; + }; + + const folderTileHtml = (folder: Folder): string => ` + ${folderLiOpen(folder, 'tile')} +
        ${icon('folder')}
        +
        ${icon('folder')}${escapeHtml(folder.name)}
        + `; + + const subtreeStats = ( + folderPath: string + ): { fileCount: number; folderCount: number; bytes: number } => { + const { files, folders } = getState(); + const prefix = childPrefix(folderPath); + const childFiles = files.filter(file => file.path.startsWith(prefix)); + const childFolders = folders.filter( + folder => folder.path !== folderPath && folder.path.startsWith(prefix) + ); + return { + fileCount: childFiles.length, + folderCount: childFolders.length, + bytes: childFiles.reduce((total, file) => total + Number(file.size), 0), + }; + }; + + return { + allFolderPaths, + fileRowHtml, + fileTileHtml, + folderRowHtml, + folderTileHtml, + immediateFiles, + immediateFolders, + subtreeStats, + visibleEntries, + }; +} + +export function fileDetailsHtml(row: FileSummary): string { + const kind = kindClass(row.mimeType); + const isImage = (row.mimeType || '').startsWith('image/'); + const updatedAtMs = tsMs(row.updatedAt); + return ` +
        ${icon(kind.ico)}
        +
        ${icon(kind.ico)}${escapeHtml(baseName(row.path))}
        +
        +
        Type${escapeHtml(row.mimeType || 'file')}
        +
        Size${fmtSize(row.size)}
        +
        Location
        + ${updatedAtMs ? `
        Modified${escapeHtml(new Date(updatedAtMs).toLocaleString())}
        ` : ''} +
        Visibility${row.visibility === 'public' ? 'Public' : 'Private (owner only)'}
        +
        SHA-256${escapeHtml((row.sha256Hex ?? '').slice(0, 16))}...
        +
        `; +} + +export function folderDetailsHtml( + folderPath: string, + folder: Folder | undefined, + stats: { fileCount: number; folderCount: number; bytes: number } +): string { + const isRoot = folderPath === '/'; + const updatedAtMs = folder ? tsMs(folder.updatedAt) : 0; + return ` +
        ${icon('folder')}
        +
        ${icon('folder')}${escapeHtml(isRoot ? 'Root' : (folder?.name ?? ''))}
        +
        +
        TypeFolder
        +
        Contents${stats.fileCount} file${stats.fileCount === 1 ? '' : 's'}, ${stats.folderCount} folder${stats.folderCount === 1 ? '' : 's'}
        +
        Size${fmtSize(stats.bytes)}
        + ${!isRoot ? `
        Location
        ` : ''} + ${updatedAtMs ? `
        Modified${escapeHtml(new Date(updatedAtMs).toLocaleString())}
        ` : ''} +
        `; +} + +export function selectionDetailsHtml(rows: readonly FileSummary[]): string { + const bytes = rows.reduce((total, file) => total + Number(file.size), 0); + return ` +
        ${icon('copy')}${rows.length} files selected
        +
        +
        Total size${fmtSize(bytes)}
        +
        Public${rows.filter(row => row.visibility === 'public').length} of ${rows.length}
        +
        `; +} diff --git a/spacetime-files-ts/example/src/selection.ts b/spacetime-files-ts/example/src/selection.ts new file mode 100644 index 00000000000..750f08adfed --- /dev/null +++ b/spacetime-files-ts/example/src/selection.ts @@ -0,0 +1,71 @@ +import type { Entry } from './rendering'; + +export class VaultSelection { + readonly selected = new Set(); + focusPath: string | null = null; + private anchorPath: string | null = null; + private renderedEntries: Entry[] = []; + + get entries(): readonly Entry[] { + return this.renderedEntries; + } + + setEntries(entries: readonly Entry[]): void { + this.renderedEntries = [...entries]; + } + + setAnchor(path: string): void { + this.anchorPath = path; + } + + focus(path: string): boolean { + this.anchorPath = path; + if (this.focusPath === path) return false; + this.focusPath = path; + return true; + } + + clearFocus(): void { + this.focusPath = null; + } + + toggle(path: string): void { + if (this.selected.has(path)) this.selected.delete(path); + else this.selected.add(path); + this.anchorPath = path; + } + + selectRange(path: string): void { + const filePaths = this.renderedEntries + .filter(entry => entry.type === 'file') + .map(entry => entry.path); + const anchorIndex = filePaths.indexOf(this.anchorPath ?? ''); + const targetIndex = filePaths.indexOf(path); + if (anchorIndex < 0 || targetIndex < 0) { + this.toggle(path); + return; + } + for ( + let index = Math.min(anchorIndex, targetIndex); + index <= Math.max(anchorIndex, targetIndex); + index++ + ) { + this.selected.add(filePaths[index]!); + } + } + + prune( + validSelectedPaths: ReadonlySet, + validFocusPaths: ReadonlySet + ): void { + for (const path of this.selected) { + if (!validSelectedPaths.has(path)) this.selected.delete(path); + } + if (this.focusPath && !validFocusPaths.has(this.focusPath)) { + this.focusPath = null; + } + if (this.anchorPath && !validSelectedPaths.has(this.anchorPath)) { + this.anchorPath = null; + } + } +} diff --git a/spacetime-files-ts/example/src/uploads.ts b/spacetime-files-ts/example/src/uploads.ts new file mode 100644 index 00000000000..a985d398a96 --- /dev/null +++ b/spacetime-files-ts/example/src/uploads.ts @@ -0,0 +1,217 @@ +import { FILE_BYTES_MAX } from '@spacetimedb/files/constants'; +import type { FileSummary } from './codegen/app/types'; +import type { DialogOptions } from './dialog'; +import { + errorCode, + escapeHtml, + fmtSize, + humanError, + joinPath, + normalizePath, + type Visibility, +} from './utils'; + +export interface DroppedEntries { + files: Array<{ file: File; rel: string }>; + dirs: string[]; +} + +function walkEntry( + entry: FileSystemEntry, + prefix: string, + output: DroppedEntries +): Promise { + return new Promise(resolve => { + if (entry.isFile) { + (entry as FileSystemFileEntry).file( + file => { + output.files.push({ file, rel: prefix + entry.name }); + resolve(); + }, + () => resolve() + ); + return; + } + if (!entry.isDirectory) { + resolve(); + return; + } + + const relativePath = prefix + entry.name; + output.dirs.push(relativePath); + const reader = (entry as FileSystemDirectoryEntry).createReader(); + const children: FileSystemEntry[] = []; + const readBatch = () => + reader.readEntries( + entries => { + void (async () => { + if (entries.length > 0) { + children.push(...entries); + readBatch(); + return; + } + for (const child of children) + await walkEntry(child, `${relativePath}/`, output); + resolve(); + })(); + }, + () => resolve() + ); + readBatch(); + }); +} + +export async function collectDropped( + dataTransfer: DataTransfer +): Promise { + const output: DroppedEntries = { files: [], dirs: [] }; + const items = [...(dataTransfer.items ?? [])]; + const entries = items + .map(item => item.webkitGetAsEntry?.()) + .filter((entry): entry is FileSystemEntry => Boolean(entry)); + if (entries.length > 0) { + for (const entry of entries) await walkEntry(entry, '', output); + } else { + for (const file of [...(dataTransfer.files ?? [])]) { + output.files.push({ file, rel: file.name }); + } + } + return output; +} + +export interface UploadServices { + ready(): boolean; + files(): readonly FileSummary[]; + createFolder(path: string): Promise; + uploadFile(args: { + path: string; + mimeType: string; + bytes: Uint8Array; + visibility: Visibility; + }): Promise; + freeName(path: string): string; + openDialog( + title: string, + bodyHtml: string, + onSave: (() => void | Promise) | null, + options?: DialogOptions + ): void; + setProgress(uploading: boolean, label?: string): void; + toast(kind: 'ok' | 'err', message: string): void; +} + +export class UploadController { + constructor(private readonly services: UploadServices) {} + + async upload(entries: DroppedEntries, targetFolder: string): Promise { + if (!this.services.ready()) return; + const { files, dirs } = entries; + if (files.length === 0 && dirs.length === 0) return; + const conflicts = files.filter(entry => + this.services + .files() + .some(file => file.path === joinPath(targetFolder, entry.rel)) + ); + if (conflicts.length > 0) { + const listHtml = + conflicts + .slice(0, 6) + .map( + conflict => `
        ${escapeHtml(conflict.rel)}
        ` + ) + .join('') + + (conflicts.length > 6 + ? `
        ...and ${conflicts.length - 6} more
        ` + : ''); + this.services.openDialog( + `${conflicts.length} file${conflicts.length === 1 ? '' : 's'} already exist${conflicts.length === 1 ? 's' : ''}`, + `

        Replace the existing file${conflicts.length === 1 ? '' : 's'}, or keep both by renaming the new one${conflicts.length === 1 ? '' : 's'}?

        ${listHtml}`, + () => this.perform(entries, targetFolder, 'replace'), + { + okLabel: 'Replace', + altLabel: 'Keep both', + onAlt: () => this.perform(entries, targetFolder, 'keep-both'), + } + ); + return; + } + await this.perform(entries, targetFolder, 'replace'); + } + + private async perform( + entries: DroppedEntries, + targetFolder: string, + conflictMode: 'replace' | 'keep-both' + ): Promise { + const directories = new Set(entries.dirs); + for (const entry of entries.files) { + const parts = entry.rel.split('/').slice(0, -1); + let path = ''; + for (const part of parts) { + path = path ? `${path}/${part}` : part; + directories.add(path); + } + } + for (const relativePath of [...directories].sort( + (a, b) => a.split('/').length - b.split('/').length + )) { + try { + await this.services.createFolder(joinPath(targetFolder, relativePath)); + } catch (error) { + if (errorCode(error) !== 'vault.folder_exists') { + this.services.toast('err', humanError(error)); + return; + } + } + } + + const failures: string[] = []; + const accepted = entries.files.filter(entry => { + if (entry.file.size <= FILE_BYTES_MAX) return true; + failures.push( + `${entry.rel}: ${fmtSize(entry.file.size)} exceeds the ${fmtSize(FILE_BYTES_MAX)} cap` + ); + return false; + }); + let completed = 0; + this.services.setProgress( + true, + accepted.length ? `Uploading 0/${accepted.length}...` : undefined + ); + for (const entry of accepted) { + try { + let path = normalizePath(joinPath(targetFolder, entry.rel), 'file'); + const existingFiles = this.services.files(); + if ( + conflictMode === 'keep-both' && + existingFiles.some(file => file.path === path) + ) { + path = this.services.freeName(path); + } + const existing = existingFiles.find(file => file.path === path); + await this.services.uploadFile({ + path, + mimeType: entry.file.type || 'application/octet-stream', + bytes: new Uint8Array(await entry.file.arrayBuffer()), + visibility: + (existing?.visibility as Visibility | undefined) ?? 'owner', + }); + completed++; + this.services.setProgress( + true, + `Uploading ${completed}/${accepted.length}...` + ); + } catch (error) { + failures.push(humanError(error, { name: entry.rel })); + } + } + this.services.setProgress(false); + if (completed > 0) { + this.services.toast( + 'ok', + `${completed} file${completed === 1 ? '' : 's'} uploaded` + ); + } + for (const failure of failures) this.services.toast('err', failure); + } +} diff --git a/spacetime-files-ts/example/src/utils.ts b/spacetime-files-ts/example/src/utils.ts new file mode 100644 index 00000000000..f1087d3bcdd --- /dev/null +++ b/spacetime-files-ts/example/src/utils.ts @@ -0,0 +1,154 @@ +import type { Timestamp } from 'spacetimedb'; + +export type Visibility = 'owner' | 'public'; + +export interface ServerConfig { + stdbUri: string; + appDatabase: string; +} + +// Connection + token persistence + +// Persisted token = same identity (and files) across reloads. +export const TOKEN_KEY = 'vault:auth-token'; + +export function loadToken(): string | undefined { + try { + return localStorage.getItem(TOKEN_KEY) ?? undefined; + } catch { + return undefined; + } +} + +export function saveToken(token: string | undefined): void { + try { + if (token) localStorage.setItem(TOKEN_KEY, token); + } catch { + /* storage unavailable; token stays in-memory only */ + } +} + +export function clearToken(): void { + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + /* ignore */ + } +} + +// Path + formatting helpers + +export function normalizePath( + path: string, + kind: 'file' | 'folder' = 'folder' +): string { + let out = String(path || '') + .trim() + .replaceAll('\\', '/') + .replace(/\/+/g, '/'); + if (!out.startsWith('/')) out = '/' + out; + if (out.length > 1 && out.endsWith('/')) out = out.slice(0, -1); + if (kind === 'file' && out === '/') throw new Error('file path required'); + return out; +} +export function parentPath(path: string): string { + if (path === '/') return '/'; + const idx = path.lastIndexOf('/'); + return idx <= 0 ? '/' : path.slice(0, idx); +} +export function baseName(path: string): string { + if (path === '/') return '/'; + return path.slice(path.lastIndexOf('/') + 1); +} +export function joinPath(dir: string, name: string): string { + return dir === '/' ? `/${name}` : `${dir}/${name}`; +} +// '/docs' must not match '/docs2'. +export function childPrefix(path: string): string { + return path === '/' ? '/' : path + '/'; +} +export function fileUrl(id: bigint): string { + return `/files?id=${encodeURIComponent(String(id))}`; +} +export function fmtSize(value: number | bigint | string | undefined): string { + const n = Number(value ?? 0); + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(2)} MB`; +} +export function tsMs(ts: Timestamp | undefined): number { + if (!ts) return 0; + try { + return Number(ts.microsSinceUnixEpoch / 1000n); + } catch { + return 0; + } +} +export function fmtWhen(ts: Timestamp | undefined): string { + const ms = tsMs(ts); + if (!ms) return ''; + const d = new Date(ms); + if (d.toDateString() === new Date().toDateString()) { + return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} +export function escapeHtml(s: unknown): string { + return String(s ?? '').replace( + /[&<>"']/g, + c => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + c + ]! + ); +} +export function kindClass(mime: string | undefined): { + cls: string; + ico: string; +} { + if (!mime) return { cls: 'generic', ico: 'file' }; + if (mime.startsWith('image/')) return { cls: 'image', ico: 'file-image' }; + if (mime.startsWith('audio/') || mime.startsWith('video/')) + return { cls: 'media', ico: 'file-media' }; + if (mime.startsWith('text/') || mime === 'application/json') + return { cls: 'text', ico: 'file-text' }; + return { cls: 'generic', ico: 'file' }; +} + +// Error mapping: turn server codes into human sentences + +// Errors are `:`. Parse only the code because detail can contain user paths. +export const ERROR_MESSAGES: Record = { + 'vault.folder_not_empty': + "That folder isn't empty. Delete its contents first.", + 'vault.folder_exists': 'A folder with that name already exists here.', + 'vault.file_exists': + 'A file with that name already exists at the destination.', + 'vault.parent_not_found': "That destination folder doesn't exist.", + 'vault.folder_not_found': "That folder doesn't exist.", + 'vault.file_not_found': "That file doesn't exist.", + 'vault.cannot_delete_root': "The root folder can't be deleted.", + 'vault.cannot_rename_root': "The root folder can't be renamed.", + 'vault.invalid_file_path': 'A file needs a name.', + 'vault.invalid_path': "That name isn't allowed.", + 'vault.invalid_visibility': 'That visibility value is invalid.', + 'files.invalid_path': "That name isn't allowed.", + 'files.invalid_visibility': 'That visibility value is invalid.', + 'files.not_found': "That file doesn't exist.", + 'files.invalid_mime_type': 'That file type is invalid.', +}; +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err ?? ''); +} +export function errorCode(err: unknown): string { + return (errorMessage(err).match(/\b(?:vault|files)\.[a-z_]+/) ?? [])[0] ?? ''; +} +export function humanError(err: unknown, ctx: { name?: string } = {}): string { + const raw = errorMessage(err) || 'Something went wrong'; + const big = raw.match(/^files\.too_large:(\d+)\/(\d+)/); + if (big) { + const name = ctx.name ? `"${ctx.name}"` : 'That file'; + return `${name} is ${fmtSize(big[1])}. Vault caps files at ${fmtSize(big[2])}.`; + } + return ERROR_MESSAGES[errorCode(err)] ?? raw; +} diff --git a/spacetime-files-ts/example/src/viewer.ts b/spacetime-files-ts/example/src/viewer.ts new file mode 100644 index 00000000000..db1d2a390d1 --- /dev/null +++ b/spacetime-files-ts/example/src/viewer.ts @@ -0,0 +1,180 @@ +import type { FileSummary } from './codegen/app/types'; +import { baseName, escapeHtml, fmtSize, humanError, tsMs } from './utils'; + +const element = (id: string): T => + document.getElementById(id) as T; + +export interface FileViewerServices { + loadBlob(file: FileSummary): Promise; + download(file: FileSummary): Promise; + iconHtml(name: 'file' | 'download'): string; +} + +export class FileViewer { + private files: FileSummary[] = []; + private index = -1; + private ownedUrl: string | null = null; + private generation = 0; + private scale: number | null = null; + + path: string | null = null; + + constructor(private readonly services: FileViewerServices) {} + + isOpen(): boolean { + return element('lightbox').classList.contains('open'); + } + + currentFile(): FileSummary | undefined { + return this.path + ? this.files.find(file => file.path === this.path) + : undefined; + } + + async open(path: string, files: FileSummary[]): Promise { + this.files = files.slice(); + const index = Math.max( + 0, + this.files.findIndex(file => file.path === path) + ); + this.scale = null; + element('lightbox').classList.add('open'); + await this.load(index); + } + + step(delta: number): void { + if (this.files.length < 2) return; + this.scale = null; + void this.load( + (this.index + delta + this.files.length) % this.files.length + ); + } + + close(): void { + this.generation++; + element('lightbox').classList.remove('open'); + element('lb-stage').innerHTML = ''; + this.setZoomControls(false); + this.releaseUrl(); + this.path = null; + } + + zoom(factor: number): void { + const image = element('lb-stage').querySelector('img'); + if (!image) return; + if (this.scale === null) this.scale = image.width / image.naturalWidth || 1; + this.scale = Math.min(8, Math.max(0.1, this.scale * factor)); + this.applyScale(); + } + + fit(): void { + this.scale = null; + this.applyScale(); + } + + fullSize(): void { + this.scale = 1; + this.applyScale(); + } + + private releaseUrl(): void { + if (!this.ownedUrl) return; + URL.revokeObjectURL(this.ownedUrl); + this.ownedUrl = null; + } + + private applyScale = (): void => { + const image = element('lb-stage').querySelector('img'); + if (!image) return; + if (this.scale === null) { + image.classList.add('fit'); + image.style.width = ''; + } else { + image.classList.remove('fit'); + image.style.width = `${image.naturalWidth * this.scale}px`; + } + }; + + private setZoomControls(visible: boolean): void { + for (const id of ['lb-out', 'lb-in', 'lb-fit', 'lb-full']) { + element(id).style.display = visible ? '' : 'none'; + } + } + + private async load(index: number): Promise { + const row = this.files[index]; + if (!row) return; + const generation = ++this.generation; + this.index = index; + this.path = row.path; + element('lb-title').textContent = baseName(row.path); + const updatedAtMs = tsMs(row.updatedAt); + element('lb-meta').textContent = [ + row.mimeType || 'file', + fmtSize(row.size), + row.visibility === 'public' ? 'Public' : 'Private', + updatedAtMs ? new Date(updatedAtMs).toLocaleString() : '', + this.files.length > 1 ? `${index + 1}/${this.files.length}` : '', + ] + .filter(Boolean) + .join(' | '); + element('lb-meta').title = row.sha256Hex ? `SHA-256 ${row.sha256Hex}` : ''; + element('lb-prev').disabled = this.files.length < 2; + element('lb-next').disabled = this.files.length < 2; + await this.buildStage(row, generation); + } + + private async buildStage( + row: FileSummary, + generation: number + ): Promise { + const stage = element('lb-stage'); + const mime = row.mimeType || ''; + const previewable = + mime.startsWith('image/') || + mime.startsWith('audio/') || + mime.startsWith('video/') || + mime.startsWith('text/') || + mime === 'application/json' || + mime === 'application/pdf'; + this.releaseUrl(); + this.setZoomControls(false); + if (!previewable) { + stage.innerHTML = `
        ${this.services.iconHtml('file')}
        No inline preview for this type.
        `; + stage + .querySelector('[data-vdl]') + ?.addEventListener('click', () => void this.services.download(row)); + return; + } + + let blob: Blob; + try { + blob = await this.services.loadBlob(row); + } catch (error) { + if (generation !== this.generation) return; + stage.innerHTML = `
        ${this.services.iconHtml('file')}
        Could not load this file: ${escapeHtml(humanError(error))}
        `; + return; + } + if (generation !== this.generation) return; + if (mime.startsWith('text/') || mime === 'application/json') { + const text = await blob.text(); + if (generation !== this.generation) return; + stage.innerHTML = `
        ${escapeHtml(text.slice(0, 20000))}
        `; + return; + } + + this.ownedUrl = URL.createObjectURL(blob); + if (mime.startsWith('image/')) { + stage.innerHTML = `${escapeHtml(baseName(row.path))}`; + this.scale = null; + stage.querySelector('img')!.onload = this.applyScale; + this.setZoomControls(true); + } else if (mime.startsWith('audio/')) { + stage.innerHTML = ``; + } else if (mime.startsWith('video/')) { + stage.innerHTML = ``; + } else { + stage.innerHTML = ``; + } + } +} diff --git a/spacetime-files-ts/example/src/zip.ts b/spacetime-files-ts/example/src/zip.ts new file mode 100644 index 00000000000..8c7e8a4dc2d --- /dev/null +++ b/spacetime-files-ts/example/src/zip.ts @@ -0,0 +1,130 @@ +// ZIP archives use STORE mode for small files and require no compression dependency. + +const crcTable = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); +function crc32(bytes: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) + c = crcTable[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function dosDateTime(ms: number | undefined): { time: number; date: number } { + const d = ms ? new Date(ms) : new Date(); + return { + time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1), + date: + (((d.getFullYear() - 1980) & 0x7f) << 9) | + ((d.getMonth() + 1) << 5) | + d.getDate(), + }; +} +export interface ZipEntry { + name: string; + bytes?: Uint8Array; + mtimeMs?: number; + isDir?: boolean; // dir names must end with '/' +} +export function buildZip(entries: ZipEntry[]): Blob { + const te = new TextEncoder(); + const u16 = (v: number) => new Uint8Array([v & 255, (v >>> 8) & 255]); + const u32 = (v: number) => + new Uint8Array([ + v & 255, + (v >>> 8) & 255, + (v >>> 16) & 255, + (v >>> 24) & 255, + ]); + const chunks: Uint8Array[] = []; + const central: Array<{ + name: Uint8Array; + crc: number; + size: number; + time: number; + date: number; + offset: number; + isDir: boolean; + }> = []; + let offset = 0; + for (const e of entries) { + const name = te.encode(e.name); + const data = e.bytes ?? new Uint8Array(); + const crc = e.isDir ? 0 : crc32(data); + const { time, date } = dosDateTime(e.mtimeMs); + // Local file header: flag 0x0800 = UTF-8 names, method 0 = store. + chunks.push( + u32(0x04034b50), + u16(20), + u16(0x0800), + u16(0), + u16(time), + u16(date), + u32(crc), + u32(data.length), + u32(data.length), + u16(name.length), + u16(0), + name, + data + ); + central.push({ + name, + crc, + size: data.length, + time, + date, + offset, + isDir: !!e.isDir, + }); + offset += 30 + name.length + data.length; + } + const cdStart = offset; + let cdSize = 0; + for (const c of central) { + chunks.push( + u32(0x02014b50), + u16(20), + u16(20), + u16(0x0800), + u16(0), + u16(c.time), + u16(c.date), + u32(c.crc), + u32(c.size), + u32(c.size), + u16(c.name.length), + u16(0), + u16(0), + u16(0), + u16(0), + u32(c.isDir ? 0x10 : 0), + u32(c.offset), + c.name + ); + cdSize += 46 + c.name.length; + } + chunks.push( + u32(0x06054b50), + u16(0), + u16(0), + u16(central.length), + u16(central.length), + u32(cdSize), + u32(cdStart), + u16(0) + ); + // BlobPart requires an ArrayBuffer-backed byte view under TS 5.7. + return new Blob(chunks as unknown as BlobPart[], { type: 'application/zip' }); +} +// Timestamp in the archive name so repeat downloads don't collide. +export function zipStamp(): string { + const d = new Date(); + const p = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; +} diff --git a/spacetime-files-ts/example/tsconfig.json b/spacetime-files-ts/example/tsconfig.json new file mode 100644 index 00000000000..ae0d0a4d3c3 --- /dev/null +++ b/spacetime-files-ts/example/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-files-ts/package.json b/spacetime-files-ts/package.json new file mode 100644 index 00000000000..e64f6026302 --- /dev/null +++ b/spacetime-files-ts/package.json @@ -0,0 +1,79 @@ +{ + "name": "@spacetimedb/files", + "description": "Transactional file storage, visibility, hashing, and serving primitives for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./constants": { + "types": "./src/constants.ts", + "default": "./src/constants.ts" + }, + "./rows": { + "types": "./src/rows.ts", + "default": "./src/rows.ts" + }, + "./procedures": { + "types": "./src/procedures.ts", + "default": "./src/procedures.ts" + }, + "./handlers": { + "types": "./src/handlers.ts", + "default": "./src/handlers.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-files-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-files-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "files", + "storage", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts" + }, + "dependencies": { + "@spacetimedb/crypto": "workspace:^" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-files-ts/scripts/test.ts b/spacetime-files-ts/scripts/test.ts new file mode 100644 index 00000000000..f9d50103839 --- /dev/null +++ b/spacetime-files-ts/scripts/test.ts @@ -0,0 +1,41 @@ +import * as assert from 'node:assert/strict'; +import { fileSha256Hex } from '../src/hash.ts'; +import { queryParam } from '../src/query.ts'; +import { + ownerPathKey, + validateFilePath, + validateMimeType, +} from '../src/validation.ts'; + +assert.equal( + fileSha256Hex(new TextEncoder().encode('abc')), + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' +); +assert.equal( + fileSha256Hex([]), + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' +); +assert.equal(queryParam('/files?id=42', 'id'), '42'); +assert.equal(queryParam('/files?name=hello+world', 'name'), 'hello world'); +assert.equal(queryParam('/files?id=%ZZ', 'id'), undefined); +assert.equal(queryParam('/files?other=1', 'id'), undefined); + +assert.notEqual( + ownerPathKey('owner-a', '/avatar.png'), + ownerPathKey('owner-b', '/avatar.png') +); +assert.notEqual(ownerPathKey('a:b', '/c'), ownerPathKey('a', '/b/c')); +assert.equal(validateFilePath('/docs/readme.txt'), '/docs/readme.txt'); +assert.throws(() => validateFilePath('docs/readme.txt'), /files\.invalid_path/); +assert.throws(() => validateFilePath('/docs/../secret'), /files\.invalid_path/); +assert.throws( + () => validateFilePath('/docs/blocked\u007f.txt'), + /files\.invalid_path/ +); +assert.equal(validateMimeType('Image/SVG+XML'), 'image/svg+xml'); +assert.throws( + () => validateMimeType('text/plain\r\nx-injected: yes'), + /files\.invalid_mime_type/ +); + +console.log('files tests passed'); diff --git a/spacetime-files-ts/src/constants.ts b/spacetime-files-ts/src/constants.ts new file mode 100644 index 00000000000..5aa158a5af1 --- /dev/null +++ b/spacetime-files-ts/src/constants.ts @@ -0,0 +1,5 @@ +// Browser-safe: no server-side imports, so client bundles can share these limits. +export const FILE_BYTES_MAX = 4_000_000; +export const FILE_PATH_MAX = 1024; +export const FILE_MIME_TYPE_MAX = 127; +export const FILE_LIST_PAGE_MAX = 200; diff --git a/spacetime-files-ts/src/handlers.ts b/spacetime-files-ts/src/handlers.ts new file mode 100644 index 00000000000..1b06435605a --- /dev/null +++ b/spacetime-files-ts/src/handlers.ts @@ -0,0 +1,155 @@ +import { SyncResponse, type Infer, type Request } from 'spacetimedb/server'; +import { fileBlobRow, fileRow, FILE_VISIBILITY_PUBLIC } from './rows.ts'; +import { queryParam } from './query.ts'; +import { safeMimeType } from './validation.ts'; + +type FileRow = Infer; +type FileBlobRow = Infer; + +interface FileTableLike { + id: { find(id: bigint): FileRow | null | undefined }; +} + +interface FileBlobTableLike { + fileId: { find(id: bigint): FileBlobRow | null | undefined }; +} + +interface FileDbLike { + file?: FileTableLike; + fileBlob?: FileBlobTableLike; + files?: { + file?: FileTableLike; + fileBlob?: FileBlobTableLike; + }; +} + +interface FileTransactionLike { + db: FileDbLike; +} + +export interface FileHandlerContext { + identity?: { toHexString(): string }; + withTx( + body: (tx: Tx) => T + ): T; +} + +type FileMetadata = ReturnType; + +export interface FileServeOptions { + getOwner: (ctx: FileHandlerContext, req: Request) => string | undefined; + canAccess?: ( + ctx: FileHandlerContext, + req: Request, + file: FileMetadata, + owner: string | undefined + ) => boolean; +} + +function getFileTable(db: FileDbLike): FileTableLike | undefined { + return db.file ?? db.files?.file; +} + +function getFileBlobTable(db: FileDbLike): FileBlobTableLike | undefined { + return db.fileBlob ?? db.files?.fileBlob; +} + +function snapshotMetadata(file: FileRow) { + return { + id: file.id, + path: file.path, + ownerUserId: file.ownerUserId, + mimeType: safeMimeType(file.mimeType), + size: file.size, + sha256Hex: file.sha256Hex, + visibility: file.visibility, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }; +} + +function snapshotWithBytes(file: FileRow, bytes: number[]) { + return { + ...snapshotMetadata(file), + bytes: new Uint8Array(bytes), + }; +} + +function responseHeaders( + file: ReturnType +): Record { + return { + 'content-type': file.mimeType, + 'content-length': String(file.size), + etag: `"${file.sha256Hex}"`, + 'cache-control': + file.visibility === FILE_VISIBILITY_PUBLIC + ? 'public, max-age=300, must-revalidate' + : 'private, max-age=60, must-revalidate', + }; +} + +export function makeFileServeImpl(opts: FileServeOptions) { + return (rawCtx: unknown, req: Request): SyncResponse => { + const ctx = rawCtx as FileHandlerContext; + const method = req.method.toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') { + return new SyncResponse('method not allowed', { status: 405 }); + } + + const rawId = queryParam(String(req.uri), 'id'); + if (!rawId) return new SyncResponse('missing id', { status: 400 }); + let id: bigint; + try { + id = BigInt(rawId); + if (id <= 0n) return new SyncResponse('bad id', { status: 400 }); + } catch { + return new SyncResponse('bad id', { status: 400 }); + } + + const metadata = ctx.withTx(tx => { + const row = getFileTable(tx.db)?.id.find(id); + return row ? snapshotMetadata(row) : undefined; + }); + if (!metadata) return new SyncResponse('not found', { status: 404 }); + + const owner = opts.getOwner(ctx, req); + const canAccess = (file: FileMetadata) => + file.visibility === FILE_VISIBILITY_PUBLIC || + (opts.canAccess + ? opts.canAccess(ctx, req, file, owner) + : Boolean(owner && file.ownerUserId === owner)); + if (!canAccess(metadata)) + return new SyncResponse('forbidden', { status: 403 }); + + const headers = responseHeaders(metadata); + if (req.headers.get('if-none-match') === headers.etag) { + return new SyncResponse('', { + status: 304, + headers: { etag: headers.etag }, + }); + } + if (method === 'HEAD') { + return new SyncResponse('', { status: 200, headers }); + } + + // Load bytes only for a GET that needs a body. Recheck access against the + // same snapshot so a visibility change cannot race the metadata lookup. + const file = ctx.withTx(tx => { + const row = getFileTable(tx.db)?.id.find(id); + if (!row) return undefined; + const blob = getFileBlobTable(tx.db)?.fileId.find(id); + return blob ? snapshotWithBytes(row, blob.bytes) : undefined; + }); + if (!file) return new SyncResponse('not found', { status: 404 }); + if (!canAccess(file)) return new SyncResponse('forbidden', { status: 403 }); + const finalHeaders = responseHeaders(file); + if (req.headers.get('if-none-match') === finalHeaders.etag) { + return new SyncResponse('', { + status: 304, + headers: { etag: finalHeaders.etag }, + }); + } + return new SyncResponse(file.bytes, { status: 200, headers: finalHeaders }); + }; +} diff --git a/spacetime-files-ts/src/hash.ts b/spacetime-files-ts/src/hash.ts new file mode 100644 index 00000000000..a94e0f85462 --- /dev/null +++ b/spacetime-files-ts/src/hash.ts @@ -0,0 +1,10 @@ +import { sha256 } from '@spacetimedb/crypto'; + +export function fileSha256Hex(bytes: Uint8Array | number[]): string { + const digest = sha256( + bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) + ); + let out = ''; + for (const byte of digest) out += byte.toString(16).padStart(2, '0'); + return out; +} diff --git a/spacetime-files-ts/src/index.ts b/spacetime-files-ts/src/index.ts new file mode 100644 index 00000000000..a633389188b --- /dev/null +++ b/spacetime-files-ts/src/index.ts @@ -0,0 +1,43 @@ +export { + fileRow, + fileBlobRow, + fileListPage, + fileSummary, + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, +} from './rows.ts'; + +export { + FILE_BYTES_MAX, + FILE_LIST_PAGE_MAX, + FILE_MIME_TYPE_MAX, + FILE_PATH_MAX, +} from './constants.ts'; + +export { + FileValidationError, + ownerPathKey, + safeMimeType, + validateFileOwner, + validateFilePath, + validateFilePrefix, + validateMimeType, +} from './validation.ts'; + +export { + fileSha256Hex, + uploadFileParams, + uploadFileImpl, + deleteFileParams, + deleteFileImpl, + listFilesParams, + listFilesReturn, + listFilesImpl, + readFileBytesParams, + readFileBytesReturn, + readFileBytesImpl, + setFileVisibilityParams, + setFileVisibilityImpl, +} from './procedures.ts'; + +export { makeFileServeImpl } from './handlers.ts'; diff --git a/spacetime-files-ts/src/procedures.ts b/spacetime-files-ts/src/procedures.ts new file mode 100644 index 00000000000..79494ec0b4f --- /dev/null +++ b/spacetime-files-ts/src/procedures.ts @@ -0,0 +1,304 @@ +// Owner passed explicitly so the submodule is identity-scheme-agnostic. +import type { Timestamp } from 'spacetimedb'; +import { + Range, + t, + SenderError, + type InferTypeOfParams, +} from 'spacetimedb/server'; +import { + fileListPage, + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, +} from './rows.ts'; +import { FILE_BYTES_MAX, FILE_LIST_PAGE_MAX } from './constants.ts'; +import { fileSha256Hex } from './hash.ts'; +import { + FileValidationError, + ownerPathKey, + validateFileOwner, + validateFilePath, + validateFilePrefix, + validateMimeType, +} from './validation.ts'; +import type { TransactionModuleCtx } from './submodule/schema.ts'; + +type FileTable = TransactionModuleCtx['db']['file']; +type FileBlobTable = TransactionModuleCtx['db']['fileBlob']; + +interface FileDbLike { + file?: FileTable; + fileBlob?: FileBlobTable; + files?: { + file?: FileTable; + fileBlob?: FileBlobTable; + }; +} + +interface FileTransactionLike { + db: FileDbLike; +} + +interface FileProcedureContext { + timestamp: Timestamp; + withTx(body: (tx: FileTransactionLike) => T): T; +} + +// Re-exported for compatibility; canonical home is ./constants.ts (browser-safe). +export { FILE_BYTES_MAX, FILE_PATH_MAX } from './constants.ts'; + +// Lowercase hex SHA-256, for consumers that write their own insert path. +export { fileSha256Hex } from './hash.ts'; + +const VALID_VISIBILITIES = new Set([ + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, +]); + +// Direct `file` table or mounted-submodule layout, as in handlers.ts. +function fileTable(db: FileDbLike): FileTable { + const table = db.file ?? db.files?.file; + if (!table) throw new Error('files.file table is unavailable'); + return table; +} + +function fileBlobTable(db: FileDbLike): FileBlobTable { + const table = db.fileBlob ?? db.files?.fileBlob; + if (!table) throw new Error('files.fileBlob table is unavailable'); + return table; +} + +function validated(fn: () => T): T { + try { + return fn(); + } catch (error) { + if (error instanceof FileValidationError) + throw new SenderError(error.message); + throw error; + } +} + +function prefixUpperBound(prefix: string): string | undefined { + if (prefix.length === 0) return undefined; + const units = Array.from(prefix); + for (let i = units.length - 1; i >= 0; i--) { + const code = units[i]!.codePointAt(0)!; + if (code < 0x10ffff) { + units[i] = String.fromCodePoint(code + 1); + return units.slice(0, i + 1).join(''); + } + } + return undefined; +} + +export const uploadFileParams = { + path: t.string(), + mimeType: t.string(), + bytes: t.array(t.u8()), + visibility: t.string(), +}; + +export function uploadFileImpl( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +): bigint { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const path = validated(() => validateFilePath(args.path)); + const mimeType = validated(() => validateMimeType(args.mimeType)); + if (args.bytes.length > FILE_BYTES_MAX) { + throw new SenderError( + `files.too_large:${args.bytes.length}/${FILE_BYTES_MAX}` + ); + } + if (!VALID_VISIBILITIES.has(args.visibility)) { + throw new SenderError(`files.invalid_visibility:${args.visibility}`); + } + const sha256Hex = fileSha256Hex(args.bytes); + const key = ownerPathKey(owner, path); + return ctx.withTx(tx => { + const files = fileTable(tx.db); + const blobs = fileBlobTable(tx.db); + const existing = files.ownerPathKey.find(key); + if (existing) { + files.id.update({ + ...existing, + mimeType, + size: BigInt(args.bytes.length), + sha256Hex, + visibility: args.visibility, + updatedAt: ctx.timestamp, + }); + blobs.fileId.update({ fileId: existing.id, bytes: args.bytes }); + return existing.id; + } + const row = files.insert({ + id: 0n, + ownerPathKey: key, + path, + ownerUserId: owner, + mimeType, + size: BigInt(args.bytes.length), + sha256Hex, + visibility: args.visibility, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + blobs.insert({ fileId: row.id, bytes: args.bytes }); + return row.id; + }); +} + +export const deleteFileParams = { + path: t.string(), +}; + +export function deleteFileImpl( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +): void { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const path = validated(() => validateFilePath(args.path)); + ctx.withTx(tx => { + const files = fileTable(tx.db); + const blobs = fileBlobTable(tx.db); + const row = files.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) return; + const blob = blobs.fileId.find(row.id); + if (blob) blobs.delete(blob); + files.delete(row); + }); +} + +export const listFilesParams = { + prefix: t.string(), + cursor: t.option(t.string()), + limit: t.option(t.u32()), +}; + +export const listFilesReturn = fileListPage; + +// Caller's own files; bytes omitted (fetch via HTTP handler). +export function listFilesImpl( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +) { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const prefix = validated(() => validateFilePrefix(args.prefix)); + const rawCursor = args.cursor; + const cursor = + rawCursor === undefined + ? undefined + : validated(() => validateFilePath(rawCursor)); + if (cursor !== undefined && !cursor.startsWith(prefix)) { + throw new SenderError('files.invalid_cursor'); + } + const limit = args.limit ?? 100; + if (!Number.isInteger(limit) || limit < 1 || limit > FILE_LIST_PAGE_MAX) { + throw new SenderError('files.invalid_page_size'); + } + return ctx.withTx(tx => { + const out: Array<{ + id: bigint; + path: string; + mimeType: string; + size: bigint; + sha256Hex: string; + visibility: string; + updatedAt: Timestamp; + }> = []; + const from = + cursor === undefined + ? prefix === '' + ? undefined + : { tag: 'included' as const, value: prefix } + : { tag: 'excluded' as const, value: cursor }; + const upper = prefixUpperBound(prefix); + const to = + upper === undefined + ? undefined + : { tag: 'excluded' as const, value: upper }; + for (const row of fileTable(tx.db).ownerPath.filter([ + owner, + new Range(from, to), + ])) { + out.push({ + id: row.id, + path: row.path, + mimeType: row.mimeType, + size: row.size, + sha256Hex: row.sha256Hex, + visibility: row.visibility, + updatedAt: row.updatedAt, + }); + if (out.length > limit) break; + } + const hasMore = out.length > limit; + if (hasMore) out.pop(); + return { + files: out, + nextCursor: hasMore ? out.at(-1)?.path : undefined, + }; + }); +} + +export const readFileBytesParams = { + path: t.string(), +}; + +export const readFileBytesReturn = t.object('FileBytes', { + bytes: t.array(t.u8()), + mimeType: t.string(), +}); + +// Owner-gated byte read. HTTP handlers never see the caller's identity, so +// private files can only be read here, over the authenticated connection. +export function readFileBytesImpl( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +): { bytes: number[]; mimeType: string } { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const path = validated(() => validateFilePath(args.path)); + return ctx.withTx(tx => { + const row = fileTable(tx.db).ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) throw new SenderError(`files.not_found:${path}`); + const blob = fileBlobTable(tx.db).fileId.find(row.id); + if (!blob) throw new SenderError(`files.not_found:${path}`); + return { bytes: blob.bytes, mimeType: row.mimeType }; + }); +} + +export const setFileVisibilityParams = { + path: t.string(), + visibility: t.string(), +}; + +export function setFileVisibilityImpl( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +): void { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const path = validated(() => validateFilePath(args.path)); + if (!VALID_VISIBILITIES.has(args.visibility)) { + throw new SenderError(`files.invalid_visibility:${args.visibility}`); + } + ctx.withTx(tx => { + const files = fileTable(tx.db); + const row = files.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) throw new SenderError(`files.not_found:${path}`); + files.id.update({ + ...row, + visibility: args.visibility, + updatedAt: ctx.timestamp, + }); + }); +} diff --git a/spacetime-files-ts/src/query.ts b/spacetime-files-ts/src/query.ts new file mode 100644 index 00000000000..4df7ca8d9c2 --- /dev/null +++ b/spacetime-files-ts/src/query.ts @@ -0,0 +1,17 @@ +export function queryParam(uri: string, name: string): string | undefined { + const queryIdx = uri.indexOf('?'); + if (queryIdx < 0) return undefined; + for (const part of uri.slice(queryIdx + 1).split('&')) { + if (!part) continue; + const eqIdx = part.indexOf('='); + const rawKey = eqIdx < 0 ? part : part.slice(0, eqIdx); + try { + if (decodeURIComponent(rawKey.replace(/\+/g, ' ')) !== name) continue; + const rawValue = eqIdx < 0 ? '' : part.slice(eqIdx + 1); + return decodeURIComponent(rawValue.replace(/\+/g, ' ')); + } catch { + return undefined; + } + } + return undefined; +} diff --git a/spacetime-files-ts/src/rows.ts b/spacetime-files-ts/src/rows.ts new file mode 100644 index 00000000000..cab4e3853e3 --- /dev/null +++ b/spacetime-files-ts/src/rows.ts @@ -0,0 +1,39 @@ +import { t } from 'spacetimedb/server'; + +export const FILE_VISIBILITY_OWNER = 'owner'; +export const FILE_VISIBILITY_PUBLIC = 'public'; + +// Canonical submodule row shape. Applications with a custom file-like table may +// reuse these fields; standard integrations mount @spacetimedb/files/submodule. +export const fileRow = { + id: t.u64().primaryKey().autoInc(), + ownerPathKey: t.string().unique(), + path: t.string().index(), + ownerUserId: t.string().index(), + mimeType: t.string(), + size: t.u64(), + sha256Hex: t.string(), + visibility: t.string().index(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const fileBlobRow = { + fileId: t.u64().primaryKey(), + bytes: t.array(t.u8()), +}; + +export const fileSummary = t.object('FileSummary', { + id: t.u64(), + path: t.string(), + mimeType: t.string(), + size: t.u64(), + sha256Hex: t.string(), + visibility: t.string(), + updatedAt: t.timestamp(), +}); + +export const fileListPage = t.object('FileListPage', { + files: t.array(fileSummary), + nextCursor: t.option(t.string()), +}); diff --git a/spacetime-files-ts/src/submodule.ts b/spacetime-files-ts/src/submodule.ts new file mode 100644 index 00000000000..ca79c5aaed3 --- /dev/null +++ b/spacetime-files-ts/src/submodule.ts @@ -0,0 +1,7 @@ +export { default, spacetimedb } from './submodule/schema.ts'; +export { file, fileBlob } from './submodule/schema.ts'; +export { installFiles } from './submodule/install.ts'; +export * from './rows.ts'; +export * from './validation.ts'; +export * from './procedures.ts'; +export * from './handlers.ts'; diff --git a/spacetime-files-ts/src/submodule/install.ts b/spacetime-files-ts/src/submodule/install.ts new file mode 100644 index 00000000000..1d2b8578d69 --- /dev/null +++ b/spacetime-files-ts/src/submodule/install.ts @@ -0,0 +1,6 @@ +import type { ReducerModuleCtx } from './schema.ts'; + +export function installFiles(_ctx: ReducerModuleCtx) { + // Files has no scheduled jobs or singleton config. Host modules decide + // authorization and ownership before calling the submodule helpers. +} diff --git a/spacetime-files-ts/src/submodule/schema.ts b/spacetime-files-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..251e034e830 --- /dev/null +++ b/spacetime-files-ts/src/submodule/schema.ts @@ -0,0 +1,42 @@ +import { + schema, + table, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { fileBlobRow, fileRow } from '../rows.ts'; + +export const file = table( + { + name: 'file', + public: false, + indexes: [ + { + accessor: 'ownerPath', + algorithm: 'btree', + columns: ['ownerUserId', 'path'] as const, + }, + ] as const, + }, + fileRow +); + +export const fileBlob = table( + { name: 'file_blob', public: false }, + fileBlobRow +); + +export const spacetimedb = schema({ + file, + fileBlob, +}); +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; diff --git a/spacetime-files-ts/src/validation.ts b/spacetime-files-ts/src/validation.ts new file mode 100644 index 00000000000..6448871da29 --- /dev/null +++ b/spacetime-files-ts/src/validation.ts @@ -0,0 +1,79 @@ +import { FILE_MIME_TYPE_MAX, FILE_PATH_MAX } from './constants.ts'; + +const MIME_TYPE = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/; + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +export class FileValidationError extends Error {} + +export function ownerPathKey(owner: string, path: string): string { + return `${owner.length}:${owner}${path}`; +} + +export function validateFileOwner(owner: string): string { + if (owner.length === 0 || owner.length > 512 || hasControlCharacter(owner)) { + throw new FileValidationError('files.invalid_owner'); + } + return owner; +} + +export function validateFilePath(path: string): string { + if ( + path.length < 2 || + path.length > FILE_PATH_MAX || + !path.startsWith('/') || + path.endsWith('/') || + path.includes('\\') || + path.includes('//') || + hasControlCharacter(path) + ) { + throw new FileValidationError('files.invalid_path'); + } + for (const segment of path.slice(1).split('/')) { + if (segment === '.' || segment === '..' || segment.length > 255) { + throw new FileValidationError('files.invalid_path'); + } + } + return path; +} + +export function validateFilePrefix(prefix: string): string { + if (prefix === '') return prefix; + if ( + prefix.length > FILE_PATH_MAX || + !prefix.startsWith('/') || + prefix.includes('\\') || + prefix.includes('//') || + hasControlCharacter(prefix) + ) { + throw new FileValidationError('files.invalid_prefix'); + } + return prefix; +} + +export function validateMimeType(mimeType: string): string { + const value = mimeType.trim(); + if ( + value.length === 0 || + value.length > FILE_MIME_TYPE_MAX || + !MIME_TYPE.test(value) + ) { + throw new FileValidationError('files.invalid_mime_type'); + } + return value.toLowerCase(); +} + +export function safeMimeType(mimeType: unknown): string { + if (typeof mimeType !== 'string') return 'application/octet-stream'; + try { + return validateMimeType(mimeType); + } catch { + return 'application/octet-stream'; + } +} diff --git a/spacetime-files-ts/tsconfig.json b/spacetime-files-ts/tsconfig.json new file mode 100644 index 00000000000..e6a8236bbab --- /dev/null +++ b/spacetime-files-ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +} diff --git a/spacetime-grid-ts/LICENSE.txt b/spacetime-grid-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-grid-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-grid-ts/README.md b/spacetime-grid-ts/README.md new file mode 100644 index 00000000000..69fbdcb23fe --- /dev/null +++ b/spacetime-grid-ts/README.md @@ -0,0 +1,232 @@ +# @spacetimedb/grid + +Square and hex grids for SpacetimeDB modules, with sparse cell costs, owned or +collaborative entities, A\* pathfinding, and Dijkstra movement ranges inside the +gameplay transaction. + +--- + +## Install + +```bash +npm install @spacetimedb/grid spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +A grid is a row plus its associated `cellState`, `gridEntity`, and `entityPath` +rows. Everything is regular SpacetimeDB state, so clients subscribe to grid +changes like any other table. + +## Usage + +### Integrate into an application + +Mount the grid namespace, initialize it from the host lifecycle hook, and wrap +its helpers with the application's ownership rules: + +```ts +import { schema, t } from 'spacetimedb/server'; +import * as grid from '@spacetimedb/grid/submodule'; + +const spacetimedb = schema({ grid }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + grid.installGrid(ctx.as.grid); +}); + +export const create_player_grid = spacetimedb.procedure( + grid.createGridParams, + t.u64(), + (ctx, args) => + grid.createGridImpl(ctx.as.grid, args, ctx.sender.toHexString()) +); +``` + +The component treats owner values as opaque strings. Host operations must map +the authenticated caller to that string before calling helpers such as +`createGrid` or `moveEntity`. See the +[Grid Tactics host module](./example/spacetimedb/) +for an authenticated boundary and scoped views. + +The generated client calls the host procedure, then subscribes to the host's +caller-scoped grid views: + +```ts +const gridId = await conn.procedures.createPlayerGrid({ + name: 'Arena', + kind: 'square', + orientation: 'flat', + width: 32, + height: 32, + defaultCost: 1, + connectivity: 4, + mode: 'owner', +}); + +conn.subscriptionBuilder().subscribe(['SELECT * FROM my_grids']); +``` + +### Standalone table builders + +```ts +import { + gridRow, + cellStateRow, + gridEntityRow, + entityPathRow, +} from '@spacetimedb/grid/rows'; +``` + +### `grid` + +| Field | Type | Notes | +| ------------------ | ----------------- | --------------------------------------------------------------------------------------- | +| `id` | `u64` PK auto-inc | | +| `ownerUserId` | `string` indexed | Opaque identity, application user ID, or host-defined actor ID | +| `name` | `string` | | +| `kind` | `string` | `GRID_KIND_SQUARE` or `GRID_KIND_HEX` | +| `orientation` | `string` | `GRID_ORIENTATION_FLAT` or `GRID_ORIENTATION_POINTY` (ignored when `kind === 'square'`) | +| `width` / `height` | `i32` | Up to 1024 each | +| `defaultCost` | `i32` | Per-cell traversal cost when no sparse row exists | +| `connectivity` | `i32` | Square: `4` or `8`. Hex: always `6`. | +| `mode` | `string` | `GRID_MODE_OWNER` (creator-only mutation) or `GRID_MODE_COLLABORATIVE` | + +### `cellState` + +Sparse cell state stores rows for non-default cells. `cost <= 0` blocks the +cell. Rows are indexed by `gridId`. + +### `gridEntity` + +Entities placed on the grid. Each has `ownerUserId`, `kind` (user-defined +string), and `blocksMovement` for pathfinding. Movement uses `ownerUserId` for +authorization. + +### `entityPath` + +The last path written for an entity, with one row per `entityId`. Consumers call +`computePath` after cost-map changes to refresh the snapshot. + +Helper types `pathCell`, `pathResult`, and `reachableCell` are exported for use in your own procedure signatures. + +## Constants + +| Constant | Value | +| ------------------------- | ----------------- | +| `GRID_KIND_SQUARE` | `'square'` | +| `GRID_KIND_HEX` | `'hex'` | +| `GRID_ORIENTATION_FLAT` | `'flat'` | +| `GRID_ORIENTATION_POINTY` | `'pointy'` | +| `GRID_MODE_OWNER` | `'owner'` | +| `GRID_MODE_COLLABORATIVE` | `'collaborative'` | + +## API + +Each `*Impl` takes `(ctx, args, owner)`. Wrap them with thin reducers in your module that supply `owner` however your auth scheme works. + +Package entrypoints: + +- `@spacetimedb/grid/submodule` supplies the mounted tables and helpers. +- `@spacetimedb/grid` exports the lower-level rows, procedures, and math + helpers. +- `@spacetimedb/grid/procedures` exports operation parameters, + implementations, and result types. +- `@spacetimedb/grid/rows` exports lower-level row builders. +- `@spacetimedb/grid/math` exports standalone pathfinding primitives. + +### `createGrid` + +- Args: `name`, `kind`, `orientation`, `width`, `height`, `defaultCost`, `connectivity`, `mode`. +- Returns: `bigint` (the grid `id`). +- Validates kind / orientation / mode / dimensions (1-1024) / `defaultCost >= 1` / square connectivity in {4, 8}. Hex `connectivity` is forced to 6 regardless of input. + +### `deleteGrid` + +- Args: `gridId`. +- Cascades: deletes all `cellState`, `gridEntity`, and `entityPath` rows for the grid. +- Owner-mode-gated (collaborative mode allows any caller). + +### `setCellCost` + +- Args: `gridId`, `x`, `y`, `cost`, `terrain`. +- Upserts the sparse row. Setting `cost === grid.defaultCost` with empty terrain + removes the sparse override. +- `cost <= 0` blocks the cell for pathfinding. + +### `paintCells` + +- Args: `gridId`, `cells: PaintCell[]` (`{ x, y, cost, terrain}`). +- Batched `setCellCost` for editor brushes / map import. + +### `placeEntity` + +- Args: `gridId`, `x`, `y`, `kind`, `blocksMovement`, `label`. +- Returns: `bigint` (the entity `id`). +- Entity `ownerUserId` is set to the host-supplied `owner` value. + +### `moveEntity` + +- Args: `entityId`, `toX`, `toY`. +- Entity-owner-gated (independent of grid mode). +- Rejects with `grid.move_not_adjacent` unless `(toX, toY)` is in the entity's current neighbor set for the grid's `kind` and `connectivity`. + +For multi-step movement, drive sequential `moveEntity` calls from `computePath` results, or compute a path with `storeFor` and replay cells client-side. + +### `computePath` + +- Args: `gridId`, `startX`, `startY`, `endX`, `endY`, `storeFor` (entity id), `maxExpansions` (default 50,000). +- Returns: `PathResult { found, cells: PathCell[], cost, expanded }`. +- A\* over the live cost map (sparse `cellState` + entities with `blocksMovement`), using a kind-aware distance heuristic. +- When `storeFor` is set and a path is found, writes / overwrites the `entityPath` row for that entity. + +### `cellsInRange` + +- Args: `gridId`, `originX`, `originY`, `maxCost`. +- Returns: `{ cells: ReachableCell[] }`. +- Dijkstra flood-fill from origin out to `maxCost`. Useful for movement-range overlays, area-of-effect previews, line-of-sight gates. + +## Errors + +All `SenderError` with stable codes: + +- `grid.invalid_kind` / `grid.invalid_orientation` / `grid.invalid_mode` / `grid.invalid_connectivity` +- `grid.invalid_dimensions:x` - outside 1-1024 +- `grid.invalid_default_cost:` - `defaultCost < 1` +- `grid.not_found:` - missing grid +- `grid.not_owner:` - caller lacks grid ownership when `mode === 'owner'` +- `grid.entity_not_found:` / `grid.entity_not_owner:` +- `grid.out_of_bounds:,` +- `grid.move_not_adjacent` - destination not in current neighbor set + +## Math helpers + +`@spacetimedb/grid/math` exports the pathfinding primitives directly so you can run them off the live tables (e.g. for client-side preview or precomputed analysis): + +- `neighbors(kind, coord, connectivity)` +- `distance(kind, a, b, connectivity)` - kind-aware heuristic +- `findPathAstar({ start, goal, cost, neighbors, heuristic, maxExpansions })` +- `dijkstra({ start, cost, neighbors, maxCost })` +- `coordKey(coord)` - stable string key for `Map` + +Types: `Coord`, `GridKind`, `Connectivity`. + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +Build the +[example host module](./example/spacetimedb/) +to verify the +mounted schema, procedures, and generated bindings. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-grid-ts/example/.env.example b/spacetime-grid-ts/example/.env.example new file mode 100644 index 00000000000..8b96e395d91 --- /dev/null +++ b/spacetime-grid-ts/example/.env.example @@ -0,0 +1,32 @@ +# Copy to .env. The example server loads this on startup and bootstraps auth. + +# ---------------- Static server ---------------- +HOST=127.0.0.1 +PORT=8793 + +# ---------------- SpacetimeDB ---------------- +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_APP_DATABASE=spacetime-grid-example +STDB_SERVER=http://127.0.0.1:3000 + +# ---------------- Auth ---------------- +AUTH_ISSUER_URL=http://localhost:8793 +AUTH_BASE_URL=http://localhost:8793 +AUTH_COOKIE_NAME=stdb_auth +AUTH_SESSION_TTL_SECONDS=604800 + +# Optional. Leave blank to have the module generate an ES256 keypair on first startup bootstrap. +# Use \n escapes if putting a PEM on one line. +AUTH_ES256_PRIVATE_KEY_PEM= + +# Optional override for the SpacetimeDB CLI. + +# OAuth (optional). Without these the corresponding buttons are disabled. +# Redirect URI to register with each provider: +# http://localhost:8793/auth/google/callback +# http://localhost:8793/auth/github/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/spacetime-grid-ts/example/README.md b/spacetime-grid-ts/example/README.md new file mode 100644 index 00000000000..efd84ff6fb2 --- /dev/null +++ b/spacetime-grid-ts/example/README.md @@ -0,0 +1,167 @@ +# Grid tactics example + +This example is a turn-based hex-grid tactics game built with +[`@spacetimedb/grid`](../). The mounted Grid component owns grids, cell +state, and entity positions; the host module owns matches, participants, unit +statistics, turns, and combat rules. + +## What this demonstrates + +- Mounting Grid and Auth components in one host module. +- Authenticated match membership and caller-scoped subscriptions. +- Hex-grid pathfinding with `computePathImpl`. +- Movement and attack ranges with `cellsInRangeImpl`. +- Layering application rules over component-owned spatial state. +- Human-versus-human matchmaking and a solo match against the built-in Xeno + Garrison actor. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity. A fresh publish seeds it as the initial auth + administrator. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-grid-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open , create an account, and deploy a solo match. For the +human-versus-human flow, open a private/incognito window, create a second account, +and join the open match. + +`build:module:fresh` deletes and recreates only the local `spacetime-grid-example` +database. Use `pnpm run build:module` when existing matches must be preserved. + +## Use in your project + +This workspace tests the component source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/grid spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Add Auth or +Rate Limit only if your application needs them. The match, account, and tactics +rules are host-owned example code. + +## Configuration + +| Variable | Default | Purpose | +| ----------------------------------- | ------------------------ | ------------------------------------------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8793` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth proxy. | +| `STDB_SERVER` | `STDB_HTTP` | CLI target used during startup auth configuration. | +| `STDB_APP_DATABASE` | `spacetime-grid-example` | Published database name. | +| `AUTH_ISSUER_URL` / `AUTH_BASE_URL` | `http://localhost:8793` | JWT issuer and browser-visible auth origin. | +| `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | +| `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | +| OAuth client variables | empty | Enables Google or GitHub when both values for that provider are present. | + +The development server calls `set_auth_config` automatically on startup as the +logged-in CLI identity. Restart it after changing auth or OAuth values. + +## Gameplay and authority + +1. A signed-in user creates a human or solo match. +2. Participants and initial units are created by module reducers. +3. The active player selects a unit and requests a legal move or attack. +4. The host module checks membership, turn ownership, path/range, occupancy, and + unit state before changing Grid-owned positions or combat state. +5. Subscriptions update each participant's UI. + +The browser may calculate highlights for responsiveness, but reducer validation is +authoritative. A custom client must not be able to move an opponent's unit, cross +blocked cells, exceed movement range, attack outside range, or act out of turn. + +## Architecture and visibility + +```text +Browser -> /auth/* proxy -> mounted Auth HTTP handlers +Browser -> linked SpacetimeDB connection + -> my_matches / my_match_participants + -> match-scoped my_player_units / my_grid_entities / my_cell_states + +Host match rules -> mounted Grid tables and helpers +``` + +The browser first subscribes to caller-scoped match views. It creates a second, +match-filtered subscription only for the selected match. Public catalogs and the +open-match lobby are intentionally shared; private match state is restricted by +the linked authenticated user and participation checks. + +## Security and deployment boundaries + +- Match reducers derive the acting user from the linked auth session and never + accept a browser-provided owner as authority. +- A fresh publish seeds only the publisher as auth administrator. +- Passwords, OAuth secrets, signing keys, cookies, `.env`, and development tokens + must not be committed or logged. +- Solo actors are server-owned game actors, not privileged browser identities. +- The included Express process is for local development. Production needs TLS, + explicit binding, origin/host policy, durable signing keys, and supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test: + +1. Complete signup, reload-based session refresh, and logout. +2. Play a solo match through movement, attack, end-turn, and terminal match state. +3. Join a human match with a second account and verify realtime state in both + browsers. +4. Attempt out-of-turn, out-of-range, blocked, occupied, and opponent-unit actions + and confirm each rejection leaves state unchanged. +5. Confirm a third account cannot subscribe to or mutate a private match. + +## Troubleshooting + +- **The server exits at startup:** verify the database exists and the CLI identity + is its owner or an auth administrator. +- **No matches appear after login:** check that `link_connection` succeeded before + the caller-scoped subscriptions were created. +- **OAuth redirects incorrectly:** make `AUTH_ISSUER_URL` match the exact origin + registered with the provider. +- **The app and publish target disagree:** ensure all STDB endpoints refer to the + same server registered as `local`. + +## Important files + +- `spacetimedb/src/index.ts` - component mounts, auth integration, match schema, + scoped views, and game rules. +- `src/app.ts` - auth/session linking, subscriptions, and interaction bridge. +- `server.ts` - auth bootstrap, static serving, and same-origin proxy. +- `public/index.html` - tactics interface. +- `public/ui.js` - game rendering and interaction handling. +- `public/styles.css` - tactics presentation. diff --git a/spacetime-grid-ts/example/package.json b/spacetime-grid-ts/example/package.json new file mode 100644 index 00000000000..0ea447eb9f8 --- /dev/null +++ b/spacetime-grid-ts/example/package.json @@ -0,0 +1,31 @@ +{ + "name": "spacetime-grid-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "test:unit": "node scripts/test-hex-geometry.mjs", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/grid": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-grid-ts/example/public/assets/brand.svg b/spacetime-grid-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-grid-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-grid-ts/example/public/assets/logo.svg b/spacetime-grid-ts/example/public/assets/logo.svg new file mode 100644 index 00000000000..adaf36cbf9c --- /dev/null +++ b/spacetime-grid-ts/example/public/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/spacetime-grid-ts/example/public/hex-geometry.js b/spacetime-grid-ts/example/public/hex-geometry.js new file mode 100644 index 00000000000..c971a31ded7 --- /dev/null +++ b/spacetime-grid-ts/example/public/hex-geometry.js @@ -0,0 +1,111 @@ +export const HEX_SIZE = 26; +export const HEX_H = Math.sqrt(3) * HEX_SIZE; +export const COL_STEP = 1.5 * HEX_SIZE; +export const GRID_RADIUS = 5; +export const HEX_MIN_X = -HEX_SIZE; +export const HEX_MAX_X = COL_STEP * 2 * GRID_RADIUS + HEX_SIZE; +export const HEX_MIN_Y = HEX_H * (GRID_RADIUS / 2) - HEX_H / 2; +export const HEX_MAX_Y = + HEX_H * (2 * GRID_RADIUS + GRID_RADIUS / 2) + HEX_H / 2; +export const PAD = 12; +export const PAD_LEFT = -HEX_MIN_X + PAD; +export const PAD_TOP = -HEX_MIN_Y + PAD; + +export function cellKey(x, y) { + return `${x},${y}`; +} + +// Keep this formula aligned with the Grid component's server distance rule. +export function axialHexDistance(ax, ay, bx, by) { + return ( + (Math.abs(ax - bx) + Math.abs(ax + ay - bx - by) + Math.abs(ay - by)) / 2 + ); +} + +export function isInHexShape(q, r) { + const center = GRID_RADIUS; + return ( + (Math.abs(q - center) + + Math.abs(r - center) + + Math.abs(q + r - center * 2)) / + 2 <= + GRID_RADIUS + ); +} + +export function cellsWithinHexDistance(gridW, gridH, ox, oy, range) { + const cells = new Set(); + for (let r = 0; r < gridH; r++) { + for (let q = 0; q < gridW; q++) { + if (isInHexShape(q, r) && axialHexDistance(ox, oy, q, r) <= range) { + cells.add(cellKey(q, r)); + } + } + } + return cells; +} + +// Flat-top axial coordinates. +export function hexCenter(q, r) { + return { + cx: COL_STEP * q + PAD_LEFT, + cy: HEX_H * (q / 2 + r) + PAD_TOP, + }; +} + +export function hexCorners(cx, cy, size) { + const points = []; + for (let index = 0; index < 6; index++) { + const angle = (Math.PI / 3) * index; + points.push([cx + size * Math.cos(angle), cy + size * Math.sin(angle)]); + } + return points; +} + +export function pixelToHex(px, py, gridW, gridH) { + let closest = null; + let closestDistance = Infinity; + for (let y = 0; y < gridH; y++) { + for (let x = 0; x < gridW; x++) { + if (!isInHexShape(x, y)) continue; + const { cx, cy } = hexCenter(x, y); + const distance = (cx - px) ** 2 + (cy - py) ** 2; + if (distance < closestDistance) { + closestDistance = distance; + closest = { x, y }; + } + } + } + return closestDistance <= HEX_SIZE * HEX_SIZE ? closest : null; +} + +export function samplePathPixels(path, progress) { + if (path.length === 0) return { x: 0, y: 0 }; + if (path.length === 1 || progress <= 0) return { ...path[0] }; + if (progress >= 1) return { ...path[path.length - 1] }; + + const segmentLengths = []; + let totalLength = 0; + for (let index = 1; index < path.length; index++) { + const length = Math.hypot( + path[index].x - path[index - 1].x, + path[index].y - path[index - 1].y + ); + segmentLengths.push(length); + totalLength += length; + } + + let targetLength = progress * totalLength; + for (let index = 0; index < segmentLengths.length; index++) { + const segmentLength = segmentLengths[index]; + if (targetLength <= segmentLength) { + const ratio = segmentLength === 0 ? 0 : targetLength / segmentLength; + return { + x: path[index].x + (path[index + 1].x - path[index].x) * ratio, + y: path[index].y + (path[index + 1].y - path[index].y) * ratio, + }; + } + targetLength -= segmentLength; + } + return { ...path[path.length - 1] }; +} diff --git a/spacetime-grid-ts/example/public/index.html b/spacetime-grid-ts/example/public/index.html new file mode 100644 index 00000000000..dc4f2147d5a --- /dev/null +++ b/spacetime-grid-ts/example/public/index.html @@ -0,0 +1,262 @@ + + + + + + + SpacetimeDB Grid + + + + +
        +
        + +

        Welcome to Grid

        +

        Sign in to continue.

        + +
        + + +
        + +
        or
        + +
        + + +
        + +
        + + +
        + +

        + Forgot password? +

        +

        + Don't have an account? + Sign up +

        +
        +
        + + + + + +
        + +
        + +
        + + + + + diff --git a/spacetime-grid-ts/example/public/styles.css b/spacetime-grid-ts/example/public/styles.css new file mode 100644 index 00000000000..329da8b731e --- /dev/null +++ b/spacetime-grid-ts/example/public/styles.css @@ -0,0 +1,682 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=Source+Code+Pro:wght@400;500;600&display=swap'); + +:root { + --font-inter: 'Inter', sans-serif; + --font-ibm: 'IBM Plex Mono', monospace; + --font-mono: 'Source Code Pro', monospace; + + --color-green: #4cf490; + --color-green-20: #4cf49033; + --color-blue: #02befa; + --color-orange: #ff9e9e; + --color-yellow: #fbdc8e; + --color-red: #ff4c4c; + --color-white: #d7d8d9; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-n1); + background: var(--color-shade7); + overflow: hidden; +} + +/* ============================================================ + Auth panel shared with the agents example. Keep the shared markup + and styles synchronized across both examples. + ============================================================ */ +.auth-shell { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 50; + background: + radial-gradient( + ellipse 80% 50% at 50% 0%, + var(--color-green-20), + transparent 60% + ), + var(--color-shade7); +} +.auth-card { + width: 100%; + max-width: 380px; + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + padding: 28px; + display: flex; + flex-direction: column; + gap: 12px; +} +.auth-logo { + width: 56px; + height: auto; + margin: 0 auto 4px; + display: block; +} +.auth-card h1 { + font-family: var(--font-inter); + font-size: 18px; + font-weight: 700; + margin: 0; + text-align: center; + color: var(--color-n1); +} +.auth-sub { + font-family: var(--font-inter); + font-size: 13px; + color: var(--color-n4); + margin: 0 0 8px; + text-align: center; +} +.auth-oauth { + display: flex; + flex-direction: column; + gap: 8px; +} +.btn.oauth { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px 14px; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 500; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + color: var(--color-n1); + border-radius: var(--radius-sm); + cursor: pointer; +} +.btn.oauth:hover:not(:disabled) { + background: var(--color-shade4); + border-color: var(--color-n4); +} +.btn.oauth svg { + flex-shrink: 0; + width: 16px; + height: 16px; +} +.btn.block { + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} +.auth-divider { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0; + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.auth-divider::before, +.auth-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--color-shade4); +} +.auth-field { + display: flex; + flex-direction: column; + gap: 4px; +} +.auth-field label { + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-n4); +} +.auth-field input { + background: var(--color-shade6); + border: 1px solid var(--color-shade4); + color: var(--color-n1); + font-family: var(--font-inter); + font-size: 13px; + padding: 8px 10px; + border-radius: var(--radius-sm); + outline: none; +} +.auth-field input:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +.auth-foot { + margin: 0; + text-align: center; + font-family: var(--font-inter); + font-size: 12px; + color: var(--color-n4); +} +.auth-foot a { + color: var(--color-green); + cursor: pointer; + text-decoration: none; + font-weight: 600; +} +.auth-foot a:hover { + text-decoration: underline; +} +.auth-card .btn.primary.block { + margin-top: 4px; +} +/* Lock down sizing so the card renders identically across apps + regardless of their per-app global input/.btn rules. */ +.auth-card { + width: 380px; + gap: 12px; +} +.auth-card .auth-logo { + width: 56px; + height: 56px; +} +.auth-card h1 { + font-size: 18px; + line-height: 24px; +} +.auth-card .auth-sub { + font-size: 13px; + line-height: 18px; +} +.auth-card .auth-field input, +.auth-card .btn { + height: 40px; + box-sizing: border-box; + width: 100%; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; +} +.auth-card .auth-field input { + padding: 0 12px; +} +.auth-card .btn.oauth { + padding: 0 14px; +} +.auth-card .auth-field label { + line-height: 14px; +} +.auth-card .auth-foot { + font-size: 12px; + line-height: 18px; +} + +/* ============================================================ + Buttons (spacetime-web canonical) + ============================================================ */ +.btn { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 8px 16px; + border-radius: 4px; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; + border: 1px solid transparent; + background: var(--color-shade7); + color: var(--color-n2); + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s, + color 0.2s; +} +.btn:hover:not(:disabled) { + background: var(--color-shade4); +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); + color: var(--color-n8); +} +.btn.primary:disabled { + background-color: var(--color-n4); + border-color: var(--color-n4); +} +.btn.danger { + background: transparent; + color: var(--color-orange); + border-color: #5a2222; +} +.btn.danger:hover:not(:disabled) { + background: rgba(255, 158, 158, 0.08); + border-color: var(--color-orange); +} +.btn.small { + font-size: 12px; + padding: 4px 12px; + height: 26px; +} + +/* ============================================================ + App shell (signed-in view) + ============================================================ */ +.shell { + width: min(1440px, calc(100% - 32px)); + margin: 14px auto; + height: calc(100dvh - 28px); + display: flex; + flex-direction: column; + gap: 14px; +} + +.topnav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 9px 12px; + box-shadow: inset 0 1px 0 #26435166; + flex-shrink: 0; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; +} +.brand-wordmark { + display: block; + height: 28px; +} +.brand-sub { + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.topnav-actions { + display: flex; + align-items: center; + gap: 8px; +} +.user-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 10px 4px 4px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 11px; + color: #c4d0e0; +} +.user-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--color-blue); + color: var(--color-n8); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 11px; + text-transform: uppercase; +} + +/* ============================================================ + Lobby + ============================================================ */ +.lobby { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; +} +.panel { + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; +} +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.create-actions { + display: flex; + gap: 8px; +} +.mode-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + font-family: var(--font-mono); + background: rgba(132, 169, 255, 0.12); + color: #84a9ff; + border: 1px solid rgba(132, 169, 255, 0.3); +} +.panel-head h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--color-n1); +} +.panel-sub { + margin: 0; + font-size: 13px; + color: var(--color-n4); +} +.match-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.match-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade6); +} +.match-meta { + display: flex; + flex-direction: column; + gap: 2px; + font-family: var(--font-ibm); + font-size: 12px; +} +.match-meta .id { + color: var(--color-blue); +} +.match-meta .players { + color: var(--color-n3); +} +.match-status { + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid #2a4250; + color: #9cb1cb; +} +.match-status.waiting { + border-color: #5b5737; + color: var(--color-yellow); +} +.match-status.active { + border-color: #31684c; + color: var(--color-green); +} +.match-status.ended { + border-color: #4a3940; + color: var(--color-orange); +} +.lobby-empty { + text-align: center; + padding: 40px; + color: var(--color-n4); + font-style: italic; +} + +/* ============================================================ + Match view (canvas + sidebar) + ============================================================ */ +.match-view { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 1fr 320px; + gap: 14px; +} +.board-panel { + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + overflow: hidden; +} +.board-canvas-wrap { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; + background: var(--color-shade7); + border-radius: var(--radius-sm); +} +#board-canvas { + display: block; +} + +.info-panel { + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; +} +.turn-banner { + padding: 10px 12px; + border-radius: var(--radius-sm); + font-family: var(--font-ibm); + font-size: 12px; + text-align: center; + background: var(--color-shade5); + border: 1px solid var(--color-shade1); +} +.turn-banner.mine { + background: rgba(76, 244, 144, 0.08); + border-color: #31684c; + color: var(--color-green); +} +.turn-banner.theirs { + background: rgba(255, 158, 158, 0.05); + border-color: #4a3940; + color: var(--color-orange); +} +.turn-banner.waiting { + color: var(--color-yellow); + border-color: #5b5737; +} + +.selected-unit { + padding: 12px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius-sm); + background: var(--color-shade6); + display: flex; + flex-direction: column; + gap: 6px; +} +.selected-unit .row { + display: flex; + justify-content: space-between; + font-family: var(--font-ibm); + font-size: 12px; +} +.selected-unit .row .label { + color: var(--color-n4); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 10px; +} +.selected-unit .row .value { + color: var(--color-n1); +} +.hint { + font-size: 12px; + color: var(--color-n4); + font-style: italic; +} +.unit-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.unit-list .unit-pill { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 10px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius-sm); + font-family: var(--font-ibm); + font-size: 11px; +} +.unit-list .unit-pill.mine { + border-color: rgba(2, 190, 250, 0.4); +} +.unit-list .unit-pill.enemy { + border-color: rgba(255, 76, 76, 0.4); +} + +/* ============================================================ + Win/lose modal + ============================================================ */ +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: none; + align-items: center; + justify-content: center; + z-index: 100; +} +.backdrop.open { + display: flex; +} +.modal { + background: var(--color-shade5); + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + padding: 24px; + min-width: 320px; + text-align: center; + display: flex; + flex-direction: column; + gap: 12px; +} +.modal h2 { + margin: 0; + font-size: 22px; +} +.modal h2.win { + color: var(--color-green); +} +.modal h2.lose { + color: var(--color-orange); +} +.modal p { + margin: 0; + color: var(--color-n3); +} +.modal .actions { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 6px; +} + +/* ============================================================ + Toast + ============================================================ */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 10px 16px; + background: var(--color-shade5); + border: 1px solid var(--color-shade1); + border-radius: var(--radius-sm); + font-size: 13px; + opacity: 0; + transition: opacity 0.2s; + z-index: 200; + pointer-events: none; +} +.toast.show { + opacity: 1; +} +.toast.err { + border-color: #5a2222; + background: #2a1212; +} +.toast.ok { + border-color: #1f5a32; + background: #102a17; +} diff --git a/spacetime-grid-ts/example/public/ui.js b/spacetime-grid-ts/example/public/ui.js new file mode 100644 index 00000000000..18798b33465 --- /dev/null +++ b/spacetime-grid-ts/example/public/ui.js @@ -0,0 +1,1186 @@ +import { + HEX_MAX_X, + HEX_MAX_Y, + HEX_MIN_X, + HEX_MIN_Y, + HEX_SIZE, + PAD, + axialHexDistance, + cellKey, + cellsWithinHexDistance, + hexCenter, + hexCorners, + isInHexShape, + pixelToHex, + samplePathPixels, +} from './hex-geometry.js'; + +const $ = id => document.getElementById(id); +let toastTimer = null; +function toast(kind, msg) { + const el = $('toast'); + el.textContent = msg; + el.className = `toast ${kind} show`; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + el.classList.remove('show'); + }, 3000); +} + +// Authentication view +let authMode = 'login'; +function setAuthMode(m) { + authMode = m; + const title = $('auth-title'), + sub = $('auth-sub'), + submit = $('auth-submit'); + const togglePrompt = $('toggle-prompt'), + toggleLink = $('toggle-link'); + const forgotFoot = $('forgot-link').parentElement; + const nameField = $('auth-name-field'), + passField = $('auth-pass').closest('.auth-field'); + if (m === 'signup') { + title.textContent = 'Create an account'; + sub.textContent = 'Sign up to play.'; + submit.textContent = 'Create account'; + togglePrompt.textContent = 'Already have an account?'; + toggleLink.textContent = 'Sign in'; + forgotFoot.hidden = true; + nameField.hidden = false; + passField.hidden = false; + $('auth-pass').autocomplete = 'new-password'; + } else if (m === 'forgot') { + title.textContent = 'Reset password'; + sub.textContent = "Enter your email and we'll send a reset link."; + submit.textContent = 'Send reset link'; + togglePrompt.textContent = 'Remembered it?'; + toggleLink.textContent = 'Sign in'; + forgotFoot.hidden = true; + nameField.hidden = true; + passField.hidden = true; + } else { + title.textContent = 'Welcome to Grid'; + sub.textContent = 'Sign in to continue.'; + submit.textContent = 'Sign in'; + togglePrompt.textContent = "Don't have an account?"; + toggleLink.textContent = 'Sign up'; + forgotFoot.hidden = false; + nameField.hidden = true; + passField.hidden = false; + $('auth-pass').autocomplete = 'current-password'; + } +} +$('toggle-link').addEventListener('click', () => + setAuthMode(authMode === 'login' ? 'signup' : 'login') +); +$('forgot-link').addEventListener('click', () => setAuthMode('forgot')); +$('auth-form').addEventListener('submit', async e => { + e.preventDefault(); + if (!window.auth) return; + const email = $('auth-email').value.trim(); + const password = $('auth-pass').value; + $('auth-submit').disabled = true; + try { + if (authMode === 'signup') { + const name = $('auth-name').value.trim() || undefined; + await window.auth.signup({ email, password, name }); + } else if (authMode === 'forgot') { + await window.auth.forgotPassword(email); + toast('ok', 'Reset link sent (dev mailer logs to STDB console).'); + setAuthMode('login'); + } else { + await window.auth.login({ email, password }); + } + } catch (err) { + toast('err', err.message ?? String(err)); + } finally { + $('auth-submit').disabled = false; + } +}); +window.addEventListener('auth:server-config', e => { + const oauth = e.detail?.oauth || {}; + const google = $('oauth-google'); + const github = $('oauth-github'); + google.disabled = !oauth.google; + github.disabled = !oauth.github; + google.title = oauth.google + ? '' + : 'Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env'; + github.title = oauth.github + ? '' + : 'Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in .env'; + google.setAttribute('aria-disabled', String(!oauth.google)); + github.setAttribute('aria-disabled', String(!oauth.github)); +}); +$('oauth-google').addEventListener('click', () => { + if ($('oauth-google').disabled) { + toast('err', 'Google OAuth is not configured'); + return; + } + window.auth?.oauthStart('google'); +}); +$('oauth-github').addEventListener('click', () => { + if ($('oauth-github').disabled) { + toast('err', 'GitHub OAuth is not configured'); + return; + } + window.auth?.oauthStart('github'); +}); +$('btn-logout').addEventListener('click', () => window.auth?.logout()); + +// State and view routing +let state = null; +let selectedUnitId = null; +let reachableCache = null; // { entityId, cells: Set<"q,r"> } for Dijkstra reachability +let attackableCache = null; // Set<"q,r"> within the selected unit's attack range +// Active move animations: entityId → { pathPx: [(x,y)…], startMs, durationMs }. +// While present, drawBoard renders the unit at the interpolated pixel +// position while the data x/y already contains the destination. +const animatingUnits = new Map(); +const MS_PER_HEX = 110; // tune for snappiness +let rafScheduled = false; +function scheduleAnimFrame() { + const hasWork = () => + animatingUnits.size > 0 || + attackFlashes.length > 0 || + visualHp.size > 0 || + ghostUnits.size > 0 || + damageNumbers.length > 0; + if (rafScheduled || !hasWork()) return; + rafScheduled = true; + requestAnimationFrame(() => { + rafScheduled = false; + const now = performance.now(); + // Prune finished move animations. + for (const [id, a] of animatingUnits) { + if (now >= a.startMs + a.durationMs) animatingUnits.delete(id); + } + // Prune expired attack flashes. + for (let i = attackFlashes.length - 1; i >= 0; i--) { + if (now >= attackFlashes[i].startMs + attackFlashes[i].durationMs) { + attackFlashes.splice(i, 1); + } + } + // Prune expired damage numbers. + for (let i = damageNumbers.length - 1; i >= 0; i--) { + if (now >= damageNumbers[i].startMs + damageNumbers[i].durationMs) { + damageNumbers.splice(i, 1); + } + } + renderMatch(); + if (hasWork()) scheduleAnimFrame(); + }); +} +function showAuth() { + $('auth-shell').hidden = false; + $('shell').hidden = true; +} +function showShell() { + $('auth-shell').hidden = true; + $('shell').hidden = false; +} +function showLobby() { + $('lobby').hidden = false; + $('match-view').hidden = true; +} +function showMatchView() { + $('lobby').hidden = true; + $('match-view').hidden = false; +} + +window.addEventListener('grid:ready', () => { + /* initial render kicked by auth:state */ +}); + +window.addEventListener('grid:auth', e => { + const u = e.detail.user; + if (u) { + showShell(); + $('user-name').textContent = u.name || u.email; + $('user-avatar').textContent = (u.name || u.email) + .slice(0, 1) + .toUpperCase(); + } else { + showAuth(); + } +}); + +window.addEventListener('grid:state', e => { + state = e.detail; + renderAll(); +}); + +// Visual overrides applied while attack animations are playing. +// Keep the data-state HP/death hidden until the flash completes, +// so the player sees: move → pause → flash → HP drop / death. +const visualHp = new Map(); // entityId → preferred HP (overrides data) +const ghostUnits = new Map(); // entityId → { x, y, ownerUserId, typeId, currentHp }; rendered during removal animations +const attackFlashes = []; // [{ from, to, startMs, durationMs }] +const damageNumbers = []; // [{ x, y, dmg, killed, startMs, durationMs }] +const FLASH_MS = 220; +const DAMAGE_FLOAT_MS = 950; + +// Spawn a floating "-N" damage indicator at (px, py). Drifts up + fades. +function spawnDamageNumber(px, py, dmg, killed, atMs) { + damageNumbers.push({ + x: px, + y: py - HEX_SIZE * 0.4, + dmg, + killed, + startMs: atMs ?? performance.now(), + durationMs: DAMAGE_FLOAT_MS, + }); + scheduleAnimFrame(); +} + +// Snapshot a target's current visual state, push an attack flash + a +// floating damage number, then release the HP/ghost override after +// FLASH_MS so the data-state HP/death is shown only after the flash +// plays. Used by both player + AI attacks. +function flashAttack(attackerId, target, dmg, killed) { + visualHp.set(target.entityId, target.currentHp); + ghostUnits.set(target.entityId, { + entityId: target.entityId, + x: target.x, + y: target.y, + ownerUserId: target.ownerUserId, + typeId: target.typeId, + currentHp: target.currentHp, + }); + const startMs = performance.now(); + attackFlashes.push({ + attackerId, + targetX: target.x, + targetY: target.y, + startMs, + durationMs: FLASH_MS, + }); + // Damage number pops at the midpoint of the flash so it reads as + // "the hit landed, here's how much it cost you." + const { cx, cy } = hexCenter(target.x, target.y); + spawnDamageNumber(cx, cy, dmg, killed, startMs + FLASH_MS / 2); + scheduleAnimFrame(); + setTimeout(() => { + visualHp.delete(target.entityId); + ghostUnits.delete(target.entityId); + renderMatch(); + }, FLASH_MS); +} + +// Schedule each AI-turn event and release its visual override. The event +// list arrives in execution order. +window.addEventListener('grid:ai-events', e => { + const events = e.detail?.events ?? []; + let cursorMs = performance.now(); + const PAUSE_MS = 80; + + for (const ev of events) { + let moveEndMs = cursorMs; + + // 1. Move animation (if any). + if (Array.isArray(ev.movePath) && ev.movePath.length >= 2) { + const pathPx = ev.movePath.map(c => { + const { cx, cy } = hexCenter(c.x, c.y); + return { x: cx, y: cy }; + }); + const hops = pathPx.length - 1; + const durationMs = Math.max(180, hops * MS_PER_HEX); + animatingUnits.set(ev.entityId, { + pathPx, + startMs: cursorMs, + durationMs, + }); + moveEndMs = cursorMs + durationMs; + cursorMs = moveEndMs + PAUSE_MS; + } + + // 2. Attack (if any). Preserve the target's pre-attack visual until + // the flash fires; if killed, ghost-render the target so it stays + // on screen even though state has already deleted it. + if (ev.attack) { + const a = ev.attack; + // Lock the target's visible HP to its pre-attack value until the flash. + visualHp.set(a.targetId, a.targetPreHp); + // Render a ghost for a defeated target during its removal animation. + if (a.killed) { + ghostUnits.set(a.targetId, { + entityId: a.targetId, + x: a.targetX, + y: a.targetY, + ownerUserId: a.targetOwner, + typeId: a.targetTypeId, + currentHp: a.targetPreHp, + }); + } + const attackStartMs = cursorMs; + // Schedule the flash + release of the visual overrides. + attackFlashes.push({ + attackerId: ev.entityId, + targetX: a.targetX, + targetY: a.targetY, + startMs: attackStartMs, + durationMs: FLASH_MS, + }); + // Floating "-N" damage number, scheduled to pop mid-flash. + const { cx, cy } = hexCenter(a.targetX, a.targetY); + spawnDamageNumber( + cx, + cy, + a.damage, + a.killed, + attackStartMs + FLASH_MS / 2 + ); + // When the flash fires, release HP override + ghost. + setTimeout( + () => { + visualHp.delete(a.targetId); + ghostUnits.delete(a.targetId); + renderMatch(); + }, + attackStartMs - performance.now() + FLASH_MS + ); + cursorMs += FLASH_MS + PAUSE_MS; + } + } + + if ( + animatingUnits.size > 0 || + attackFlashes.length > 0 || + ghostUnits.size > 0 + ) { + renderMatch(); + scheduleAnimFrame(); + } +}); + +// Default to auth view until grid:state arrives. +showAuth(); + +// Rendering +function renderAll() { + if (!state) return; + renderLobby(); + if (state.activeMatchId !== null && state.activeMatch) { + showMatchView(); + renderMatch(); + } else { + showLobby(); + } +} + +// Tag → lowercase string for CSS class names and display text. +// (Enum tags from the bindings are PascalCase: 'Waiting', 'Active', etc.) +function statusKey(s) { + return s?.tag ? s.tag.toLowerCase() : 'unknown'; +} + +// Resolve seats from match_participant rows. +function seatsForMatch(matchId) { + const seats = {}; + for (const p of state.participants ?? []) { + if (p.matchId === matchId) seats[p.seatIdx] = p.userId; + } + return seats; +} +function actorById(id) { + return id ? (state.actors ?? []).find(a => a.actorId === id) : null; +} +function displayName(actor, fallbackId) { + if (!actor) return fallbackId ? fallbackId.slice(0, 8) : 'Unknown'; + return actor.name || actor.actorId.slice(0, 8); +} + +function textSpan(className, text) { + const span = document.createElement('span'); + span.className = className; + span.textContent = text; + return span; +} + +function hint(text) { + const div = document.createElement('div'); + div.className = 'hint'; + div.textContent = text; + return div; +} + +function infoRow(label, value) { + const row = document.createElement('div'); + row.className = 'row'; + row.appendChild(textSpan('label', label)); + row.appendChild(textSpan('value', value)); + return row; +} + +function renderLobby() { + const list = $('match-list'); + const my = state.myUserId; + const myMatches = state.matches ?? []; + const openOther = (state.openMatches ?? []).filter( + o => !myMatches.some(m => m.matchId === o.matchId) + ); + if (myMatches.length === 0 && openOther.length === 0) { + const empty = document.createElement('div'); + empty.className = 'lobby-empty'; + empty.textContent = 'No missions in this sector. Deploy one to begin.'; + list.replaceChildren(empty); + return; + } + list.replaceChildren(); + + // Open matches anyone can join. + for (const o of openOther) { + const row = document.createElement('div'); + row.className = 'match-row'; + const meta = document.createElement('div'); + meta.className = 'match-meta'; + const hostName = displayName(actorById(o.hostUserId), o.hostUserId); + meta.appendChild(textSpan('id', `#${o.matchId}`)); + meta.appendChild(textSpan('players', `${hostName} waiting for opponent`)); + const status = document.createElement('span'); + status.className = 'match-status waiting'; + status.textContent = 'waiting'; + const actions = document.createElement('div'); + actions.style.display = 'flex'; + actions.style.gap = '8px'; + actions.style.alignItems = 'center'; + actions.appendChild(status); + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = 'Join'; + btn.addEventListener('click', async () => { + try { + await window.grid.joinMatch(o.matchId); + window.grid.setActiveMatch(o.matchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } + }); + actions.appendChild(btn); + row.appendChild(meta); + row.appendChild(actions); + list.appendChild(row); + } + + for (const m of myMatches) { + const row = document.createElement('div'); + row.className = 'match-row'; + const meta = document.createElement('div'); + meta.className = 'match-meta'; + const seats = seatsForMatch(m.matchId); + const hostUid = seats[0]; + const oppUid = seats[1]; + const isVsAi = oppUid === window.grid.AI_BOT_USER_ID; + const hostName = displayName(actorById(hostUid), hostUid); + const oppName = isVsAi + ? 'Alien Hive' + : displayName(actorById(oppUid), oppUid); + meta.appendChild(textSpan('id', `#${m.matchId}`)); + const players = textSpan('players', `${hostName} vs ${oppName}`); + if (isVsAi) { + players.append(' '); + players.appendChild(textSpan('mode-badge ai', 'SOLO')); + } + meta.appendChild(players); + const status = document.createElement('span'); + status.className = `match-status ${statusKey(m.status)}`; + status.textContent = statusKey(m.status); + const actions = document.createElement('div'); + actions.style.display = 'flex'; + actions.style.gap = '8px'; + actions.style.alignItems = 'center'; + actions.appendChild(status); + + const inMatch = Object.values(seats).includes(my); + if (m.status.tag === 'Waiting' && !inMatch) { + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = 'Join'; + btn.addEventListener('click', async () => { + try { + await window.grid.joinMatch(m.matchId); + window.grid.setActiveMatch(m.matchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } + }); + actions.appendChild(btn); + } else if (m.status.tag === 'Active' && inMatch) { + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = 'Resume'; + btn.addEventListener('click', () => + window.grid.setActiveMatch(m.matchId) + ); + actions.appendChild(btn); + } else if (m.status.tag === 'Waiting' && inMatch) { + const btn = document.createElement('button'); + btn.className = 'btn small'; + btn.textContent = 'View'; + btn.addEventListener('click', () => + window.grid.setActiveMatch(m.matchId) + ); + actions.appendChild(btn); + } else { + const btn = document.createElement('button'); + btn.className = 'btn small'; + btn.textContent = 'Spectate'; + btn.addEventListener('click', () => + window.grid.setActiveMatch(m.matchId) + ); + actions.appendChild(btn); + } + row.appendChild(meta); + row.appendChild(actions); + list.appendChild(row); + } +} + +function renderMatch() { + const m = state.activeMatch; + const grid = state.activeGrid; + if (!m || !grid) return; + const my = state.myUserId; + const seats = seatsForMatch(m.matchId); + const mySeatIdx = Object.entries(seats).find(([, uid]) => uid === my)?.[0]; + const isHost = mySeatIdx === '0'; + const myTurn = + m.status.tag === 'Active' && + mySeatIdx !== undefined && + Number(mySeatIdx) === m.currentSeatIdx; + + const isVsAi = seats[1] === window.grid.AI_BOT_USER_ID; + $('match-title').textContent = + `Sector ${m.matchId}${isVsAi ? ' · solo' : ''}`; + $('match-subtitle').textContent = `, turn ${m.turnNumber}`; + + const banner = $('turn-banner'); + if (m.status.tag === 'Waiting') { + banner.className = 'turn-banner waiting'; + banner.textContent = isHost + ? 'Waiting for an opponent to join…' + : 'Match is waiting for a host'; + } else if (m.status.tag === 'Ended') { + banner.className = 'turn-banner'; + banner.textContent = + m.winnerUserId === my ? 'You won this match.' : 'Match ended.'; + } else if (myTurn) { + banner.className = 'turn-banner mine'; + banner.textContent = 'Your turn'; + } else { + banner.className = 'turn-banner theirs'; + banner.textContent = isVsAi ? 'Aliens advancing…' : "Opponent's turn"; + } + + $('btn-end-turn').disabled = !myTurn; + + // Sidebar unit lists + const my0 = []; + const en = []; + for (const u of state.units) { + const type = state.unitTypes.find(t => t.typeId === u.typeId); + const entity = state.entities.find(e => e.id === u.entityId); + const item = { u, type, entity }; + (u.ownerUserId === my ? my0 : en).push(item); + } + function pill(item) { + const div = document.createElement('div'); + div.className = `unit-pill ${item.u.ownerUserId === my ? 'mine' : 'enemy'}`; + const left = document.createElement('span'); + left.textContent = `${item.type?.glyph ?? '?'} ${item.type?.name ?? item.u.typeId} (${item.entity?.x},${item.entity?.y})`; + const right = document.createElement('span'); + right.textContent = `${item.u.currentHp}/${item.type?.hp ?? '?'} HP`; + div.appendChild(left); + div.appendChild(right); + return div; + } + const myList = $('my-units'); + const enList = $('enemy-units'); + myList.replaceChildren(); + enList.replaceChildren(); + for (const x of my0) myList.appendChild(pill(x)); + for (const x of en) enList.appendChild(pill(x)); + if (my0.length === 0) myList.appendChild(hint('no units')); + if (en.length === 0) enList.appendChild(hint('no units')); + + // Selected-unit info + const selUnit = + selectedUnitId !== null + ? state.units.find(u => u.entityId === selectedUnitId) + : null; + const selEntity = selUnit + ? state.entities.find(e => e.id === selUnit.entityId) + : null; + const selType = selUnit + ? state.unitTypes.find(t => t.typeId === selUnit.typeId) + : null; + const sel = $('selected-unit-info'); + if (selUnit && selType && selEntity) { + const card = document.createElement('div'); + card.className = 'selected-unit'; + card.appendChild(infoRow('Unit', `${selType.glyph} ${selType.name}`)); + card.appendChild(infoRow('Position', `(${selEntity.x}, ${selEntity.y})`)); + card.appendChild(infoRow('HP', `${selUnit.currentHp} / ${selType.hp}`)); + card.appendChild( + infoRow( + 'Movement', + `${selType.movement} (used: ${selUnit.hasMoved ? 'yes' : 'no'})` + ) + ); + card.appendChild( + infoRow( + 'Attack', + `${selType.attackDmg} dmg · range ${selType.attackRange} (used: ${selUnit.hasAttacked ? 'yes' : 'no'})` + ) + ); + sel.replaceChildren(card); + } else { + sel.replaceChildren(hint('click one of your units to select')); + } + + // Canvas + drawBoard(grid, state.entities, state.cells, state.units, m, my); + + // End-modal + if (m.status.tag === 'Ended') { + $('end-title').textContent = m.winnerUserId === my ? 'Victory!' : 'Defeat'; + $('end-title').className = m.winnerUserId === my ? 'win' : 'lose'; + const winner = actorById(m.winnerUserId); + const winnerName = winner + ? winner.name || m.winnerUserId.slice(0, 8) + : 'someone'; + $('end-body').textContent = + m.winnerUserId === my + ? `You secured the sector.` + : `${winnerName} overran the outpost.`; + $('end-backdrop').classList.add('open'); + } else { + $('end-backdrop').classList.remove('open'); + } +} + +$('end-back-to-lobby').addEventListener('click', () => { + $('end-backdrop').classList.remove('open'); + window.grid.setActiveMatch(null); +}); +$('btn-leave-match').addEventListener('click', () => { + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + window.grid.setActiveMatch(null); +}); +$('btn-create-match').addEventListener('click', async () => { + try { + const r = await window.grid.createMatch(false); + window.grid.setActiveMatch(r.matchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); +$('btn-create-match-ai').addEventListener('click', async () => { + try { + const r = await window.grid.createMatch(true); + window.grid.setActiveMatch(r.matchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); +$('btn-end-turn').addEventListener('click', async () => { + if (!state?.activeMatchId) return; + try { + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + await window.grid.endTurn(state.activeMatchId); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); + +// Canvas drawing +function drawBoard(grid, entities, cells, units, match, myUserId) { + const canvas = $('board-canvas'); + // Canvas only needs to hold the hex-shape bounding box (computed at + // module load from HEX_RADIUS). Out-of-hex axial cells aren't drawn. + const W = Math.ceil(HEX_MAX_X - HEX_MIN_X + 2 * PAD); + const H = Math.ceil(HEX_MAX_Y - HEX_MIN_Y + 2 * PAD); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + // Match the surrounding panel with a solid STDB shade7 background. + ctx.fillStyle = + getComputedStyle(document.body).getPropertyValue('--color-shade7').trim() || + '#0b1114'; + ctx.fillRect(0, 0, W, H); + // A deterministic pale-blue starfield supports the alien-planet view. + const starSeed = (grid.width * 31 + grid.height) | 0; + let s = starSeed; + for (let i = 0; i < 60; i++) { + s = (s * 1664525 + 1013904223) >>> 0; + const sx = ((s % 1000) / 1000) * W; + s = (s * 1664525 + 1013904223) >>> 0; + const sy = ((s % 1000) / 1000) * H; + s = (s * 1664525 + 1013904223) >>> 0; + const a = 0.12 + ((s % 1000) / 1000) * 0.25; + ctx.fillStyle = `rgba(160, 180, 200, ${a})`; + ctx.fillRect(sx, sy, 1, 1); + } + + const cellMap = new Map(cells.map(c => [cellKey(c.x, c.y), c])); + const reachable = reachableCache?.cells ?? null; + + // 1. Draw all hex cells (STDB-palette only) + // regolith = default dark teal plain (shade5/shade4) + // crater = burned, impassable obstacle (shade7 + dim red edge) + // void = outside the hex play area and omitted from rendering + for (let y = 0; y < grid.height; y++) { + for (let x = 0; x < grid.width; x++) { + if (!isInHexShape(x, y)) continue; + const { cx, cy } = hexCenter(x, y); + const corners = hexCorners(cx, cy, HEX_SIZE - 1); + const cell = cellMap.get(cellKey(x, y)); + let fill = '#0e161a'; // shade6 regolith (default) + let stroke = '#162d38'; // shade1 + if (cell && cell.terrain === 'crater') { + fill = '#080c0e'; + stroke = '#3a1a22'; + } + const isReachable = reachable && reachable.has(cellKey(x, y)); + const isAttackable = + attackableCache && attackableCache.has(cellKey(x, y)); + ctx.beginPath(); + ctx.moveTo(corners[0][0], corners[0][1]); + for (let i = 1; i < 6; i++) ctx.lineTo(corners[i][0], corners[i][1]); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + if (isAttackable) { + // STDB red at 28% marks hostile movement range. + ctx.fillStyle = 'rgba(255, 76, 76, 0.28)'; + ctx.fill(); + ctx.strokeStyle = '#ff4c4c'; + ctx.lineWidth = 1.5; + } else if (isReachable) { + // STDB blue at 20% marks friendly movement range. + ctx.fillStyle = 'rgba(2, 190, 250, 0.20)'; + ctx.fill(); + ctx.strokeStyle = '#02befa'; + ctx.lineWidth = 1.5; + } else { + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + } + ctx.stroke(); + } + } + + // 2. Draw units (data state + ghost-rendered killed units pending flash). + // The helper draws live units and ghosts with identical logic. + const drawUnit = (u, posX, posY, hpForBar) => { + const type = state.unitTypes.find(t => t.typeId === u.typeId); + const isMine = u.ownerUserId === myUserId; + // Use STDB blue for the player's landing party and green for the xeno hive. + const color = isMine ? '#02befa' : '#4cf490'; + ctx.beginPath(); + ctx.arc(posX, posY, HEX_SIZE * 0.55, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.strokeStyle = u.entityId === selectedUnitId ? '#fbdc8e' : '#000a'; + ctx.lineWidth = u.entityId === selectedUnitId ? 3 : 2; + ctx.stroke(); + // Glyph + ctx.fillStyle = '#060606'; + ctx.font = 'bold 14px "Source Code Pro", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(type?.glyph ?? '?', posX, posY); + // HP bar + const hpPct = type ? hpForBar / type.hp : 0; + const barW = HEX_SIZE * 0.9; + ctx.fillStyle = '#000a'; + ctx.fillRect(posX - barW / 2, posY + HEX_SIZE * 0.6, barW, 4); + ctx.fillStyle = + hpPct > 0.5 ? '#4cf490' : hpPct > 0.25 ? '#fbdc8e' : '#ff4c4c'; + ctx.fillRect(posX - barW / 2, posY + HEX_SIZE * 0.6, barW * hpPct, 4); + // Greyed out if hasMoved + hasAttacked (turn done) + if (isMine && u.hasMoved && u.hasAttacked) { + ctx.fillStyle = 'rgba(0,0,0,0.4)'; + ctx.beginPath(); + ctx.arc(posX, posY, HEX_SIZE * 0.55, 0, Math.PI * 2); + ctx.fill(); + } + }; + + for (const u of units) { + // Skip if this unit is being ghost-rendered (its data state may + // be stale during the animation; the ghost below supplies the visual). + if (ghostUnits.has(u.entityId)) continue; + const ent = entities.find(e => e.id === u.entityId); + if (!ent) continue; + // Use the interpolated pixel position while a unit is moving. Its + // data x/y already contains the destination. + let cx, cy; + const anim = animatingUnits.get(u.entityId); + if (anim) { + const t = Math.min( + 1, + (performance.now() - anim.startMs) / anim.durationMs + ); + const p = samplePathPixels(anim.pathPx, t); + cx = p.x; + cy = p.y; + } else { + const c = hexCenter(ent.x, ent.y); + cx = c.cx; + cy = c.cy; + } + // Use visual HP override if pending attack hasn't fired yet. + const hp = visualHp.has(u.entityId) + ? visualHp.get(u.entityId) + : u.currentHp; + drawUnit(u, cx, cy, hp); + } + + // 3. Ghost-render defeated AI targets until their attack flash runs. + for (const g of ghostUnits.values()) { + const { cx, cy } = hexCenter(g.x, g.y); + drawUnit( + { + entityId: g.entityId, + ownerUserId: g.ownerUserId, + typeId: g.typeId, + hasMoved: true, + hasAttacked: true, + }, + cx, + cy, + visualHp.has(g.entityId) ? visualHp.get(g.entityId) : g.currentHp + ); + } + + // 4. Draw a bright red attack beam from attacker to target. + for (const f of attackFlashes) { + const now = performance.now(); + const t = Math.min(1, Math.max(0, (now - f.startMs) / f.durationMs)); + if (t <= 0 || t >= 1) continue; + // Attacker pixel position: animated if mid-move, else data. + let fx, fy; + const aAnim = animatingUnits.get(f.attackerId); + if (aAnim) { + const at = Math.min(1, (now - aAnim.startMs) / aAnim.durationMs); + const p = samplePathPixels(aAnim.pathPx, at); + fx = p.x; + fy = p.y; + } else { + const aEnt = entities.find(e => e.id === f.attackerId); + if (!aEnt) continue; + const p = hexCenter(aEnt.x, aEnt.y); + fx = p.cx; + fy = p.cy; + } + const tEnd = hexCenter(f.targetX, f.targetY); + // Pulse: stroke fades out across the flash duration. + const alpha = 0.9 * (1 - t); + ctx.strokeStyle = `rgba(255, 76, 76, ${alpha})`; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(fx, fy); + ctx.lineTo(tEnd.cx, tEnd.cy); + ctx.stroke(); + // Impact ring at target. + ctx.strokeStyle = `rgba(255, 156, 61, ${alpha})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(tEnd.cx, tEnd.cy, HEX_SIZE * (0.4 + t * 0.6), 0, Math.PI * 2); + ctx.stroke(); + } + + // 5. Draw floating damage numbers with upward drift and fade above the + // flash and impact ring. + const nowMs = performance.now(); + for (const d of damageNumbers) { + const t = (nowMs - d.startMs) / d.durationMs; + if (t < 0 || t > 1) continue; + // Fade in 0..0.15, hold to 0.7, fade out to 1.0. + let alpha; + if (t < 0.15) alpha = t / 0.15; + else if (t > 0.7) alpha = (1 - t) / 0.3; + else alpha = 1; + alpha = Math.max(0, Math.min(1, alpha)); + // Ease-out drift upward. + const drift = HEX_SIZE * 1.1 * (1 - Math.pow(1 - t, 2)); + const px = d.x; + const py = d.y - drift; + const text = d.killed ? `−${d.dmg} KO` : `−${d.dmg}`; + ctx.font = d.killed + ? 'bold 18px "Source Code Pro", monospace' + : 'bold 15px "Source Code Pro", monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + // Dark stroke first so the number stays readable over any hex. + ctx.strokeStyle = `rgba(0, 0, 0, ${alpha * 0.85})`; + ctx.lineWidth = 3; + ctx.strokeText(text, px, py); + ctx.fillStyle = d.killed + ? `rgba(255, 76, 76, ${alpha})` + : `rgba(255, 156, 61, ${alpha})`; + ctx.fillText(text, px, py); + } +} + +// Click handlers +$('board-canvas').addEventListener('click', async e => { + if (!state?.activeMatch || !state.activeGrid) return; + const m = state.activeMatch; + const grid = state.activeGrid; + const seats = seatsForMatch(m.matchId); + const myTurn = + m.status.tag === 'Active' && seats[m.currentSeatIdx] === state.myUserId; + if (!myTurn) return; + + const rect = e.target.getBoundingClientRect(); + const px = e.clientX - rect.left; + const py = e.clientY - rect.top; + const hex = pixelToHex(px, py, grid.width, grid.height); + if (!hex) return; + + const entityAtHex = state.entities.find( + e2 => e2.x === hex.x && e2.y === hex.y + ); + const unitAtHex = entityAtHex + ? state.units.find(u => u.entityId === entityAtHex.id) + : null; + + // Click on my own unit -> select + compute movement + INFLUENCE overlays. + // Influence is the union of cells within attackRange of every + // movement-reachable cell, including the origin. + if (unitAtHex && unitAtHex.ownerUserId === state.myUserId) { + selectedUnitId = unitAtHex.entityId; + const type = state.unitTypes.find(t => t.typeId === unitAtHex.typeId); + // Reachable cells from server (Dijkstra, honors terrain + entity blocking). + let reachCells = [{ x: hex.x, y: hex.y, cost: 0 }]; + if (type && !unitAtHex.hasMoved) { + try { + const r = await window.grid.getCellsInRange( + grid.id, + hex.x, + hex.y, + type.movement + ); + reachCells = r; + } catch { + reachCells = [{ x: hex.x, y: hex.y, cost: 0 }]; + } + } + reachableCache = { + entityId: unitAtHex.entityId, + cells: new Set(reachCells.map(c => cellKey(c.x, c.y))), + }; + // Influence range = "movement zone with attack range tacked on the + // outside". For each reachable cell, fan out by attackRange. Strip + // the cells that are already in the movement zone so cyan shows the + // movement halo and red shows only the OUTER attack ring. + if (type && !unitAtHex.hasAttacked) { + const influence = new Set(); + for (const r of reachCells) { + const ring = cellsWithinHexDistance( + grid.width, + grid.height, + r.x, + r.y, + type.attackRange + ); + for (const k of ring) influence.add(k); + } + for (const k of reachableCache.cells) influence.delete(k); + attackableCache = influence; + } else { + attackableCache = null; + } + renderMatch(); + return; + } + + // Click on enemy unit while one of mine is selected -> auto-move + attack + if (unitAtHex && selectedUnitId !== null) { + const attacker = state.units.find(u => u.entityId === selectedUnitId); + const attackerEnt = attacker + ? state.entities.find(e => e.id === selectedUnitId) + : null; + const type = attacker + ? state.unitTypes.find(t => t.typeId === attacker.typeId) + : null; + if (!attacker || !attackerEnt || !type) return; + if (attacker.hasAttacked) { + toast('err', 'this unit has already attacked'); + return; + } + + const targetId = unitAtHex.entityId; + const attackerId = selectedUnitId; + const targetX = hex.x, + targetY = hex.y; + + // Attack from the current position when the target is in range. + const fromHere = axialHexDistance( + attackerEnt.x, + attackerEnt.y, + targetX, + targetY + ); + if (fromHere <= type.attackRange) { + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + // Snapshot the target so its HP/death is held until the flash fires. + const targetEnt = state.entities.find(en => en.id === targetId); + if (targetEnt) { + const dmg = type.attackDmg; + const killed = unitAtHex.currentHp - dmg <= 0; + flashAttack( + attackerId, + { + entityId: targetId, + x: targetEnt.x, + y: targetEnt.y, + ownerUserId: unitAtHex.ownerUserId, + typeId: unitAtHex.typeId, + currentHp: unitAtHex.currentHp, + }, + dmg, + killed + ); + } + renderMatch(); + try { + await window.grid.attackUnit(attackerId, targetId); + } catch (err) { + toast('err', err.message ?? String(err)); + } + return; + } + + // Need to move first. Pick the CHEAPEST reachable cell that puts the + // target within attackRange. Has to be one cellsInRange returned. + if ( + attacker.hasMoved || + !reachableCache || + reachableCache.entityId !== attackerId + ) { + toast('err', 'target out of range'); + return; + } + let bestStep = null; + for (const k of reachableCache.cells) { + const [sx, sy] = k.split(',').map(Number); + if (axialHexDistance(sx, sy, targetX, targetY) <= type.attackRange) { + if ( + bestStep === null || + axialHexDistance(attackerEnt.x, attackerEnt.y, sx, sy) < + axialHexDistance( + attackerEnt.x, + attackerEnt.y, + bestStep.x, + bestStep.y + ) + ) { + bestStep = { x: sx, y: sy }; + } + } + } + if (!bestStep) { + toast('err', 'target out of range'); + return; + } + + // Drop highlights immediately. Then move (animated), then attack. + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + renderMatch(); + try { + const { path } = await window.grid.moveUnit( + attackerId, + bestStep.x, + bestStep.y + ); + const pathPx = path.map(c => { + const { cx, cy } = hexCenter(c.x, c.y); + return { x: cx, y: cy }; + }); + if (pathPx.length >= 2) { + const hops = pathPx.length - 1; + const durationMs = Math.max(180, hops * MS_PER_HEX); + animatingUnits.set(attackerId, { + pathPx, + startMs: performance.now(), + durationMs, + }); + scheduleAnimFrame(); + // Hold the attack until the move animation completes so the unit + // is visibly adjacent before the HP drop on the target. + await new Promise(r => setTimeout(r, durationMs)); + } + // Snapshot target's current visual state before the RPC, then + // flash + release after FLASH_MS. Re-fetch from state in case + // anything changed during the move animation. + const targetEnt = state.entities.find(en => en.id === targetId); + const targetUnit = state.units.find(u => u.entityId === targetId); + if (targetEnt && targetUnit) { + const dmg = type.attackDmg; + const killed = targetUnit.currentHp - dmg <= 0; + flashAttack( + attackerId, + { + entityId: targetId, + x: targetEnt.x, + y: targetEnt.y, + ownerUserId: targetUnit.ownerUserId, + typeId: targetUnit.typeId, + currentHp: targetUnit.currentHp, + }, + dmg, + killed + ); + } + await window.grid.attackUnit(attackerId, targetId); + } catch (err) { + toast('err', err.message ?? String(err)); + } + return; + } + + // Click empty cell while a unit is selected + cell is reachable -> move + if ( + selectedUnitId !== null && + reachableCache?.entityId === selectedUnitId && + reachableCache.cells.has(cellKey(hex.x, hex.y)) + ) { + const movingId = selectedUnitId; + // Drop highlights immediately so the player sees the action commit. + selectedUnitId = null; + reachableCache = null; + attackableCache = null; + renderMatch(); + try { + const { path } = await window.grid.moveUnit(movingId, hex.x, hex.y); + // Convert the axial path into pixel centers and lerp through them. + const pathPx = path.map(c => { + const { cx, cy } = hexCenter(c.x, c.y); + return { x: cx, y: cy }; + }); + // path includes the origin cell; need >= 2 points to animate. + if (pathPx.length >= 2) { + const hops = pathPx.length - 1; + animatingUnits.set(movingId, { + pathPx, + startMs: performance.now(), + durationMs: Math.max(180, hops * MS_PER_HEX), + }); + scheduleAnimFrame(); + } + } catch (err) { + toast('err', err.message ?? String(err)); + } + } +}); diff --git a/spacetime-grid-ts/example/scripts/test-hex-geometry.mjs b/spacetime-grid-ts/example/scripts/test-hex-geometry.mjs new file mode 100644 index 00000000000..b8ddf646ed9 --- /dev/null +++ b/spacetime-grid-ts/example/scripts/test-hex-geometry.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { + GRID_RADIUS, + HEX_SIZE, + axialHexDistance, + cellKey, + cellsWithinHexDistance, + hexCenter, + hexCorners, + isInHexShape, + pixelToHex, + samplePathPixels, +} from '../public/hex-geometry.js'; + +assert.equal(axialHexDistance(0, 0, 0, 0), 0); +assert.equal(axialHexDistance(0, 0, 2, -1), 2); +assert.equal(axialHexDistance(2, -1, 0, 0), 2); + +assert.equal(isInHexShape(GRID_RADIUS, GRID_RADIUS), true); +assert.equal(isInHexShape(GRID_RADIUS, 0), true); +assert.equal(isInHexShape(-1, GRID_RADIUS), false); +assert.equal(isInHexShape(GRID_RADIUS * 2, GRID_RADIUS * 2), false); + +const nearby = cellsWithinHexDistance( + GRID_RADIUS * 2 + 1, + GRID_RADIUS * 2 + 1, + GRID_RADIUS, + GRID_RADIUS, + 1 +); +assert.equal(nearby.size, 7); +assert.equal(nearby.has(cellKey(GRID_RADIUS + 1, GRID_RADIUS)), true); + +const center = hexCenter(GRID_RADIUS, GRID_RADIUS); +assert.deepEqual( + pixelToHex(center.cx, center.cy, GRID_RADIUS * 2 + 1, GRID_RADIUS * 2 + 1), + { x: GRID_RADIUS, y: GRID_RADIUS } +); +assert.equal(pixelToHex(-HEX_SIZE * 10, -HEX_SIZE * 10, 11, 11), null); + +const corners = hexCorners(center.cx, center.cy, HEX_SIZE); +assert.equal(corners.length, 6); +for (const [x, y] of corners) { + assert.ok( + Math.abs(Math.hypot(x - center.cx, y - center.cy) - HEX_SIZE) < 1e-9 + ); +} + +const path = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 30 }, +]; +assert.deepEqual(samplePathPixels([], 0.5), { x: 0, y: 0 }); +assert.deepEqual(samplePathPixels(path, 0), path[0]); +assert.deepEqual(samplePathPixels(path, 0.25), { x: 10, y: 0 }); +assert.deepEqual(samplePathPixels(path, 0.5), { x: 10, y: 10 }); +assert.deepEqual(samplePathPixels(path, 1), path[2]); + +console.log('grid hex geometry tests passed'); diff --git a/spacetime-grid-ts/example/server.ts b/spacetime-grid-ts/example/server.ts new file mode 100644 index 00000000000..21a3929b229 --- /dev/null +++ b/spacetime-grid-ts/example/server.ts @@ -0,0 +1,196 @@ +// Express + static. Browser connects to STDB directly via WebSocket; this +// server serves the SPA, /api/config, and proxies /auth/* to the STDB +// module's HTTP handlers so auth cookies stay same-origin. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared/root env supplies secrets; example-local env supplies app defaults. +// Blank placeholders in the example .env should not erase shared secrets. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8793', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-grid-example'; +const AUTH_ISSUER_URL = + process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; +const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME ?? 'stdb_auth'; +const AUTH_SESSION_TTL_SECONDS = Number.parseInt( + process.env.AUTH_SESSION_TTL_SECONDS ?? `${60 * 60 * 24 * 7}`, + 10 +); +if ( + !Number.isInteger(AUTH_SESSION_TTL_SECONDS) || + AUTH_SESSION_TTL_SECONDS <= 0 +) { + throw new Error('AUTH_SESSION_TTL_SECONDS must be a positive integer'); +} +const GOOGLE_OAUTH_ENABLED = Boolean( + process.env.GOOGLE_CLIENT_ID?.trim() && + process.env.GOOGLE_CLIENT_SECRET?.trim() +); +const GITHUB_OAUTH_ENABLED = Boolean( + process.env.GITHUB_CLIENT_ID?.trim() && + process.env.GITHUB_CLIENT_SECRET?.trim() +); +const STDB_SERVER = process.env.STDB_SERVER ?? STDB_HTTP; +const SPACETIME_BIN = 'spacetime'; + +function configuredValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function configuredPem(value: string | undefined): string | undefined { + return configuredValue(value)?.replace(/\\n/g, '\n'); +} + +const opt = (value: string | undefined) => + value === undefined ? JSON.stringify([1, []]) : JSON.stringify([0, value]); + +function configureAuthFromEnv(): void { + const args = [ + JSON.stringify(AUTH_ISSUER_URL), + opt(AUTH_BASE_URL), + opt(AUTH_COOKIE_NAME), + JSON.stringify([0, AUTH_SESSION_TTL_SECONDS]), + opt(configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM)), + opt(configuredValue(process.env.GOOGLE_CLIENT_ID)), + opt(configuredValue(process.env.GOOGLE_CLIENT_SECRET)), + opt(configuredValue(process.env.GITHUB_CLIENT_ID)), + opt(configuredValue(process.env.GITHUB_CLIENT_SECRET)), + ]; + + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, STDB_APP_DB, 'set_auth_config', ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`auth config bootstrap failed (exit ${result.status})`); + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +// Reset-password email link serves the SPA so the frontend can read ?token=... +app.get('/auth/password/reset', (_req: Request, res: Response) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// Proxy /auth/* to STDB module HTTP handlers. Cookies pass through both ways. +app.use('/auth', async (req, res) => { + const fullPath = `/auth${req.url}`; + const qIdx = fullPath.indexOf('?'); + const subpath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${subpath}${query}`; + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + delete headers.host; + delete headers['content-length']; + + const init: RequestInit = { method: req.method, headers, redirect: 'manual' }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = JSON.stringify(req.body); + headers['content-type'] = 'application/json'; + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, val); + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + res.send(buf); + } catch (err) { + res + .status(502) + .json({ error: 'upstream_unreachable', detail: (err as Error).message }); + } +}); + +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, app: STDB_APP_DB }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + stdbUri: STDB_URI, + appDatabase: STDB_APP_DB, + auth: { + issuerUrl: AUTH_ISSUER_URL, + baseUrl: AUTH_BASE_URL, + cookieName: AUTH_COOKIE_NAME, + sessionTtlSeconds: AUTH_SESSION_TTL_SECONDS, + hasEs256PrivateKeyPem: Boolean( + configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM) + ), + }, + oauth: { + google: GOOGLE_OAUTH_ENABLED, + github: GITHUB_OAUTH_ENABLED, + }, + }); +}); + +try { + console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); + configureAuthFromEnv(); + console.log(`[auth] bootstrapped env config issuer=${AUTH_ISSUER_URL}`); +} catch (err) { + console.error( + `[auth] env config bootstrap failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[auth] is the SpacetimeDB host running and the grid example module published?' + ); + process.exit(1); +} + +app.listen(PORT, HOST, () => { + console.log(`Grid test app running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*)`); + console.log(` Database -> ${STDB_APP_DB}`); +}); diff --git a/spacetime-grid-ts/example/spacetimedb/package.json b/spacetime-grid-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..495f86f9db1 --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/package.json @@ -0,0 +1,21 @@ +{ + "name": "spacetime-grid-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-grid-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-grid-example" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/grid": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-grid-ts/example/spacetimedb/src/auth-adapter.ts b/spacetime-grid-ts/example/spacetimedb/src/auth-adapter.ts new file mode 100644 index 00000000000..aefa062282c --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/src/auth-adapter.ts @@ -0,0 +1,181 @@ +import { Router, t } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import * as auth from '@spacetimedb/auth/submodule'; +import { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + unlinkConnectionParams, + updateProfileParams, + revokeSessionParams, + listMySessionsParams, + revokeMySessionParams, + passwordSignupHandler, + passwordLoginHandler, + meHandler, + logoutHandler, + refreshHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + makeEmailVerifyRequestHandler, + makeEmailVerifyHandler, +} from '@spacetimedb/auth/submodule'; + +import { consoleSendMail, spacetimedb } from './schema'; + +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.set_auth_config(ctx.as.auth, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.get_auth_public_key(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +export const link_connection = spacetimedb.reducer( + linkConnectionParams, + (ctx, args) => { + auth.link_connection(ctx.as.auth, args); + } +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlink_connection(ctx.as.auth, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.update_profile(ctx.as.auth, args); + } +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revoke_session(ctx.as.auth, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + (ctx, args) => + auth.list_my_sessions(ctx.as.auth, args) as { + sessions: Array<{ + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; + }>; + } +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revoke_my_session(ctx.as.auth, args); + } +); + +const forgotHandler = makeForgotPasswordHandler({ + sendMail: consoleSendMail, + appName: 'Grid', +}); +const verifyRequestHandler = makeEmailVerifyRequestHandler({ + sendMail: consoleSendMail, + appName: 'Grid', +}); +const verifyHandler = makeEmailVerifyHandler({ + successRedirect: '/?verified=1', +}); + +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + passwordSignupHandler(ctx.as.auth, req) +); +export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => + passwordLoginHandler(ctx.as.auth, req) +); +export const authMe = spacetimedb.httpHandler((ctx, req) => + meHandler(ctx.as.auth, req) +); +export const authLogout = spacetimedb.httpHandler((ctx, req) => + logoutHandler(ctx.as.auth, req) +); +export const authRefresh = spacetimedb.httpHandler((ctx, req) => + refreshHandler(ctx.as.auth, req) +); +export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => + googleStartHandler(ctx.as.auth, req) +); +export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => + googleCallbackHandler(ctx.as.auth, req) +); +export const authGithubStart = spacetimedb.httpHandler((ctx, req) => + githubStartHandler(ctx.as.auth, req) +); +export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => + githubCallbackHandler(ctx.as.auth, req) +); +export const authPasswordForgot = spacetimedb.httpHandler((ctx, req) => + forgotHandler(ctx.as.auth, req) +); +export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => + resetPasswordHandler(ctx.as.auth, req) +); +export const authEmailVerifyRequest = spacetimedb.httpHandler((ctx, req) => + verifyRequestHandler(ctx.as.auth, req) +); +export const authEmailVerify = spacetimedb.httpHandler((ctx, req) => + verifyHandler(ctx.as.auth, req) +); + +export const router = spacetimedb.httpRouter( + new Router() + .post('/auth/password/signup', authPasswordSignup) + .post('/auth/password/login', authPasswordLogin) + .post('/auth/session/refresh', authRefresh) + .get('/auth/me', authMe) + .post('/auth/logout', authLogout) + .get('/auth/google/start', authGoogleStart) + .get('/auth/google/callback', authGoogleCallback) + .get('/auth/github/start', authGithubStart) + .get('/auth/github/callback', authGithubCallback) + .post('/auth/password/forgot', authPasswordForgot) + .post('/auth/password/reset', authPasswordReset) + .post('/auth/email/verify-request', authEmailVerifyRequest) + .get('/auth/email/verify', authEmailVerify) +); diff --git a/spacetime-grid-ts/example/spacetimedb/src/index.ts b/spacetime-grid-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..0b80dc40364 --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,966 @@ +// spacetime-grid-example module. Wires auth-ts + grid-ts plus +// game-specific tables (match, unit_type, player_unit) for an Advance-Wars-lite +// turn-based strategy demo on a hex grid. + +import { t, SenderError, type ProcedureCtx } from 'spacetimedb/server'; +import { Timestamp, type Identity } from 'spacetimedb'; +import * as auth from '@spacetimedb/auth/submodule'; +import * as gridSubmodule from '@spacetimedb/grid/submodule'; +import { getCallerUserId } from '@spacetimedb/auth/submodule'; +import { + GRID_KIND_HEX, + GRID_ORIENTATION_FLAT, + GRID_MODE_COLLABORATIVE, + computePathImpl, + cellsInRangeImpl, +} from '@spacetimedb/grid'; +import { distance } from '@spacetimedb/grid/math'; + +// Dev mailer + +import { + MatchStatus, + AI_BOT_USER_ID, + AI_BOT_NAME, + spacetimedb, + type Schema, + type WriteCtx, +} from './schema'; +export { default } from './schema'; +export * from './auth-adapter'; + +// Helpers + +function throwSenderError(msg: string): never { + throw new SenderError(msg); +} + +function requireUserId(ctx: ProcedureCtx): string { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throwSenderError('grid.not_authenticated'); + return userId; +} + +// Views (caller-scoped) + +export * from './views'; + +export const init = spacetimedb.init(ctx => { + auth.installAuth(ctx.as.auth); + gridSubmodule.installGrid(ctx.as.grid); + + // Seed canonical unit types. Theme: stranded survey crew on a hostile alien + // planet. Unit stats support distinct movement and combat roles. + const types = [ + { + typeId: 'marine', + name: 'Marine', + movement: 3, + attackRange: 1, + attackDmg: 3, + hp: 10, + glyph: 'M', + }, + { + typeId: 'titan', + name: 'Titan', + movement: 4, + attackRange: 1, + attackDmg: 5, + hp: 14, + glyph: 'T', + }, + { + typeId: 'drone', + name: 'Drone', + movement: 6, + attackRange: 2, + attackDmg: 2, + hp: 6, + glyph: 'D', + }, + ]; + for (const u of types) { + if (!ctx.db.unitType.typeId.find(u.typeId)) ctx.db.unitType.insert(u); + } + // Seed the AI opponent as an NPC actor. Lives outside auth_user so it + // can't be impersonated and shows up in actor_directory as an Npc, not a User. + if (!ctx.db.npcActor.actorId.find(AI_BOT_USER_ID)) { + ctx.db.npcActor.insert({ + actorId: AI_BOT_USER_ID, + name: AI_BOT_NAME, + image: undefined, + createdAt: ctx.timestamp, + }); + } +}); + +// Whoami (debug helper) + +export const whoami = spacetimedb.procedure( + {}, + t.object('WhoAmI', { + userId: t.option(t.string()), + senderIdentityHex: t.string(), + }), + ctx => { + const userId = getCallerUserId(ctx.as.auth); + return { + userId: userId ?? undefined, + senderIdentityHex: (ctx.sender as Identity).toHexString(), + }; + } +); + +// Game: match flow + +// The playable area is a HEXAGON of radius R centered at axial (R, R). +// The grid submodule allocates a (2R+1) x (2R+1) rectangle because its bounds +// checker uses rectangular coordinates. Cells outside the playable hex are +// impassable, which keeps A* and Dijkstra inside the SpacetimeDB-logo shape. +const GRID_RADIUS = 5; +const GRID_DIAMETER = 2 * GRID_RADIUS + 1; // 11 +const DEFAULT_COST = 1; + +// Hex distance from (R, R). <= R means the cell is inside the play hex. +function isInHexShape(q: number, r: number): boolean { + const cx = GRID_RADIUS, + cy = GRID_RADIUS; + return ( + (Math.abs(q - cx) + Math.abs(r - cy) + Math.abs(q + r - (cx + cy))) / 2 <= + GRID_RADIUS + ); +} + +// Top corner of the hex; bottom corner is mirror across (R, R). +const PLAYER_SPAWNS = [ + { x: GRID_RADIUS, y: 0, typeId: 'marine' }, + { x: GRID_RADIUS - 1, y: 1, typeId: 'titan' }, + { x: GRID_RADIUS + 1, y: 0, typeId: 'drone' }, +]; +const OPPONENT_SPAWNS = [ + { x: GRID_RADIUS, y: 2 * GRID_RADIUS, typeId: 'marine' }, + { x: GRID_RADIUS + 1, y: 2 * GRID_RADIUS - 1, typeId: 'titan' }, + { x: GRID_RADIUS - 1, y: 2 * GRID_RADIUS, typeId: 'drone' }, +]; + +// Deterministic-ish terrain seed (uses match createdAt micros). Cheap PRNG. +function rng(seed: bigint) { + let state = seed === 0n ? 1n : seed; + const M = 0xffffffffn; + return (): number => { + state = (state * 1103515245n + 12345n) & M; + return Number(state) / Number(M); + }; +} + +export const create_match = spacetimedb.procedure( + { vsAi: t.bool() }, + t.object('CreateMatchResult', { matchId: t.u64(), gridId: t.u64() }), + (ctx, args) => { + const userId = requireUserId(ctx); + return ctx.withTx(tx => { + // 1. Create the grid (collaborative so both players can move units via our own reducers). + const gridRowInserted = tx.db.grid.grid.insert({ + id: 0n, + ownerUserId: userId, + name: `Match by ${userId.slice(0, 8)}`, + kind: GRID_KIND_HEX, + orientation: GRID_ORIENTATION_FLAT, + width: GRID_DIAMETER, + height: GRID_DIAMETER, + defaultCost: DEFAULT_COST, + connectivity: 6, + mode: GRID_MODE_COLLABORATIVE, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + + // 2. Seed terrain. Cells outside the hex shape are 'void' + impassable + // so neither A* nor Dijkstra crosses them. Spawn cells stay clear. + const spawnKeys = new Set( + [...PLAYER_SPAWNS, ...OPPONENT_SPAWNS].map(s => `${s.x},${s.y}`) + ); + const seedMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + const rand = rng(seedMicros); + for (let y = 0; y < GRID_DIAMETER; y++) { + for (let x = 0; x < GRID_DIAMETER; x++) { + if (!isInHexShape(x, y)) { + tx.db.grid.cellState.insert({ + id: 0n, + gridId: gridRowInserted.id, + x, + y, + cost: -1, + terrain: 'void', + }); + continue; + } + if (spawnKeys.has(`${x},${y}`)) continue; // keep spawns clear + // Single tactical-obstacle type: impassable crater. Movement is + // either 1 (regolith) or blocked - no slow terrain to remember. + if (rand() < 0.14) { + tx.db.grid.cellState.insert({ + id: 0n, + gridId: gridRowInserted.id, + x, + y, + cost: -1, + terrain: 'crater', + }); + } + // else: regolith plains (default cost, no row needed) + } + } + + // 3. Create the match row. vs-AI starts active immediately; vs-human + // waits for someone to call join_match. + const matchInserted = tx.db.match.insert({ + matchId: 0n, + status: args.vsAi ? MatchStatus.Active : MatchStatus.Waiting, + currentSeatIdx: 0, + turnNumber: 1, + winnerUserId: undefined, + gridId: gridRowInserted.id, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + + // 4. Seat the caller at seat 0 (team 0) and drop their landing party. + insertParticipant(tx, matchInserted.matchId, userId, 0, 0, ctx.timestamp); + placeStartingUnits( + tx, + ctx.timestamp, + matchInserted.matchId, + gridRowInserted.id, + userId, + PLAYER_SPAWNS + ); + + // 5. If vs-AI, seat the bot at seat 1 (team 1) and drop the xeno garrison. + if (args.vsAi) { + insertParticipant( + tx, + matchInserted.matchId, + AI_BOT_USER_ID, + 1, + 1, + ctx.timestamp + ); + placeStartingUnits( + tx, + ctx.timestamp, + matchInserted.matchId, + gridRowInserted.id, + AI_BOT_USER_ID, + OPPONENT_SPAWNS + ); + } + + return { matchId: matchInserted.matchId, gridId: gridRowInserted.id }; + }); + } +); + +export const join_match = spacetimedb.procedure( + { matchId: t.u64() }, + t.unit(), + (ctx, { matchId }) => { + const userId = requireUserId(ctx); + ctx.withTx(tx => { + const m = tx.db.match.matchId.find(matchId); + if (!m) throwSenderError(`grid.match_not_found:${matchId}`); + if (m.status.tag !== 'Waiting') + throwSenderError(`grid.match_not_joinable:${m.status.tag}`); + for (const p of tx.db.matchParticipant.matchId.filter(matchId)) { + if (p.userId === userId) throwSenderError(`grid.match_self_join`); + } + + insertParticipant(tx, m.matchId, userId, 1, 1, ctx.timestamp); + placeStartingUnits( + tx, + ctx.timestamp, + m.matchId, + m.gridId, + userId, + OPPONENT_SPAWNS + ); + + tx.db.match.matchId.update({ + ...m, + status: MatchStatus.Active, + updatedAt: ctx.timestamp, + }); + }); + return {}; + } +); + +export const end_turn = spacetimedb.procedure( + { matchId: t.u64() }, + t.unit(), + (ctx, { matchId }) => { + const userId = requireUserId(ctx); + ctx.withTx(tx => { + const m = tx.db.match.matchId.find(matchId); + if (!m) throwSenderError(`grid.match_not_found:${matchId}`); + if (m.status.tag !== 'Active') + throwSenderError(`grid.match_not_active:${m.status.tag}`); + const seats = participantsBySeat(tx, matchId); + if (userIdAt(seats, m.currentSeatIdx) !== userId) + throwSenderError(`grid.not_your_turn`); + + // Two-seat rotation. Generalizes via (currentSeatIdx + 1) % seats.size. + const nextIdx = (m.currentSeatIdx + 1) % seats.size; + const nextUserId = userIdAt(seats, nextIdx); + for (const u of tx.db.playerUnit.matchId.filter(matchId)) { + if (u.ownerUserId === nextUserId) { + tx.db.playerUnit.entityId.update({ + ...u, + hasMoved: false, + hasAttacked: false, + }); + } + } + tx.db.match.matchId.update({ + ...m, + currentSeatIdx: nextIdx, + turnNumber: nextIdx === 0 ? m.turnNumber + 1 : m.turnNumber, + updatedAt: ctx.timestamp, + }); + }); + return {}; + } +); + +// Participant helpers + +function insertParticipant( + tx: WriteCtx, + matchId: bigint, + userId: string, + seatIdx: number, + team: number, + timestamp: Timestamp +): void { + tx.db.matchParticipant.insert({ + id: 0n, + matchId, + userId, + seatIdx, + team, + joinedAt: timestamp, + }); +} + +function participantsBySeat( + tx: WriteCtx, + matchId: bigint +): Map { + const seats = new Map(); + for (const p of tx.db.matchParticipant.matchId.filter(matchId)) { + seats.set(p.seatIdx, p.userId); + } + return seats; +} + +function userIdAt( + seats: Map, + seatIdx: number +): string | undefined { + return seats.get(seatIdx); +} + +function participantTeams(tx: WriteCtx, matchId: bigint): Map { + const teams = new Map(); + for (const p of tx.db.matchParticipant.matchId.filter(matchId)) { + teams.set(p.userId, p.team); + } + return teams; +} + +// Helper used by create_match + join_match to drop starting units. +function placeStartingUnits( + tx: WriteCtx, + timestamp: Timestamp, + matchId: bigint, + gridId: bigint, + ownerUserId: string, + spawns: Array<{ x: number; y: number; typeId: string }> +): void { + for (const s of spawns) { + const type = tx.db.unitType.typeId.find(s.typeId); + if (!type) throwSenderError(`grid.unknown_unit_type:${s.typeId}`); + const entity = tx.db.grid.gridEntity.insert({ + id: 0n, + gridId, + ownerUserId, + x: s.x, + y: s.y, + kind: s.typeId, + blocksMovement: true, + label: undefined, + createdAt: timestamp, + updatedAt: timestamp, + }); + tx.db.playerUnit.insert({ + entityId: entity.id, + matchId, + ownerUserId, + typeId: s.typeId, + currentHp: type.hp, + hasMoved: false, + hasAttacked: false, + createdAt: timestamp, + }); + } +} + +// Game: per-unit movement and attack procedures. + +export const move_unit = spacetimedb.procedure( + { entityId: t.u64(), toX: t.i32(), toY: t.i32() }, + t.object('MoveUnitResult', { + // The exact A* path from start to end, including both endpoints. The client + // animates the unit along this path to the destination. + path: t.array(t.object('MoveStep', { x: t.i32(), y: t.i32() })), + }), + (ctx, args) => { + const userId = requireUserId(ctx); + + // Validate ownership and turn state, then capture coordinates for pathfinding. + // computePathImpl opens its own transaction, so run it after this transaction. + let entityX = 0, + entityY = 0; + let gridId = 0n; + let typeMovement = 0; + ctx.withTx(tx => { + const unit = tx.db.playerUnit.entityId.find(args.entityId); + if (!unit) throwSenderError(`grid.unit_not_found:${args.entityId}`); + if (unit.ownerUserId !== userId) throwSenderError(`grid.not_unit_owner`); + if (unit.hasMoved) throwSenderError(`grid.already_moved`); + + const m = tx.db.match.matchId.find(unit.matchId); + if (!m) throwSenderError(`grid.match_not_found:${unit.matchId}`); + if (m.status.tag !== 'Active') + throwSenderError(`grid.match_not_active:${m.status.tag}`); + const seats = participantsBySeat(tx, unit.matchId); + if (userIdAt(seats, m.currentSeatIdx) !== userId) + throwSenderError(`grid.not_your_turn`); + + const entity = tx.db.grid.gridEntity.id.find(args.entityId); + if (!entity) throwSenderError(`grid.entity_not_found:${args.entityId}`); + + const type = tx.db.unitType.typeId.find(unit.typeId); + if (!type) throwSenderError(`grid.unknown_unit_type:${unit.typeId}`); + + entityX = entity.x; + entityY = entity.y; + gridId = entity.gridId; + typeMovement = type.movement; + }); + + // Run A* pathfinding in a read-only transaction. + const path = computePathImpl( + ctx.as.grid, + { + gridId, + startX: entityX, + startY: entityY, + endX: args.toX, + endY: args.toY, + storeFor: undefined, + maxExpansions: undefined, + }, + userId + ) as { + found: boolean; + cells: Array<{ x: number; y: number }>; + cost: number; + }; + if (!path.found) + throwSenderError(`grid.no_path_to:${args.toX},${args.toY}`); + if (path.cost > typeMovement) + throwSenderError(`grid.move_too_far:${path.cost}>${typeMovement}`); + + // Apply the move. Procedure serialization bounds TOCTOU, and the transaction + // verifies hasMoved immediately before mutation. + ctx.withTx(tx => { + const unit = tx.db.playerUnit.entityId.find(args.entityId); + const entity = tx.db.grid.gridEntity.id.find(args.entityId); + if (!unit || !entity) throwSenderError(`grid.unit_vanished`); + if (unit.hasMoved) throwSenderError(`grid.already_moved`); + tx.db.grid.gridEntity.id.update({ + ...entity, + x: args.toX, + y: args.toY, + updatedAt: ctx.timestamp, + }); + tx.db.playerUnit.entityId.update({ ...unit, hasMoved: true }); + }); + return { path: path.cells }; + } +); + +export const attack_unit = spacetimedb.procedure( + { attackerId: t.u64(), targetId: t.u64() }, + t.unit(), + (ctx, args) => { + const userId = requireUserId(ctx); + ctx.withTx(tx => { + const attacker = tx.db.playerUnit.entityId.find(args.attackerId); + const target = tx.db.playerUnit.entityId.find(args.targetId); + if (!attacker) throwSenderError(`grid.unit_not_found:${args.attackerId}`); + if (!target) throwSenderError(`grid.unit_not_found:${args.targetId}`); + if (attacker.ownerUserId !== userId) + throwSenderError(`grid.not_unit_owner`); + if (attacker.hasAttacked) throwSenderError(`grid.already_attacked`); + if (target.ownerUserId === userId) + throwSenderError(`grid.cant_attack_self`); + if (attacker.matchId !== target.matchId) + throwSenderError(`grid.cross_match_attack`); + + const m = tx.db.match.matchId.find(attacker.matchId); + if (!m) throwSenderError(`grid.match_not_found:${attacker.matchId}`); + if (m.status.tag !== 'Active') throwSenderError(`grid.match_not_active`); + const seats = participantsBySeat(tx, attacker.matchId); + if (userIdAt(seats, m.currentSeatIdx) !== userId) + throwSenderError(`grid.not_your_turn`); + + const attackerEntity = tx.db.grid.gridEntity.id.find(args.attackerId); + const targetEntity = tx.db.grid.gridEntity.id.find(args.targetId); + if (!attackerEntity || !targetEntity) + throwSenderError(`grid.entity_missing`); + + const type = tx.db.unitType.typeId.find(attacker.typeId); + if (!type) throwSenderError(`grid.unknown_unit_type:${attacker.typeId}`); + + const dist = distance( + 'hex', + { x: attackerEntity.x, y: attackerEntity.y }, + { x: targetEntity.x, y: targetEntity.y } + ); + if (dist > type.attackRange) { + throwSenderError( + `grid.target_out_of_range:${dist}>${type.attackRange}` + ); + } + + const newHp = target.currentHp - type.attackDmg; + tx.db.playerUnit.entityId.update({ ...attacker, hasAttacked: true }); + + if (newHp <= 0) { + // Unit dies: remove both the playerUnit row and the gridEntity. + tx.db.playerUnit.delete(target); + tx.db.grid.gridEntity.delete(targetEntity); + + // Win check: are there any units left on any other team? + const teamByUser = participantTeams(tx, attacker.matchId); + const myTeam = teamByUser.get(userId); + const remaining = [ + ...tx.db.playerUnit.matchId.filter(attacker.matchId), + ].filter(u => teamByUser.get(u.ownerUserId) !== myTeam); + if (remaining.length === 0) { + tx.db.match.matchId.update({ + ...m, + status: MatchStatus.Ended, + winnerUserId: userId, + updatedAt: ctx.timestamp, + }); + } + } else { + tx.db.playerUnit.entityId.update({ ...target, currentHp: newHp }); + } + }); + return {}; + } +); + +// AI opponent. Triggered by the client after end_turn flips to AI's turn. +// Greedy heuristic: for each AI unit (highest-damage first), attack the +// lowest-HP enemy in range; otherwise move toward the nearest enemy and +// attack after the move if newly in range. Then flips the turn back. + +type AiUnit = { + entityId: bigint; + typeId: string; + currentHp: number; + x: number; + y: number; + hasMoved: boolean; + hasAttacked: boolean; +}; +type EnemyUnit = { + entityId: bigint; + ownerUserId: string; + currentHp: number; + x: number; + y: number; +}; +type UnitTypeSnap = { + movement: number; + attackRange: number; + attackDmg: number; + hp: number; +}; + +export const ai_take_turn = spacetimedb.procedure( + { matchId: t.u64() }, + t.object('AiTakeTurnResult', { + // One event per acting unit, in execution order. Each event may have a + // movePath (the A* path the unit walked) and/or an attack (with target + // snapshot so the client can ghost-render the victim until the attack + // visually fires after the move animation). Lets the client sequence: + // move animation -> brief pause -> attack flash -> target HP drop / death. + events: t.array( + t.object('AiTurnEvent', { + entityId: t.u64(), + movePath: t.option( + t.array(t.object('AiPathStep', { x: t.i32(), y: t.i32() })) + ), + attack: t.option( + t.object('AiAttackInfo', { + targetId: t.u64(), + damage: t.i32(), + killed: t.bool(), + // Target snapshot AT the moment of attack (so the client knows where + // to draw the ghost while it's pending and what HP to show). + targetX: t.i32(), + targetY: t.i32(), + targetOwner: t.string(), + targetTypeId: t.string(), + targetPreHp: t.i32(), + }) + ), + }) + ), + }), + (ctx, args) => { + // Any signed-in human in the match can trigger the AI to play. The + // procedure validates the AI turn before acting, so invalid calls are no-ops. + requireUserId(ctx); + type AttackInfo = { + targetId: bigint; + damage: number; + killed: boolean; + targetX: number; + targetY: number; + targetOwner: string; + targetTypeId: string; + targetPreHp: number; + }; + // Fields are `| undefined` (not optional) because spacetimedb's t.option + // serializer requires them to be present in the runtime shape. + type TurnEvent = { + entityId: bigint; + movePath: Array<{ x: number; y: number }> | undefined; + attack: AttackInfo | undefined; + }; + const events: TurnEvent[] = []; + + // Snapshot: read match + unit positions/HP + unit-type catalog. + let gridId = 0n; + const aiUnits: AiUnit[] = []; + const enemyUnits: EnemyUnit[] = []; + const typeIdx = new Map(); + ctx.withTx(tx => { + const m = tx.db.match.matchId.find(args.matchId); + if (!m) throwSenderError(`grid.match_not_found:${args.matchId}`); + if (m.status.tag !== 'Active') + throwSenderError(`grid.match_not_active:${m.status.tag}`); + const seats = participantsBySeat(tx, args.matchId); + if (userIdAt(seats, m.currentSeatIdx) !== AI_BOT_USER_ID) + throwSenderError(`grid.not_ai_turn`); + gridId = m.gridId; + for (const u of tx.db.playerUnit.matchId.filter(args.matchId)) { + const e = tx.db.grid.gridEntity.id.find(u.entityId); + if (!e) continue; + if (u.ownerUserId === AI_BOT_USER_ID) { + aiUnits.push({ + entityId: u.entityId, + typeId: u.typeId, + currentHp: u.currentHp, + x: e.x, + y: e.y, + hasMoved: u.hasMoved, + hasAttacked: u.hasAttacked, + }); + } else { + enemyUnits.push({ + entityId: u.entityId, + ownerUserId: u.ownerUserId, + currentHp: u.currentHp, + x: e.x, + y: e.y, + }); + } + } + for (const t of tx.db.unitType.iter()) { + typeIdx.set(t.typeId, { + movement: t.movement, + attackRange: t.attackRange, + attackDmg: t.attackDmg, + hp: t.hp, + }); + } + }); + + const hexDist = (ax: number, ay: number, bx: number, by: number) => + distance('hex', { x: ax, y: ay }, { x: bx, y: by }); + + // Apply one attack atomically. Returns { info, ended } where `info` is + // the snapshot needed to animate the attack on the client. A null `info` + // means no attack fired, and `ended` indicates the match concluded. + const tryAttack = ( + aiUnit: AiUnit, + target: EnemyUnit, + dmg: number + ): { info: AttackInfo | null; ended: boolean } => { + let ended = false; + let info: AttackInfo | null = null; + ctx.withTx(tx => { + const attacker = tx.db.playerUnit.entityId.find(aiUnit.entityId); + const tgt = tx.db.playerUnit.entityId.find(target.entityId); + if (!attacker || !tgt || attacker.hasAttacked) return; + const newHp = tgt.currentHp - dmg; + const tEnt = tx.db.grid.gridEntity.id.find(target.entityId); + // Capture target snapshot BEFORE the delete/update so the client + // can ghost-render it during the move animation. + info = { + targetId: target.entityId, + damage: dmg, + killed: newHp <= 0, + targetX: tEnt ? tEnt.x : target.x, + targetY: tEnt ? tEnt.y : target.y, + targetOwner: tgt.ownerUserId, + targetTypeId: tgt.typeId, + targetPreHp: tgt.currentHp, + }; + tx.db.playerUnit.entityId.update({ ...attacker, hasAttacked: true }); + if (newHp <= 0) { + tx.db.playerUnit.delete(tgt); + if (tEnt) tx.db.grid.gridEntity.delete(tEnt); + const teamByUser = participantTeams(tx, args.matchId); + const aiTeam = teamByUser.get(AI_BOT_USER_ID); + const remaining = [ + ...tx.db.playerUnit.matchId.filter(args.matchId), + ].filter(u => teamByUser.get(u.ownerUserId) !== aiTeam); + if (remaining.length === 0) { + const mm = tx.db.match.matchId.find(args.matchId); + if (mm) { + tx.db.match.matchId.update({ + ...mm, + status: MatchStatus.Ended, + winnerUserId: AI_BOT_USER_ID, + updatedAt: ctx.timestamp, + }); + ended = true; + } + } + } else { + tx.db.playerUnit.entityId.update({ ...tgt, currentHp: newHp }); + } + }); + // Mirror in our local snapshot for subsequent units' planning. + aiUnit.hasAttacked = true; + target.currentHp -= dmg; + if (target.currentHp <= 0) { + const idx = enemyUnits.indexOf(target); + if (idx >= 0) enemyUnits.splice(idx, 1); + } + return { info, ended }; + }; + + // Best target in attack range: lowest currentHp (greedy lethal-first). + const pickTarget = (aiUnit: AiUnit, range: number): EnemyUnit | null => { + const inRange = enemyUnits + .filter(e => e.currentHp > 0) + .filter(e => hexDist(aiUnit.x, aiUnit.y, e.x, e.y) <= range); + if (inRange.length === 0) return null; + inRange.sort((a, b) => a.currentHp - b.currentHp); + return inRange[0]; + }; + + // High-damage units act first so the kills land before the chip-damage. + aiUnits.sort( + (a, b) => + (typeIdx.get(b.typeId)?.attackDmg ?? 0) - + (typeIdx.get(a.typeId)?.attackDmg ?? 0) + ); + + for (const aiUnit of aiUnits) { + const type = typeIdx.get(aiUnit.typeId); + if (!type) continue; + + // Buffer per-unit events until movement and combat complete. + const evt: TurnEvent = { + entityId: aiUnit.entityId, + movePath: undefined, + attack: undefined, + }; + + // 1. Shoot from the current position when a target is in range. + if (!aiUnit.hasAttacked) { + const target = pickTarget(aiUnit, type.attackRange); + if (target) { + const r = tryAttack(aiUnit, target, type.attackDmg); + if (r.info) evt.attack = r.info; + if (r.ended) { + events.push(evt); + return { events }; + } + events.push(evt); + continue; // already attacked; skip move this turn + } + } + + // 2. Otherwise close the distance toward the nearest enemy. + if (!aiUnit.hasMoved && enemyUnits.length > 0) { + const cells = ( + cellsInRangeImpl( + ctx.as.grid, + { + gridId, + originX: aiUnit.x, + originY: aiUnit.y, + maxCost: type.movement, + }, + AI_BOT_USER_ID + ) as { cells: Array<{ x: number; y: number; cost: number }> } + ).cells; + + // Avoid stepping onto a tile occupied by another known unit (defensive; + // blocksMovement on entities should already prevent this). + const blocked = new Set(); + for (const u of aiUnits) + if (u.entityId !== aiUnit.entityId) blocked.add(`${u.x},${u.y}`); + for (const e of enemyUnits) blocked.add(`${e.x},${e.y}`); + + const movable = cells.filter( + c => c.cost > 0 && !blocked.has(`${c.x},${c.y}`) + ); + if (movable.length > 0) { + let best = movable[0]; + let bestScore = Infinity; + for (const c of movable) { + const closest = Math.min( + ...enemyUnits.map(e => hexDist(c.x, c.y, e.x, e.y)) + ); + // Primary: closeness to enemy. Tiebreak: prefer cheaper paths. + const score = closest * 100 + c.cost; + if (score < bestScore) { + bestScore = score; + best = c; + } + } + // Capture the A* path BEFORE the move so the client can animate it. + const fromX = aiUnit.x, + fromY = aiUnit.y; + const pathRes = computePathImpl( + ctx.as.grid, + { + gridId, + startX: fromX, + startY: fromY, + endX: best.x, + endY: best.y, + storeFor: undefined, + maxExpansions: undefined, + }, + AI_BOT_USER_ID + ) as { + found: boolean; + cells: Array<{ x: number; y: number }>; + cost: number; + }; + ctx.withTx(tx => { + const u = tx.db.playerUnit.entityId.find(aiUnit.entityId); + const e = tx.db.grid.gridEntity.id.find(aiUnit.entityId); + if (!u || !e || u.hasMoved) return; + tx.db.grid.gridEntity.id.update({ + ...e, + x: best.x, + y: best.y, + updatedAt: ctx.timestamp, + }); + tx.db.playerUnit.entityId.update({ ...u, hasMoved: true }); + }); + aiUnit.x = best.x; + aiUnit.y = best.y; + aiUnit.hasMoved = true; + evt.movePath = + pathRes.found && pathRes.cells.length >= 2 + ? pathRes.cells + : [ + { x: fromX, y: fromY }, + { x: best.x, y: best.y }, + ]; + + // After the move, attack if newly in range. + if (!aiUnit.hasAttacked) { + const target = pickTarget(aiUnit, type.attackRange); + if (target) { + const r = tryAttack(aiUnit, target, type.attackDmg); + if (r.info) evt.attack = r.info; + if (r.ended) { + events.push(evt); + return { events }; + } + } + } + } + } + + // Emit the event if this unit did anything (move or attack). + if (evt.movePath || evt.attack) events.push(evt); + } + + // End the AI's turn: flip back to the human and reset their per-turn flags. + ctx.withTx(tx => { + const m = tx.db.match.matchId.find(args.matchId); + if (!m || m.status.tag !== 'Active') return; + const seats = participantsBySeat(tx, args.matchId); + const nextIdx = (m.currentSeatIdx + 1) % seats.size; + const nextUserId = userIdAt(seats, nextIdx); + for (const u of tx.db.playerUnit.matchId.filter(args.matchId)) { + if (u.ownerUserId === nextUserId) { + tx.db.playerUnit.entityId.update({ + ...u, + hasMoved: false, + hasAttacked: false, + }); + } + } + tx.db.match.matchId.update({ + ...m, + currentSeatIdx: nextIdx, + turnNumber: nextIdx === 0 ? m.turnNumber + 1 : m.turnNumber, + updatedAt: ctx.timestamp, + }); + }); + + return { events }; + } +); + +// Query helpers exposed as procedures (so the client can preview +// movement range / paths without subscribing to entity_path). + +export const get_cells_in_range = spacetimedb.procedure( + { gridId: t.u64(), originX: t.i32(), originY: t.i32(), maxCost: t.i32() }, + t.object('CellsInRangeResult', { + cells: t.array( + t.object('ReachableCell', { + x: t.i32(), + y: t.i32(), + cost: t.i32(), + }) + ), + }), + (ctx, args) => { + const userId = requireUserId(ctx); + return cellsInRangeImpl(ctx.as.grid, args, userId) as { + cells: Array<{ x: number; y: number; cost: number }>; + }; + } +); diff --git a/spacetime-grid-ts/example/spacetimedb/src/schema.ts b/spacetime-grid-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..33c0500be00 --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,140 @@ +// spacetime-grid-example module. Wires auth-ts + grid-ts plus +// game-specific tables (match, unit_type, player_unit) for an Advance-Wars-lite +// turn-based strategy demo on a hex grid. + +import { + schema, + t, + table, + type InferSchema, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as auth from '@spacetimedb/auth/submodule'; +import * as gridSubmodule from '@spacetimedb/grid/submodule'; +import { type SendMailFn, type MailParams } from '@spacetimedb/auth/submodule'; + +// Dev mailer + +export const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { + console.log( + `[mail] to=${params.to} subject=${params.subject}\n${params.text}` + ); +}; + +export const authUserViewRow = t.object('GridAuthUser', { + userId: t.string(), + email: t.string(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +// Game tables + +// Static unit catalog. Seeded once in init. +export const unitType = table( + { name: 'unit_type', public: true }, + { + typeId: t.string().primaryKey(), + name: t.string(), + movement: t.i32(), + attackRange: t.i32(), + attackDmg: t.i32(), + hp: t.i32(), + glyph: t.string(), + } +); + +// Match lifecycle. Waiting = seats not yet filled. Active = playing. +// Ended = a side has won (winnerUserId is set). +export const matchStatus = t.enum('MatchStatus', [ + 'Waiting', + 'Active', + 'Ended', +]); +export const MatchStatus = { + Waiting: { tag: 'Waiting' as const }, + Active: { tag: 'Active' as const }, + Ended: { tag: 'Ended' as const }, +}; + +// One row per match. Participant userIds live in match_participant, not here. +export const match = table( + { name: 'match', public: false }, + { + matchId: t.u64().primaryKey().autoInc(), + status: matchStatus.index(), + currentSeatIdx: t.i32(), + turnNumber: t.i32(), + winnerUserId: t.option(t.string()), + gridId: t.u64().index(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +// Seats in a match. matchId+userId pair via two indexes - no host/opponent +// asymmetry, naturally extends past 2 seats, and a player's matches are +// reachable through matchParticipant.userId in O(log n). team groups allies +// for win-check semantics; in free-for-all each seat is its own team. +export const matchParticipant = table( + { name: 'match_participant', public: false }, + { + id: t.u64().primaryKey().autoInc(), + matchId: t.u64().index(), + userId: t.string().index(), + seatIdx: t.i32(), + team: t.i32(), + joinedAt: t.timestamp(), + } +); + +// Non-player actors include the built-in AI. Their actorId shares the +// auth_user.userId namespace for ownership columns. A separate table keeps +// NPCs out of authentication and user-directory rows. +export const npcActor = table( + { name: 'npc_actor', public: true }, + { + actorId: t.string().primaryKey(), + name: t.string(), + image: t.option(t.string()), + createdAt: t.timestamp(), + } +); + +export const AI_BOT_USER_ID = 'ai-bot-001'; +export const AI_BOT_NAME = 'Xeno Garrison'; + +// Combat state per unit. The grid submodule owns positional state +// (gridEntity.x/y); this row holds the game-specific layer on top. +export const playerUnit = table( + { name: 'player_unit', public: false }, + { + entityId: t.u64().primaryKey(), // FK into grid_entity.id + matchId: t.u64().index(), + ownerUserId: t.string().index(), + typeId: t.string().index(), + currentHp: t.i32(), + hasMoved: t.bool(), + hasAttacked: t.bool(), + createdAt: t.timestamp(), + } +); + +// Schema + +export const spacetimedb = schema({ + auth, + grid: gridSubmodule, + unitType, + match, + matchParticipant, + npcActor, + playerUnit, +}); +export default spacetimedb; + +export type Schema = InferSchema; +export type WriteCtx = TransactionCtx; diff --git a/spacetime-grid-ts/example/spacetimedb/src/views.ts b/spacetime-grid-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..0f459920a58 --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,243 @@ +// spacetime-grid-example module. Wires auth-ts + grid-ts plus +// game-specific tables (match, unit_type, player_unit) for an Advance-Wars-lite +// turn-based strategy demo on a hex grid. + +import { t, type ViewCtx } from 'spacetimedb/server'; +import * as gridSubmodule from '@spacetimedb/grid/submodule'; + +// Dev mailer + +import { + authUserViewRow, + MatchStatus, + match, + matchParticipant, + playerUnit, + spacetimedb, + type Schema, +} from './schema'; +export { default } from './schema'; + +// Helpers + +export const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUserViewRow), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + const row = ctx.db.auth.authUser.userId.find(binding.userId); + return row ? [row] : []; + } +); + +// Per-match scoping. Caller sees matches they participate in, the grid +// state for those matches, and other seats' participant rows so the lobby +// can render opponent names. Other matches are invisible. +function callerUserId(ctx: ViewCtx): string | undefined { + return ctx.db.auth.authConnectionBinding.stdbIdentity.find(ctx.sender) + ?.userId; +} + +function myMatchAndGridIds(ctx: ViewCtx): { + matchIds: Set; + gridIds: Set; +} { + const matchIds = new Set(); + const gridIds = new Set(); + const uid = callerUserId(ctx); + if (!uid) return { matchIds, gridIds }; + for (const p of ctx.db.matchParticipant.userId.filter(uid)) { + if (matchIds.has(p.matchId)) continue; + matchIds.add(p.matchId); + const m = ctx.db.match.matchId.find(p.matchId); + if (m) gridIds.add(m.gridId); + } + return { matchIds, gridIds }; +} + +export const myMatches = spacetimedb.view( + { name: 'my_matches', public: true }, + t.array(match.rowType), + ctx => { + const { matchIds } = myMatchAndGridIds(ctx); + if (matchIds.size === 0) return []; + const out = []; + for (const id of matchIds) { + const m = ctx.db.match.matchId.find(id); + if (m) out.push(m); + } + return out; + } +); +export const myGrids = spacetimedb.view( + { name: 'my_grids', public: true }, + t.array(gridSubmodule.grid.rowType), + ctx => { + const { gridIds } = myMatchAndGridIds(ctx); + if (gridIds.size === 0) return []; + const out = []; + for (const id of gridIds) { + const g = ctx.db.grid.grid.id.find(id); + if (g) out.push(g); + } + return out; + } +); + +export const myCellStates = spacetimedb.view( + { name: 'my_cell_states', public: true }, + t.array(gridSubmodule.cellState.rowType), + ctx => { + const { gridIds } = myMatchAndGridIds(ctx); + if (gridIds.size === 0) return []; + const out = []; + for (const gid of gridIds) { + for (const c of ctx.db.grid.cellState.gridId.filter(gid)) out.push(c); + } + return out; + } +); + +export const myGridEntities = spacetimedb.view( + { name: 'my_grid_entities', public: true }, + t.array(gridSubmodule.gridEntity.rowType), + ctx => { + const { gridIds } = myMatchAndGridIds(ctx); + if (gridIds.size === 0) return []; + const out = []; + for (const gid of gridIds) { + for (const e of ctx.db.grid.gridEntity.gridId.filter(gid)) out.push(e); + } + return out; + } +); + +export const myPlayerUnits = spacetimedb.view( + { name: 'my_player_units', public: true }, + t.array(playerUnit.rowType), + ctx => { + const { matchIds } = myMatchAndGridIds(ctx); + if (matchIds.size === 0) return []; + const out = []; + for (const mid of matchIds) { + for (const u of ctx.db.playerUnit.matchId.filter(mid)) out.push(u); + } + return out; + } +); + +export const myMatchParticipants = spacetimedb.view( + { name: 'my_match_participants', public: true }, + t.array(matchParticipant.rowType), + ctx => { + const { matchIds } = myMatchAndGridIds(ctx); + if (matchIds.size === 0) return []; + const out = []; + for (const mid of matchIds) { + for (const p of ctx.db.matchParticipant.matchId.filter(mid)) out.push(p); + } + return out; + } +); + +// Discriminator for actor_directory rows. User = real auth_user; Npc = +// npc_actor row (built-in opponents). +const actorKind = t.enum('ActorKind', ['User', 'Npc']); +const ActorKind = { + User: { tag: 'User' as const }, + Npc: { tag: 'Npc' as const }, +}; + +function visibleActorIds(ctx: ViewCtx): Set { + const ids = new Set(); + const uid = callerUserId(ctx); + if (!uid) return ids; + ids.add(uid); + + for (const ownSeat of ctx.db.matchParticipant.userId.filter(uid)) { + for (const seat of ctx.db.matchParticipant.matchId.filter( + ownSeat.matchId + )) { + ids.add(seat.userId); + } + } + + for (const pending of ctx.db.match.status.filter(MatchStatus.Waiting)) { + for (const seat of ctx.db.matchParticipant.matchId.filter( + pending.matchId + )) { + if (seat.seatIdx === 0) ids.add(seat.userId); + } + } + return ids; +} + +// Safe profile fields for actors the caller can currently encounter: the +// caller, participants in their matches, and hosts of joinable matches. +export const actorDirectory = spacetimedb.view( + { name: 'actor_directory', public: true }, + t.array( + t.object('ActorDirectoryRow', { + actorId: t.string(), + name: t.option(t.string()), + image: t.option(t.string()), + kind: actorKind, + }) + ), + ctx => { + const out = []; + for (const actorId of visibleActorIds(ctx)) { + const user = ctx.db.auth.authUser.userId.find(actorId); + if (user) { + out.push({ + actorId: user.userId, + name: user.name, + image: user.image, + kind: ActorKind.User, + }); + continue; + } + const npc = ctx.db.npcActor.actorId.find(actorId); + if (npc) { + out.push({ + actorId: npc.actorId, + name: npc.name, + image: npc.image, + kind: ActorKind.Npc, + }); + } + } + return out; + } +); + +// Public discovery of joinable matches. Anyone signed in can see Waiting +// matches and call join_match. +export const lobbyOpenMatches = spacetimedb.view( + { name: 'lobby_open_matches', public: true }, + t.array( + t.object('LobbyOpenMatch', { + matchId: t.u64(), + hostUserId: t.string(), + createdAt: t.timestamp(), + }) + ), + ctx => { + const out = []; + for (const m of ctx.db.match.status.filter(MatchStatus.Waiting)) { + const seat0 = [...ctx.db.matchParticipant.matchId.filter(m.matchId)].find( + p => p.seatIdx === 0 + ); + if (!seat0) continue; + out.push({ + matchId: m.matchId, + hostUserId: seat0.userId, + createdAt: m.createdAt, + }); + } + return out; + } +); diff --git a/spacetime-grid-ts/example/spacetimedb/tsconfig.json b/spacetime-grid-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..c18065b7cb8 --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-grid-ts/example/src/app.ts b/spacetime-grid-ts/example/src/app.ts new file mode 100644 index 00000000000..34ee330f7dc --- /dev/null +++ b/spacetime-grid-ts/example/src/app.ts @@ -0,0 +1,502 @@ +// STDB connection + game procedures + auth flow. +// +// Two facades on window: +// - window.auth: signup / login / logout / oauth / session lifecycle. +// - window.grid: game ops (create_match, join_match, move_unit, attack_unit, +// end_turn, getCellsInRange, setActiveMatch). +// +// Page load attempts a cookie-based session refresh, connects to SpacetimeDB, +// and calls link_connection so visibility filters return the user's data. +// Anonymous visitors see the login card. + +import { + DbConnection, + type ErrorContext, + type SubscriptionHandle, +} from './codegen/app'; +interface AuthUser { + userId: string; + email: string; + emailVerified: boolean; + name?: string; + image?: string; +} +interface AuthMe { + user: AuthUser; + sessionExpiresAt: number; +} + +interface ReachableCell { + x: number; + y: number; + cost: number; +} + +declare global { + interface Window { + auth?: { + signup: (args: { + email: string; + password: string; + name?: string; + }) => Promise; + login: (args: { email: string; password: string }) => Promise; + logout: () => Promise; + oauthStart: (provider: 'google' | 'github') => void; + forgotPassword: (email: string) => Promise; + requestEmailVerify: () => Promise; + }; + grid?: { + createMatch: ( + vsAi: boolean + ) => Promise<{ matchId: bigint; gridId: bigint }>; + joinMatch: (matchId: bigint) => Promise; + setActiveMatch: (matchId: bigint | null) => void; + moveUnit: ( + entityId: bigint, + toX: number, + toY: number + ) => Promise<{ path: Array<{ x: number; y: number }> }>; + attackUnit: (attackerId: bigint, targetId: bigint) => Promise; + endTurn: (matchId: bigint) => Promise; + getCellsInRange: ( + gridId: bigint, + originX: number, + originY: number, + maxCost: number + ) => Promise; + AI_BOT_USER_ID: string; + }; + } +} + +type ConnState = 'idle' | 'connecting' | 'connected' | 'error'; + +// Module state + +let currentConn: DbConnection | null = null; +let globalSub: SubscriptionHandle | null = null; +let matchSub: SubscriptionHandle | null = null; +let activeMatchId: bigint | null = null; +type ServerConfig = { + stdbUri: string; + appDatabase: string; + oauth?: { + google?: boolean; + github?: boolean; + }; +}; + +let serverCfg: ServerConfig | null = null; + +let currentUser: AuthUser | null = null; +let currentExp: number | undefined; + +let reconnectAttempt = 0; +let reconnectTimer: ReturnType | null = null; +const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; + +// Browser event bus +function dispatch(name: string, detail: unknown): void { + window.dispatchEvent(new CustomEvent(name, { detail })); +} +function broadcastConn(state: ConnState, detail?: string): void { + dispatch('grid:conn', { state, detail }); +} +function broadcastAuth(): void { + dispatch('grid:auth', { user: currentUser, sessionExpiresAt: currentExp }); +} +function broadcastState(): void { + const myUserId = currentUser?.userId; + if (!currentConn) { + dispatch('grid:state', { + myUserId, + matches: [], + activeMatchId, + activeMatch: null, + activeGrid: null, + units: [], + entities: [], + cells: [], + unitTypes: [], + actors: [], + openMatches: [], + }); + return; + } + const c = currentConn; + const matchList = [...c.db.myMatches.iter()].sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? 1 : av > bv ? -1 : 0; + }); + const activeMatch = + activeMatchId !== null + ? (matchList.find(m => m.matchId === activeMatchId) ?? null) + : null; + const activeGrid = activeMatch + ? ([...c.db.myGrids.iter()].find(g => g.id === activeMatch.gridId) ?? null) + : null; + const activeUnits = + activeMatchId !== null + ? [...c.db.myPlayerUnits.iter()].filter(u => u.matchId === activeMatchId) + : []; + const activeEntities = activeGrid + ? [...c.db.myGridEntities.iter()].filter(e => e.gridId === activeGrid.id) + : []; + const activeCells = activeGrid + ? [...c.db.myCellStates.iter()].filter(c2 => c2.gridId === activeGrid.id) + : []; + dispatch('grid:state', { + myUserId, + matches: matchList, + participants: [...c.db.myMatchParticipants.iter()], + activeMatchId, + activeMatch, + activeGrid, + units: activeUnits, + entities: activeEntities, + cells: activeCells, + unitTypes: [...c.db.unitType.iter()], + actors: [...c.db.actorDirectory.iter()], + openMatches: [...c.db.lobbyOpenMatches.iter()], + }); +} + +function requireConn(): DbConnection { + if (!currentConn) throw new Error('STDB not connected'); + return currentConn; +} + +// Authentication requests +async function callJson(path: string, body?: unknown): Promise { + const r = await fetch(path, { + method: body !== undefined ? 'POST' : 'GET', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'same-origin', + }); + let data: unknown = null; + try { + data = await r.json(); + } catch { + /* empty */ + } + if (!r.ok) { + const err = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${r.status}`; + throw new Error(err); + } + return data as T; +} + +async function loadServerConfig(): Promise { + const res = await fetch('/api/config', { credentials: 'same-origin' }); + if (!res.ok) throw new Error(`/api/config returned ${res.status}`); + const cfg = (await res.json()) as ServerConfig; + dispatch('auth:server-config', cfg); + return cfg; +} + +// SpacetimeDB connection +function buildConnection(uri: string, db: string): Promise { + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(uri) + .withDatabaseName(db) + .onConnect(c => resolve(c)) + .onDisconnect((_ctx, err) => { + broadcastConn('error', err?.message ?? 'disconnected'); + currentConn = null; + globalSub = null; + matchSub = null; + if (currentUser) scheduleReconnect(); + }) + .onConnectError((_ctx, err) => { + broadcastConn('error', err?.message ?? 'connect failed'); + reject(err); + }) + .build(); + }); +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + const delay = + RECONNECT_DELAYS_MS[ + Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1) + ]; + console.warn( + `STDB reconnect in ${delay}ms (attempt ${reconnectAttempt + 1})` + ); + reconnectTimer = setTimeout(async () => { + reconnectTimer = null; + reconnectAttempt++; + if (!currentUser) return; + try { + const r = await callJson<{ + user: AuthUser; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + reconnectAttempt = 0; + } catch (err) { + console.error('Reconnect failed:', err); + scheduleReconnect(); + } + }, delay); +} + +function setActiveMatch(matchId: bigint | null): void { + if (activeMatchId === matchId) return; + activeMatchId = matchId; + + if (matchSub) { + matchSub.unsubscribe(); + matchSub = null; + } + + if (matchId === null || !currentConn) { + broadcastState(); + return; + } + + const m = currentConn + ? [...currentConn.db.myMatches.iter()].find(x => x.matchId === matchId) + : undefined; + if (!m) { + broadcastState(); + return; + } + + // Per-active-match subscription for the grid entities and cell states in + // this match's grid, plus the player_unit rows for this match. The match + // row + unit_type rows + auth_user rows are already in the global sub. + matchSub = currentConn + .subscriptionBuilder() + .onApplied(() => broadcastState()) + .onError((ctx: ErrorContext) => console.error('match sub error', ctx.event)) + .subscribe([ + `SELECT * FROM my_player_units WHERE match_id = ${matchId}`, + `SELECT * FROM my_grid_entities WHERE grid_id = ${m.gridId}`, + `SELECT * FROM my_cell_states WHERE grid_id = ${m.gridId}`, + `SELECT * FROM my_grids WHERE id = ${m.gridId}`, + ]); +} + +function wireRowHandlers(conn: DbConnection): void { + const tables = [ + conn.db.myMatches, + conn.db.myMatchParticipants, + conn.db.myPlayerUnits, + conn.db.unitType, + conn.db.myGrids, + conn.db.myGridEntities, + conn.db.myCellStates, + conn.db.actorDirectory, + conn.db.lobbyOpenMatches, + ]; + for (const t of tables) { + t.onInsert(() => broadcastState()); + t.onUpdate(() => broadcastState()); + t.onDelete(() => broadcastState()); + } +} + +async function bindSession( + token: string, + user: AuthUser, + exp: number +): Promise { + currentUser = user; + currentExp = exp; + + if (!serverCfg) serverCfg = await loadServerConfig(); + + if (!currentConn) { + broadcastConn('connecting'); + try { + const conn = await buildConnection( + serverCfg.stdbUri, + serverCfg.appDatabase + ); + currentConn = conn; + reconnectAttempt = 0; + broadcastConn('connected'); + + broadcastState(); + + wireRowHandlers(conn); + + globalSub = conn + .subscriptionBuilder() + .onApplied(() => broadcastState()) + .onError((ctx: ErrorContext) => + console.error('global sub error', ctx.event) + ) + .subscribe([ + 'SELECT * FROM my_matches', + 'SELECT * FROM my_match_participants', + 'SELECT * FROM unit_type', + 'SELECT * FROM actor_directory', + 'SELECT * FROM lobby_open_matches', + ]); + + // Re-open per-match subscription if a match was active before reconnect. + const previousActive = activeMatchId; + activeMatchId = null; + matchSub = null; + if (previousActive !== null) setActiveMatch(previousActive); + } catch (err) { + broadcastConn('error', err instanceof Error ? err.message : String(err)); + return; + } + } + + try { + await currentConn.reducers.linkConnection({ sessionToken: token }); + } catch (err) { + console.warn('link_connection failed', err); + } + + broadcastAuth(); + broadcastState(); +} + +async function restoreSession(): Promise { + try { + const r = await callJson<{ + user: AuthUser; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + return true; + } catch { + return false; + } +} + +// Authentication flows +async function signup(args: { + email: string; + password: string; + name?: string; +}): Promise { + const r = await callJson<{ token: string }>('/auth/password/signup', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} +async function login(args: { email: string; password: string }): Promise { + const r = await callJson<{ token: string }>('/auth/password/login', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} +async function logout(): Promise { + globalSub?.unsubscribe(); + globalSub = null; + matchSub?.unsubscribe(); + matchSub = null; + if (currentConn) { + try { + await currentConn.reducers.unlinkConnection({}); + } catch { + /* ignore */ + } + } + try { + await callJson('/auth/logout', {}); + } catch { + /* ignore */ + } + currentUser = null; + currentExp = undefined; + activeMatchId = null; + broadcastAuth(); + broadcastState(); +} +function oauthStart(provider: 'google' | 'github'): void { + window.location.href = `/auth/${provider}/start?redirectTo=/`; +} +async function forgotPassword(email: string): Promise { + await callJson('/auth/password/forgot', { email }); +} +async function requestEmailVerify(): Promise { + await callJson('/auth/email/verify-request', {}); +} + +// Application startup +async function main(): Promise { + window.auth = { + signup, + login, + logout, + oauthStart, + forgotPassword, + requestEmailVerify, + }; + + window.grid = { + AI_BOT_USER_ID: 'ai-bot-001', + createMatch: async vsAi => requireConn().procedures.createMatch({ vsAi }), + joinMatch: async matchId => { + await requireConn().procedures.joinMatch({ matchId }); + }, + setActiveMatch, + moveUnit: async (entityId, toX, toY) => { + const r = await requireConn().procedures.moveUnit({ entityId, toX, toY }); + return { path: r.path }; + }, + attackUnit: async (attackerId, targetId) => { + await requireConn().procedures.attackUnit({ attackerId, targetId }); + }, + endTurn: async matchId => { + await requireConn().procedures.endTurn({ matchId }); + // If the new active seat belongs to the built-in AI, prod it to play. + // Pause briefly so the player can see the turn flip in the UI. + const conn = currentConn; + const m = conn + ? [...conn.db.myMatches.iter()].find(x => x.matchId === matchId) + : undefined; + const seatUserId = + conn && m + ? [...conn.db.myMatchParticipants.iter()].find( + p => p.matchId === matchId && p.seatIdx === m.currentSeatIdx + )?.userId + : undefined; + if (m && m.status.tag === 'Active' && seatUserId === 'ai-bot-001') { + await new Promise(r => setTimeout(r, 400)); + try { + const result = await requireConn().procedures.aiTakeTurn({ matchId }); + // Hand the events to the renderer so it can sequence: + // move animation → pause → attack flash → target HP drop / death. + dispatch('grid:ai-events', { events: result.events }); + } catch (err) { + console.error('ai_take_turn failed:', err); + } + } + }, + getCellsInRange: async (gridId, originX, originY, maxCost) => { + const r = await requireConn().procedures.getCellsInRange({ + gridId, + originX, + originY, + maxCost, + }); + return r.cells; + }, + }; + + broadcastConn('idle'); + serverCfg = await loadServerConfig(); + await restoreSession(); + dispatch('grid:ready', {}); +} + +main().catch(err => { + console.error(err); + broadcastConn('error', err instanceof Error ? err.message : String(err)); +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/actor_directory_table.ts b/spacetime-grid-ts/example/src/codegen/app/actor_directory_table.ts new file mode 100644 index 00000000000..a6151ec4368 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/actor_directory_table.ts @@ -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 { + ActorKind, +} from "./types"; + + +export default __t.row({ + actorId: __t.string().name("actor_id"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + get kind() { + return ActorKind; + }, +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/ai_take_turn_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/ai_take_turn_procedure.ts new file mode 100644 index 00000000000..8f540212e08 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/ai_take_turn_procedure.ts @@ -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"; + +import { + AiTakeTurnResult, +} from "./types"; + +export const params = { + matchId: __t.u64(), +}; +export const returnType = AiTakeTurnResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/attack_unit_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/attack_unit_procedure.ts new file mode 100644 index 00000000000..6416ac0c492 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/attack_unit_procedure.ts @@ -0,0 +1,17 @@ +// 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 const params = { + attackerId: __t.u64(), + targetId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/link_connection_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/my_auth_user_table.ts b/spacetime-grid-ts/example/src/codegen/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/types.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/revoke_session_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/types.ts b/spacetime-grid-ts/example/src/codegen/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/types.ts @@ -0,0 +1,137 @@ +// 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 const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/update_profile_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/whoami_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/auth/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/create_match_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/create_match_procedure.ts new file mode 100644 index 00000000000..19e91eb52ec --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/create_match_procedure.ts @@ -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"; + +import { + CreateMatchResult, +} from "./types"; + +export const params = { + vsAi: __t.bool(), +}; +export const returnType = CreateMatchResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/end_turn_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/end_turn_procedure.ts new file mode 100644 index 00000000000..c1f25720383 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/end_turn_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + matchId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/get_auth_public_key_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/get_cells_in_range_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/get_cells_in_range_procedure.ts new file mode 100644 index 00000000000..486c44aee70 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/get_cells_in_range_procedure.ts @@ -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 { + CellsInRangeResult, +} from "./types"; + +export const params = { + gridId: __t.u64(), + originX: __t.i32(), + originY: __t.i32(), + maxCost: __t.i32(), +}; +export const returnType = CellsInRangeResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/grid/types.ts b/spacetime-grid-ts/example/src/codegen/app/grid/types.ts new file mode 100644 index 00000000000..48f7c6524bd --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/grid/types.ts @@ -0,0 +1,69 @@ +// 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 const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const EntityPath = __t.object("EntityPath", { + entityId: __t.u64(), + gridId: __t.u64(), + get cells() { + return __t.array(PathCell); + }, + cost: __t.i32(), + computedAt: __t.timestamp(), +}); +export type EntityPath = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const PathCell = __t.object("PathCell", { + x: __t.i32(), + y: __t.i32(), +}); +export type PathCell = __Infer; + diff --git a/spacetime-grid-ts/example/src/codegen/app/index.ts b/spacetime-grid-ts/example/src/codegen/app/index.ts new file mode 100644 index 00000000000..05a46c2eea7 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/index.ts @@ -0,0 +1,360 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 LinkConnectionReducer from "./link_connection_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; + +// Import all procedure arg schemas +import * as AiTakeTurnProcedure from "./ai_take_turn_procedure"; +import * as AttackUnitProcedure from "./attack_unit_procedure"; +import * as CreateMatchProcedure from "./create_match_procedure"; +import * as EndTurnProcedure from "./end_turn_procedure"; +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as GetCellsInRangeProcedure from "./get_cells_in_range_procedure"; +import * as JoinMatchProcedure from "./join_match_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as MoveUnitProcedure from "./move_unit_procedure"; +import * as WhoamiProcedure from "./whoami_procedure"; + +// Import all table schema definitions +import ActorDirectoryRow from "./actor_directory_table"; +import LobbyOpenMatchesRow from "./lobby_open_matches_table"; +import MyAuthUserRow from "./my_auth_user_table"; +import MyCellStatesRow from "./my_cell_states_table"; +import MyGridEntitiesRow from "./my_grid_entities_table"; +import MyGridsRow from "./my_grids_table"; +import MyMatchParticipantsRow from "./my_match_participants_table"; +import MyMatchesRow from "./my_matches_table"; +import MyPlayerUnitsRow from "./my_player_units_table"; +import NpcActorRow from "./npc_actor_table"; +import UnitTypeRow from "./unit_type_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; + +/** 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({ + npcActor: __table({ + name: 'npc_actor', + indexes: [ + { accessor: 'actorId', name: 'npc_actor_actor_id_idx_btree', algorithm: 'btree', columns: [ + 'actorId', + ] }, + ], + constraints: [ + { name: 'npc_actor_actor_id_key', constraint: 'unique', columns: ['actorId'] }, + ], + }, NpcActorRow), + unitType: __table({ + name: 'unit_type', + indexes: [ + { accessor: 'typeId', name: 'unit_type_type_id_idx_btree', algorithm: 'btree', columns: [ + 'typeId', + ] }, + ], + constraints: [ + { name: 'unit_type_type_id_key', constraint: 'unique', columns: ['typeId'] }, + ], + }, UnitTypeRow), + actorDirectory: __table({ + name: 'actor_directory', + indexes: [ + ], + constraints: [ + ], + }, ActorDirectoryRow), + lobbyOpenMatches: __table({ + name: 'lobby_open_matches', + indexes: [ + ], + constraints: [ + ], + }, LobbyOpenMatchesRow), + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myCellStates: __table({ + name: 'my_cell_states', + indexes: [ + ], + constraints: [ + ], + }, MyCellStatesRow), + myGridEntities: __table({ + name: 'my_grid_entities', + indexes: [ + ], + constraints: [ + ], + }, MyGridEntitiesRow), + myGrids: __table({ + name: 'my_grids', + indexes: [ + ], + constraints: [ + ], + }, MyGridsRow), + myMatchParticipants: __table({ + name: 'my_match_participants', + indexes: [ + ], + constraints: [ + ], + }, MyMatchParticipantsRow), + myMatches: __table({ + name: 'my_matches', + indexes: [ + ], + constraints: [ + ], + }, MyMatchesRow), + myPlayerUnits: __table({ + name: 'my_player_units', + indexes: [ + ], + constraints: [ + ], + }, MyPlayerUnitsRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), +}); + +/** 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( + __reducerSchema("link_connection", LinkConnectionReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), +); + +/** 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("ai_take_turn", AiTakeTurnProcedure.params, AiTakeTurnProcedure.returnType), + __procedureSchema("attack_unit", AttackUnitProcedure.params, AttackUnitProcedure.returnType), + __procedureSchema("create_match", CreateMatchProcedure.params, CreateMatchProcedure.returnType), + __procedureSchema("end_turn", EndTurnProcedure.params, EndTurnProcedure.returnType), + __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("get_cells_in_range", GetCellsInRangeProcedure.params, GetCellsInRangeProcedure.returnType), + __procedureSchema("join_match", JoinMatchProcedure.params, JoinMatchProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("move_unit", MoveUnitProcedure.params, MoveUnitProcedure.returnType), + __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + npcActor: __qb.npcActor, + unitType: __qb.unitType, + actorDirectory: __qb.actorDirectory, + lobbyOpenMatches: __qb.lobbyOpenMatches, + myAuthUser: __qb.myAuthUser, + myCellStates: __qb.myCellStates, + myGridEntities: __qb.myGridEntities, + myGrids: __qb.myGrids, + myMatchParticipants: __qb.myMatchParticipants, + myMatches: __qb.myMatches, + myPlayerUnits: __qb.myPlayerUnits, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + linkConnection: __reducerAccessors.linkConnection, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + setAuthConfig: __reducerAccessors.setAuthConfig, + unlinkConnection: __reducerAccessors.unlinkConnection, + updateProfile: __reducerAccessors.updateProfile, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + aiTakeTurn: __procedureAccessors.aiTakeTurn, + attackUnit: __procedureAccessors.attackUnit, + createMatch: __procedureAccessors.createMatch, + endTurn: __procedureAccessors.endTurn, + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + getCellsInRange: __procedureAccessors.getCellsInRange, + joinMatch: __procedureAccessors.joinMatch, + listMySessions: __procedureAccessors.listMySessions, + moveUnit: __procedureAccessors.moveUnit, + whoami: __procedureAccessors.whoami, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-grid-ts/example/src/codegen/app/join_match_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/join_match_procedure.ts new file mode 100644 index 00000000000..c1f25720383 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/join_match_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + matchId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/link_connection_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/list_my_sessions_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/lobby_open_matches_table.ts b/spacetime-grid-ts/example/src/codegen/app/lobby_open_matches_table.ts new file mode 100644 index 00000000000..96bdadeae1c --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/lobby_open_matches_table.ts @@ -0,0 +1,17 @@ +// 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({ + matchId: __t.u64().name("match_id"), + hostUserId: __t.string().name("host_user_id"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/move_unit_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/move_unit_procedure.ts new file mode 100644 index 00000000000..75f2e273fb6 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/move_unit_procedure.ts @@ -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"; + +import { + MoveUnitResult, +} from "./types"; + +export const params = { + entityId: __t.u64(), + toX: __t.i32(), + toY: __t.i32(), +}; +export const returnType = MoveUnitResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/my_auth_user_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_cell_states_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_cell_states_table.ts new file mode 100644 index 00000000000..9f1a3bfc694 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/my_cell_states_table.ts @@ -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().primaryKey(), + gridId: __t.u64().name("grid_id"), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_grid_entities_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_grid_entities_table.ts new file mode 100644 index 00000000000..1b75cfb7ea0 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/my_grid_entities_table.ts @@ -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({ + id: __t.u64().primaryKey(), + gridId: __t.u64().name("grid_id"), + ownerUserId: __t.string().name("owner_user_id"), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool().name("blocks_movement"), + label: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_grids_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_grids_table.ts new file mode 100644 index 00000000000..ba03c161ea6 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/my_grids_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + ownerUserId: __t.string().name("owner_user_id"), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32().name("default_cost"), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_match_participants_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_match_participants_table.ts new file mode 100644 index 00000000000..2a912a235b9 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/my_match_participants_table.ts @@ -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().primaryKey(), + matchId: __t.u64().name("match_id"), + userId: __t.string().name("user_id"), + seatIdx: __t.i32().name("seat_idx"), + team: __t.i32(), + joinedAt: __t.timestamp().name("joined_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_matches_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_matches_table.ts new file mode 100644 index 00000000000..ca03504bff9 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/my_matches_table.ts @@ -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 { + MatchStatus, +} from "./types"; + + +export default __t.row({ + matchId: __t.u64().primaryKey().name("match_id"), + get status() { + return MatchStatus; + }, + currentSeatIdx: __t.i32().name("current_seat_idx"), + turnNumber: __t.i32().name("turn_number"), + winnerUserId: __t.option(__t.string()).name("winner_user_id"), + gridId: __t.u64().name("grid_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_player_units_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_player_units_table.ts new file mode 100644 index 00000000000..fd18c44eb92 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/my_player_units_table.ts @@ -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({ + entityId: __t.u64().primaryKey().name("entity_id"), + matchId: __t.u64().name("match_id"), + ownerUserId: __t.string().name("owner_user_id"), + typeId: __t.string().name("type_id"), + currentHp: __t.i32().name("current_hp"), + hasMoved: __t.bool().name("has_moved"), + hasAttacked: __t.bool().name("has_attacked"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/npc_actor_table.ts b/spacetime-grid-ts/example/src/codegen/app/npc_actor_table.ts new file mode 100644 index 00000000000..c4dd4173c26 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/npc_actor_table.ts @@ -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({ + actorId: __t.string().primaryKey().name("actor_id"), + name: __t.string(), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/revoke_my_session_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/revoke_session_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/set_auth_config_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/types.ts b/spacetime-grid-ts/example/src/codegen/app/types.ts new file mode 100644 index 00000000000..4727e25df0a --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/types.ts @@ -0,0 +1,276 @@ +// 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 const ActorDirectory = __t.object("ActorDirectory", {}); +export type ActorDirectory = __Infer; + +export const ActorDirectoryRow = __t.object("ActorDirectoryRow", { + actorId: __t.string(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + get kind() { + return ActorKind; + }, +}); +export type ActorDirectoryRow = __Infer; + +// The tagged union or sum type for the algebraic type `ActorKind`. +export const ActorKind = __t.enum("ActorKind", { + User: __t.unit(), + Npc: __t.unit(), +}); +export type ActorKind = __Infer; + +export const AiAttackInfo = __t.object("AiAttackInfo", { + targetId: __t.u64(), + damage: __t.i32(), + killed: __t.bool(), + targetX: __t.i32(), + targetY: __t.i32(), + targetOwner: __t.string(), + targetTypeId: __t.string(), + targetPreHp: __t.i32(), +}); +export type AiAttackInfo = __Infer; + +export const AiPathStep = __t.object("AiPathStep", { + x: __t.i32(), + y: __t.i32(), +}); +export type AiPathStep = __Infer; + +export const AiTakeTurnResult = __t.object("AiTakeTurnResult", { + get events() { + return __t.array(AiTurnEvent); + }, +}); +export type AiTakeTurnResult = __Infer; + +export const AiTurnEvent = __t.object("AiTurnEvent", { + entityId: __t.u64(), + get movePath() { + return __t.option(__t.array(AiPathStep)); + }, + get attack() { + return __t.option(AiAttackInfo); + }, +}); +export type AiTurnEvent = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const CellsInRangeResult = __t.object("CellsInRangeResult", { + get cells() { + return __t.array(ReachableCell); + }, +}); +export type CellsInRangeResult = __Infer; + +export const CreateMatchResult = __t.object("CreateMatchResult", { + matchId: __t.u64(), + gridId: __t.u64(), +}); +export type CreateMatchResult = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridAuthUser = __t.object("GridAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridAuthUser = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const LobbyOpenMatch = __t.object("LobbyOpenMatch", { + matchId: __t.u64(), + hostUserId: __t.string(), + createdAt: __t.timestamp(), +}); +export type LobbyOpenMatch = __Infer; + +export const LobbyOpenMatches = __t.object("LobbyOpenMatches", {}); +export type LobbyOpenMatches = __Infer; + +export const Match = __t.object("Match", { + matchId: __t.u64(), + get status() { + return MatchStatus; + }, + currentSeatIdx: __t.i32(), + turnNumber: __t.i32(), + winnerUserId: __t.option(__t.string()), + gridId: __t.u64(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Match = __Infer; + +export const MatchParticipant = __t.object("MatchParticipant", { + id: __t.u64(), + matchId: __t.u64(), + userId: __t.string(), + seatIdx: __t.i32(), + team: __t.i32(), + joinedAt: __t.timestamp(), +}); +export type MatchParticipant = __Infer; + +// The tagged union or sum type for the algebraic type `MatchStatus`. +export const MatchStatus = __t.enum("MatchStatus", { + Waiting: __t.unit(), + Active: __t.unit(), + Ended: __t.unit(), +}); +export type MatchStatus = __Infer; + +export const MoveStep = __t.object("MoveStep", { + x: __t.i32(), + y: __t.i32(), +}); +export type MoveStep = __Infer; + +export const MoveUnitResult = __t.object("MoveUnitResult", { + get path() { + return __t.array(MoveStep); + }, +}); +export type MoveUnitResult = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyCellStates = __t.object("MyCellStates", {}); +export type MyCellStates = __Infer; + +export const MyGridEntities = __t.object("MyGridEntities", {}); +export type MyGridEntities = __Infer; + +export const MyGrids = __t.object("MyGrids", {}); +export type MyGrids = __Infer; + +export const MyMatchParticipants = __t.object("MyMatchParticipants", {}); +export type MyMatchParticipants = __Infer; + +export const MyMatches = __t.object("MyMatches", {}); +export type MyMatches = __Infer; + +export const MyPlayerUnits = __t.object("MyPlayerUnits", {}); +export type MyPlayerUnits = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const NpcActor = __t.object("NpcActor", { + actorId: __t.string(), + name: __t.string(), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type NpcActor = __Infer; + +export const PlayerUnit = __t.object("PlayerUnit", { + entityId: __t.u64(), + matchId: __t.u64(), + ownerUserId: __t.string(), + typeId: __t.string(), + currentHp: __t.i32(), + hasMoved: __t.bool(), + hasAttacked: __t.bool(), + createdAt: __t.timestamp(), +}); +export type PlayerUnit = __Infer; + +export const ReachableCell = __t.object("ReachableCell", { + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), +}); +export type ReachableCell = __Infer; + +export const UnitType = __t.object("UnitType", { + typeId: __t.string(), + name: __t.string(), + movement: __t.i32(), + attackRange: __t.i32(), + attackDmg: __t.i32(), + hp: __t.i32(), + glyph: __t.string(), +}); +export type UnitType = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-grid-ts/example/src/codegen/app/types/procedures.ts b/spacetime-grid-ts/example/src/codegen/app/types/procedures.ts new file mode 100644 index 00000000000..ec53ed8c107 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/types/procedures.ts @@ -0,0 +1,40 @@ +// 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 AiTakeTurnProcedure from "../ai_take_turn_procedure"; +import * as AttackUnitProcedure from "../attack_unit_procedure"; +import * as CreateMatchProcedure from "../create_match_procedure"; +import * as EndTurnProcedure from "../end_turn_procedure"; +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as GetCellsInRangeProcedure from "../get_cells_in_range_procedure"; +import * as JoinMatchProcedure from "../join_match_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as MoveUnitProcedure from "../move_unit_procedure"; +import * as WhoamiProcedure from "../whoami_procedure"; + +export type AiTakeTurnArgs = __Infer; +export type AiTakeTurnResult = __Infer; +export type AttackUnitArgs = __Infer; +export type AttackUnitResult = __Infer; +export type CreateMatchArgs = __Infer; +export type CreateMatchResult = __Infer; +export type EndTurnArgs = __Infer; +export type EndTurnResult = __Infer; +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type GetCellsInRangeArgs = __Infer; +export type GetCellsInRangeResult = __Infer; +export type JoinMatchArgs = __Infer; +export type JoinMatchResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type MoveUnitArgs = __Infer; +export type MoveUnitResult = __Infer; +export type WhoamiArgs = __Infer; +export type WhoamiResult = __Infer; + diff --git a/spacetime-grid-ts/example/src/codegen/app/types/reducers.ts b/spacetime-grid-ts/example/src/codegen/app/types/reducers.ts new file mode 100644 index 00000000000..db11ee2a71f --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/types/reducers.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import LinkConnectionReducer from "../link_connection_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; + +export type LinkConnectionParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UpdateProfileParams = __Infer; + diff --git a/spacetime-grid-ts/example/src/codegen/app/unit_type_table.ts b/spacetime-grid-ts/example/src/codegen/app/unit_type_table.ts new file mode 100644 index 00000000000..7cdd49c6677 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/unit_type_table.ts @@ -0,0 +1,21 @@ +// 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({ + typeId: __t.string().primaryKey().name("type_id"), + name: __t.string(), + movement: __t.i32(), + attackRange: __t.i32().name("attack_range"), + attackDmg: __t.i32().name("attack_dmg"), + hp: __t.i32(), + glyph: __t.string(), +}); diff --git a/spacetime-grid-ts/example/src/codegen/app/unlink_connection_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-grid-ts/example/src/codegen/app/update_profile_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/codegen/app/whoami_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-grid-ts/example/src/codegen/app/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-grid-ts/example/tsconfig.json b/spacetime-grid-ts/example/tsconfig.json new file mode 100644 index 00000000000..9b159ac1913 --- /dev/null +++ b/spacetime-grid-ts/example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-grid-ts/package.json b/spacetime-grid-ts/package.json new file mode 100644 index 00000000000..4285e6cbaba --- /dev/null +++ b/spacetime-grid-ts/package.json @@ -0,0 +1,72 @@ +{ + "name": "@spacetimedb/grid", + "description": "Square and hex grid storage, pathfinding, range, and entity movement for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./rows": { + "types": "./src/rows.ts", + "default": "./src/rows.ts" + }, + "./procedures": { + "types": "./src/procedures.ts", + "default": "./src/procedures.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + }, + "./math": { + "types": "./src/math/index.ts", + "default": "./src/math/index.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-grid-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-grid-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "grid", + "pathfinding", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test-pathfind.ts" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.22.3", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-grid-ts/scripts/test-pathfind.ts b/spacetime-grid-ts/scripts/test-pathfind.ts new file mode 100644 index 00000000000..3c05c52f49c --- /dev/null +++ b/spacetime-grid-ts/scripts/test-pathfind.ts @@ -0,0 +1,228 @@ +// Minimal sanity tests for pathfind.ts. Run: pnpm exec tsx scripts/test-pathfind.ts + +import { + type Coord, + neighbors, + distance, + findPathAstar, + dijkstra, + coordKey, +} from '../src/math/index.ts'; + +let pass = 0; +let fail = 0; +function check(name: string, cond: boolean, extra?: string): void { + if (cond) { + pass++; + process.stdout.write(` ${name} OK\n`); + } else { + fail++; + process.stdout.write(` ${name} FAIL ${extra ?? ''}\n`); + } +} + +// Helper: build a sparse obstacle map from a 2D character grid. +// '.' = open (cost 1) +// '#' = wall (cost -1) +// digits = explicit cost +function buildGrid(rows: string[]) { + const h = rows.length; + const w = rows[0].length; + const obstacles = new Map(); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const ch = rows[y][x]; + if (ch === '#') obstacles.set(coordKey({ x, y }), -1); + else if (ch >= '2' && ch <= '9') + obstacles.set(coordKey({ x, y }), Number(ch)); + } + } + const cost = (c: Coord): number => obstacles.get(coordKey(c)) ?? 1; + const inBounds = (c: Coord) => c.x >= 0 && c.y >= 0 && c.x < w && c.y < h; + const nb = (c: Coord) => neighbors('square', c, 4).filter(inBounds); + return { w, h, cost, neighbors: nb }; +} + +// A* test cases +process.stdout.write('A* tests\n'); + +// 1. Trivial: start == goal +{ + const g = buildGrid(['..']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 0, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + }); + check( + 'start equals goal -> found, 1 cell, 0 cost', + r.found && r.cells.length === 1 && r.cost === 0 + ); +} + +// 2. Straight line, no obstacles +{ + const g = buildGrid(['.....']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + check( + '5-cell straight line -> cost 4, 5 cells', + r.found && r.cost === 4 && r.cells.length === 5 + ); +} + +// 3. Around a wall +{ + const g = buildGrid(['.....', '...#.', '...#.', '.....']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + check('straight path around no obstacle -> cost 4', r.found && r.cost === 4); +} + +// 4. Goal walled off +{ + const g = buildGrid(['...#.', '...#.', '...#.', '...#.']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + check('goal unreachable -> found:false', !r.found); +} + +// 5. Forced detour +{ + const g = buildGrid(['..#..', '..#..', '..#..', '.....']); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + // 0,0 -> 0,3 -> 4,3 -> 4,0 = 3 + 4 + 3 = 10 + check( + 'forced detour around vertical wall -> cost 10', + r.found && r.cost === 10, + r.found ? `(got ${r.cost})` : '' + ); +} + +// 6. Weighted terrain (swamp cost=3) +{ + const g = buildGrid(['.....', '.333.', '.333.', '.....']); + const straight = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 4, y: 3 }, + cost: g.cost, + neighbors: g.neighbors, + heuristic: (a, b) => distance('square', a, b, 4), + }); + // Cheapest is around the swamp via edges. + check('weighted terrain finds path', straight.found); + // 0,0 -> 4,0 -> 4,3 = cost 4 + 3 = 7 (all open). + check( + 'weighted terrain picks cheap route (cost 7)', + straight.found && straight.cost === 7, + straight.found ? `(got ${straight.cost})` : '' + ); +} + +// 7. Hex grid: 3 cells apart in axial coords +{ + const cost = (_c: Coord) => 1; + const nb = (c: Coord) => neighbors('hex', c); + const r = findPathAstar({ + start: { x: 0, y: 0 }, + goal: { x: 3, y: 0 }, + cost, + neighbors: nb, + heuristic: (a, b) => distance('hex', a, b), + }); + check( + 'hex 3 cells apart -> cost 3, 4 cells', + r.found && r.cost === 3 && r.cells.length === 4 + ); +} + +// Dijkstra test cases +process.stdout.write('\nDijkstra tests\n'); + +// 8. 5x5 open grid, range = 2 -> 13 cells (manhattan disk). +{ + const g = buildGrid(['.....', '.....', '.....', '.....', '.....']); + const r = dijkstra({ + start: { x: 2, y: 2 }, + cost: g.cost, + neighbors: g.neighbors, + maxCost: 2, + }); + check( + 'dijkstra radius 2 on open 5x5 -> 13 reachable', + r.size === 13, + `(got ${r.size})` + ); +} + +// 9. Range 0 returns only the start. +{ + const g = buildGrid(['.']); + const r = dijkstra({ + start: { x: 0, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + maxCost: 0, + }); + check('dijkstra range 0 -> 1 reachable (start only)', r.size === 1); +} + +// 10. Reachable record carries cost + parent. +{ + const g = buildGrid(['...', '...', '...']); + const r = dijkstra({ + start: { x: 0, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + maxCost: 4, + }); + const corner = r.get(coordKey({ x: 2, y: 2 })); + check('dijkstra: corner record exists', corner !== undefined); + check( + 'dijkstra: corner cost == 4', + corner !== undefined && corner.cost === 4, + `(got ${corner?.cost})` + ); +} + +// 11. Wall reduces reachable set. +{ + const g = buildGrid(['...', '###', '...']); + const r = dijkstra({ + start: { x: 0, y: 0 }, + cost: g.cost, + neighbors: g.neighbors, + maxCost: 100, + }); + // Only the top row is reachable. + check( + 'dijkstra: wall blocks lower half -> 3 reachable', + r.size === 3, + `(got ${r.size})` + ); +} + +process.stdout.write(`\n${pass} pass, ${fail} fail\n`); +if (fail > 0) process.exit(1); diff --git a/spacetime-grid-ts/src/index.ts b/spacetime-grid-ts/src/index.ts new file mode 100644 index 00000000000..c464a222a15 --- /dev/null +++ b/spacetime-grid-ts/src/index.ts @@ -0,0 +1,38 @@ +export { + gridRow, + cellStateRow, + gridEntityRow, + entityPathRow, + pathCell, + pathResult, + reachableCell, + GRID_KIND_SQUARE, + GRID_KIND_HEX, + GRID_ORIENTATION_FLAT, + GRID_ORIENTATION_POINTY, + GRID_MODE_OWNER, + GRID_MODE_COLLABORATIVE, +} from './rows.ts'; + +export { + createGridParams, + createGridImpl, + deleteGridParams, + deleteGridImpl, + setCellCostParams, + setCellCostImpl, + paintCellsParams, + paintCellsImpl, + placeEntityParams, + placeEntityImpl, + moveEntityParams, + moveEntityImpl, + computePathParams, + computePathReturn, + computePathImpl, + cellsInRangeParams, + cellsInRangeReturn, + cellsInRangeImpl, +} from './procedures.ts'; + +export * from './math/index.ts'; diff --git a/spacetime-grid-ts/src/math/coords.ts b/spacetime-grid-ts/src/math/coords.ts new file mode 100644 index 00000000000..ada38594d08 --- /dev/null +++ b/spacetime-grid-ts/src/math/coords.ts @@ -0,0 +1,19 @@ +// Pure coordinate types. No STDB. Square (x,y) and hex axial (q=x, r=y). + +export type Coord = { x: number; y: number }; +export type GridKind = 'square' | 'hex'; +export type HexOrientation = 'flat' | 'pointy'; +export type Connectivity = 4 | 8; + +export function coordKey(c: Coord): string { + return `${c.x},${c.y}`; +} + +export function parseCoordKey(key: string): Coord { + const i = key.indexOf(','); + return { x: Number(key.slice(0, i)), y: Number(key.slice(i + 1)) }; +} + +export function coordsEqual(a: Coord, b: Coord): boolean { + return a.x === b.x && a.y === b.y; +} diff --git a/spacetime-grid-ts/src/math/distance.ts b/spacetime-grid-ts/src/math/distance.ts new file mode 100644 index 00000000000..dc9c01cc04b --- /dev/null +++ b/spacetime-grid-ts/src/math/distance.ts @@ -0,0 +1,34 @@ +// Grid distance functions. Choose based on connectivity: +// square 4-connected: manhattan +// square 8-connected: chebyshev +// hex (any orientation): hexDistance + +import type { Coord, GridKind, Connectivity } from './coords.ts'; + +export function manhattan(a: Coord, b: Coord): number { + return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); +} + +export function chebyshev(a: Coord, b: Coord): number { + return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)); +} + +// Hex axial distance. (q,r) stored as (x,y). +export function hexDistance(a: Coord, b: Coord): number { + return ( + (Math.abs(a.x - b.x) + + Math.abs(a.x + a.y - b.x - b.y) + + Math.abs(a.y - b.y)) / + 2 + ); +} + +export function distance( + kind: GridKind, + a: Coord, + b: Coord, + connectivity: Connectivity = 4 +): number { + if (kind === 'hex') return hexDistance(a, b); + return connectivity === 8 ? chebyshev(a, b) : manhattan(a, b); +} diff --git a/spacetime-grid-ts/src/math/index.ts b/spacetime-grid-ts/src/math/index.ts new file mode 100644 index 00000000000..515b43b8471 --- /dev/null +++ b/spacetime-grid-ts/src/math/index.ts @@ -0,0 +1,22 @@ +export { + type Coord, + type GridKind, + type HexOrientation, + type Connectivity, + coordKey, + parseCoordKey, + coordsEqual, +} from './coords.ts'; + +export { neighbors } from './neighbors.ts'; + +export { manhattan, chebyshev, hexDistance, distance } from './distance.ts'; + +export { + type PathResult, + type PathfindOpts, + type DijkstraOpts, + type DijkstraNode, + findPathAstar, + dijkstra, +} from './pathfind.ts'; diff --git a/spacetime-grid-ts/src/math/neighbors.ts b/spacetime-grid-ts/src/math/neighbors.ts new file mode 100644 index 00000000000..0773a310ee9 --- /dev/null +++ b/spacetime-grid-ts/src/math/neighbors.ts @@ -0,0 +1,44 @@ +// Neighbor offsets per grid kind. Hex axial is orientation-agnostic. + +import type { Coord, Connectivity, GridKind } from './coords.ts'; + +const SQUARE_4: ReadonlyArray = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0], +]; + +const SQUARE_8: ReadonlyArray = [ + [0, -1], + [1, -1], + [1, 0], + [1, 1], + [0, 1], + [-1, 1], + [-1, 0], + [-1, -1], +]; + +const HEX_AXIAL: ReadonlyArray = [ + [1, 0], + [1, -1], + [0, -1], + [-1, 0], + [-1, 1], + [0, 1], +]; + +export function neighbors( + kind: GridKind, + c: Coord, + connectivity: Connectivity = 4 +): Coord[] { + const offsets = + kind === 'hex' ? HEX_AXIAL : connectivity === 8 ? SQUARE_8 : SQUARE_4; + const out: Coord[] = []; + for (const [dx, dy] of offsets) { + out.push({ x: c.x + dx, y: c.y + dy }); + } + return out; +} diff --git a/spacetime-grid-ts/src/math/pathfind.ts b/spacetime-grid-ts/src/math/pathfind.ts new file mode 100644 index 00000000000..ca973e4929f --- /dev/null +++ b/spacetime-grid-ts/src/math/pathfind.ts @@ -0,0 +1,189 @@ +// Pathfinding over an abstract graph. Caller injects `cost` and `neighbors` +// so this module knows nothing about STDB or grid kind. cost <= 0 = blocked. + +import type { Coord } from './coords.ts'; +import { coordKey } from './coords.ts'; + +export type PathResult = + | { found: true; cells: Coord[]; cost: number; expanded: number } + | { found: false; expanded: number }; + +export interface PathfindOpts { + start: Coord; + goal: Coord; + cost: (c: Coord) => number; + neighbors: (c: Coord) => Coord[]; + heuristic?: (c: Coord, goal: Coord) => number; + maxExpansions?: number; +} + +const DEFAULT_MAX_EXPANSIONS = 100_000; + +// Binary min-heap. Each item carries its priority `key`, avoiding an +// external decrease-key. Duplicate pushes allow stale pops to be skipped. +interface HeapEntry { + key: number; + node: Coord; + gScore: number; +} + +class MinHeap { + private items: HeapEntry[] = []; + + get size(): number { + return this.items.length; + } + + push(e: HeapEntry): void { + this.items.push(e); + this.siftUp(this.items.length - 1); + } + + pop(): HeapEntry | undefined { + const n = this.items.length; + if (n === 0) return undefined; + const top = this.items[0]; + const last = this.items.pop()!; + if (n > 1) { + this.items[0] = last; + this.siftDown(0); + } + return top; + } + + private siftUp(i: number): void { + const a = this.items; + while (i > 0) { + const p = (i - 1) >> 1; + if (a[p].key <= a[i].key) break; + [a[p], a[i]] = [a[i], a[p]]; + i = p; + } + } + + private siftDown(i: number): void { + const a = this.items; + const n = a.length; + while (true) { + const l = i * 2 + 1; + const r = l + 1; + let s = i; + if (l < n && a[l].key < a[s].key) s = l; + if (r < n && a[r].key < a[s].key) s = r; + if (s === i) break; + [a[s], a[i]] = [a[i], a[s]]; + i = s; + } + } +} + +export function findPathAstar(opts: PathfindOpts): PathResult { + const { start, goal, cost, neighbors } = opts; + const heuristic = opts.heuristic ?? (() => 0); + const cap = opts.maxExpansions ?? DEFAULT_MAX_EXPANSIONS; + + const startKey = coordKey(start); + const goalKey = coordKey(goal); + + if (startKey === goalKey) { + return { found: true, cells: [start], cost: 0, expanded: 0 }; + } + + const gScore = new Map(); + const parent = new Map(); + const open = new MinHeap(); + + gScore.set(startKey, 0); + open.push({ key: heuristic(start, goal), node: start, gScore: 0 }); + + let expanded = 0; + while (open.size > 0) { + if (expanded >= cap) break; + const cur = open.pop()!; + const curKey = coordKey(cur.node); + + // Skip stale heap entries (a better path landed after this one was pushed). + if (cur.gScore > (gScore.get(curKey) ?? Infinity)) continue; + expanded++; + + if (curKey === goalKey) { + const path: Coord[] = []; + let n: Coord | undefined = cur.node; + while (n !== undefined) { + path.unshift(n); + n = parent.get(coordKey(n)); + } + return { found: true, cells: path, cost: cur.gScore, expanded }; + } + + for (const nb of neighbors(cur.node)) { + const stepCost = cost(nb); + if (stepCost <= 0) continue; + const tentativeG = cur.gScore + stepCost; + const nbKey = coordKey(nb); + if (tentativeG < (gScore.get(nbKey) ?? Infinity)) { + gScore.set(nbKey, tentativeG); + parent.set(nbKey, cur.node); + open.push({ + key: tentativeG + heuristic(nb, goal), + node: nb, + gScore: tentativeG, + }); + } + } + } + + return { found: false, expanded }; +} + +export interface DijkstraOpts { + start: Coord; + cost: (c: Coord) => number; + neighbors: (c: Coord) => Coord[]; + maxCost: number; + maxExpansions?: number; +} + +export interface DijkstraNode { + cell: Coord; + cost: number; + from: Coord | null; +} + +// Returns all cells reachable from `start` within `maxCost`, keyed by coordKey. +export function dijkstra(opts: DijkstraOpts): Map { + const { start, cost, neighbors, maxCost } = opts; + const cap = opts.maxExpansions ?? DEFAULT_MAX_EXPANSIONS; + + const result = new Map(); + const open = new MinHeap(); + + result.set(coordKey(start), { cell: start, cost: 0, from: null }); + open.push({ key: 0, node: start, gScore: 0 }); + + let expanded = 0; + while (open.size > 0) { + if (expanded >= cap) break; + const cur = open.pop()!; + const curKey = coordKey(cur.node); + const known = result.get(curKey); + if (!known || cur.gScore > known.cost) continue; + if (cur.gScore > maxCost) break; + expanded++; + + for (const nb of neighbors(cur.node)) { + const stepCost = cost(nb); + if (stepCost <= 0) continue; + const tentative = cur.gScore + stepCost; + if (tentative > maxCost) continue; + const nbKey = coordKey(nb); + const existing = result.get(nbKey); + if (!existing || tentative < existing.cost) { + result.set(nbKey, { cell: nb, cost: tentative, from: cur.node }); + open.push({ key: tentative, node: nb, gScore: tentative }); + } + } + } + + return result; +} diff --git a/spacetime-grid-ts/src/procedures.ts b/spacetime-grid-ts/src/procedures.ts new file mode 100644 index 00000000000..d0faabce219 --- /dev/null +++ b/spacetime-grid-ts/src/procedures.ts @@ -0,0 +1,535 @@ +// Owner is passed explicitly so the submodule is identity-scheme-agnostic. +import type { Timestamp } from 'spacetimedb'; +import { + t, + SenderError, + type Infer, + type InferTypeOfParams, +} from 'spacetimedb/server'; +import { + gridRow, + pathResult, + reachableCell, + GRID_KIND_SQUARE, + GRID_KIND_HEX, + GRID_ORIENTATION_FLAT, + GRID_ORIENTATION_POINTY, + GRID_MODE_OWNER, + GRID_MODE_COLLABORATIVE, +} from './rows.ts'; +import { + type Coord, + type GridKind, + type Connectivity, + coordKey, + neighbors, + distance, + findPathAstar, + dijkstra, +} from './math/index.ts'; +import type { + ProcedureModuleCtx, + TransactionModuleCtx, +} from './submodule/schema.ts'; + +type GridRow = Infer; + +const VALID_KINDS = new Set([GRID_KIND_SQUARE, GRID_KIND_HEX]); +const VALID_ORIENTATIONS = new Set([ + GRID_ORIENTATION_FLAT, + GRID_ORIENTATION_POINTY, +]); +const VALID_MODES = new Set([GRID_MODE_OWNER, GRID_MODE_COLLABORATIVE]); +const VALID_CONNECTIVITY = new Set([4, 8]); + +const GRID_MAX_DIM = 1024; +const PATH_DEFAULT_MAX_EXPANSIONS = 50_000; +const PATH_MAX_EXPANSIONS = 50_000; +const MAX_RESULT_CELLS = 5_000; +const MAX_PAINT_CELLS = 1_000; +const MAX_NAME_LENGTH = 128; +const MAX_KIND_LENGTH = 64; +const MAX_LABEL_LENGTH = 256; +const MAX_TERRAIN_LENGTH = 64; + +export const createGridParams = { + name: t.string(), + kind: t.string(), + orientation: t.string(), + width: t.i32(), + height: t.i32(), + defaultCost: t.i32(), + connectivity: t.i32(), + mode: t.string(), +}; + +export function createGridImpl( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): bigint { + if ( + typeof args.name !== 'string' || + args.name.length === 0 || + args.name.length > MAX_NAME_LENGTH + ) { + throw new SenderError('grid.invalid_name'); + } + if (!VALID_KINDS.has(args.kind)) { + throw new SenderError(`grid.invalid_kind:${args.kind}`); + } + if (!VALID_ORIENTATIONS.has(args.orientation)) { + throw new SenderError(`grid.invalid_orientation:${args.orientation}`); + } + if (!VALID_MODES.has(args.mode)) { + throw new SenderError(`grid.invalid_mode:${args.mode}`); + } + if ( + args.width < 1 || + args.width > GRID_MAX_DIM || + args.height < 1 || + args.height > GRID_MAX_DIM + ) { + throw new SenderError( + `grid.invalid_dimensions:${args.width}x${args.height}` + ); + } + if (args.defaultCost < 1) { + throw new SenderError(`grid.invalid_default_cost:${args.defaultCost}`); + } + if ( + args.kind === GRID_KIND_SQUARE && + !VALID_CONNECTIVITY.has(args.connectivity) + ) { + throw new SenderError(`grid.invalid_connectivity:${args.connectivity}`); + } + return ctx.withTx(tx => { + const row = tx.db.grid.insert({ + id: 0n, + ownerUserId: owner, + name: args.name, + kind: args.kind, + orientation: args.orientation, + width: args.width, + height: args.height, + defaultCost: args.defaultCost, + connectivity: args.kind === GRID_KIND_HEX ? 6 : args.connectivity, + mode: args.mode, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + return row.id; + }); +} + +// delete_grid cascades cell_state, grid_entity, entity_path. +export const deleteGridParams = { + gridId: t.u64(), +}; + +export function deleteGridImpl( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): void { + ctx.withTx(tx => { + const grid = requireGridForMutation(tx, args.gridId, owner); + for (const c of [...tx.db.cellState.gridId.filter(grid.id)]) + tx.db.cellState.delete(c); + for (const e of [...tx.db.gridEntity.gridId.filter(grid.id)]) + tx.db.gridEntity.delete(e); + for (const p of [...tx.db.entityPath.gridId.filter(grid.id)]) + tx.db.entityPath.delete(p); + tx.db.grid.delete(grid); + }); +} + +// cost<=0 blocks, cost==defaultCost removes the sparse row. +export const setCellCostParams = { + gridId: t.u64(), + x: t.i32(), + y: t.i32(), + cost: t.i32(), + terrain: t.option(t.string()), +}; + +export function setCellCostImpl( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): void { + if ((args.terrain?.length ?? 0) > MAX_TERRAIN_LENGTH) { + throw new SenderError('grid.invalid_terrain'); + } + ctx.withTx(tx => { + const grid = requireGridForMutation(tx, args.gridId, owner); + assertInBounds(grid, { x: args.x, y: args.y }); + upsertCellState(tx, grid, args.x, args.y, args.cost, args.terrain); + }); +} + +export const paintCellsParams = { + gridId: t.u64(), + cells: t.array( + t.object('PaintCell', { + x: t.i32(), + y: t.i32(), + cost: t.i32(), + terrain: t.option(t.string()), + }) + ), +}; + +export function paintCellsImpl( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): void { + if (!Array.isArray(args.cells) || args.cells.length > MAX_PAINT_CELLS) { + throw new SenderError('grid.too_many_cells'); + } + ctx.withTx(tx => { + const grid = requireGridForMutation(tx, args.gridId, owner); + for (const c of args.cells) { + if ((c.terrain?.length ?? 0) > MAX_TERRAIN_LENGTH) { + throw new SenderError('grid.invalid_terrain'); + } + assertInBounds(grid, { x: c.x, y: c.y }); + upsertCellState(tx, grid, c.x, c.y, c.cost, c.terrain); + } + }); +} + +export const placeEntityParams = { + gridId: t.u64(), + x: t.i32(), + y: t.i32(), + kind: t.string(), + blocksMovement: t.bool(), + label: t.option(t.string()), +}; + +export function placeEntityImpl( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): bigint { + if ( + typeof args.kind !== 'string' || + args.kind.length === 0 || + args.kind.length > MAX_KIND_LENGTH + ) { + throw new SenderError('grid.invalid_entity_kind'); + } + if ((args.label?.length ?? 0) > MAX_LABEL_LENGTH) { + throw new SenderError('grid.invalid_entity_label'); + } + return ctx.withTx(tx => { + const grid = requireGridForMutation(tx, args.gridId, owner); + assertInBounds(grid, { x: args.x, y: args.y }); + const row = tx.db.gridEntity.insert({ + id: 0n, + gridId: grid.id, + ownerUserId: owner, + x: args.x, + y: args.y, + kind: args.kind, + blocksMovement: args.blocksMovement, + label: args.label, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + return row.id; + }); +} + +export const moveEntityParams = { + entityId: t.u64(), + toX: t.i32(), + toY: t.i32(), +}; + +export function moveEntityImpl( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +): void { + ctx.withTx(tx => { + const ent = tx.db.gridEntity.id.find(args.entityId); + if (!ent) throw new SenderError(`grid.entity_not_found:${args.entityId}`); + if (ent.ownerUserId !== owner) + throw new SenderError(`grid.entity_not_owner:${args.entityId}`); + const grid = tx.db.grid.id.find(ent.gridId); + if (!grid) throw new SenderError(`grid.not_found:${ent.gridId}`); + assertInBounds(grid, { x: args.toX, y: args.toY }); + + const neighborSet = neighbors( + grid.kind as GridKind, + { x: ent.x, y: ent.y }, + grid.connectivity as Connectivity + ); + const reachable = neighborSet.some( + n => n.x === args.toX && n.y === args.toY + ); + if (!reachable) throw new SenderError(`grid.move_not_adjacent`); + + tx.db.gridEntity.id.update({ + ...ent, + x: args.toX, + y: args.toY, + updatedAt: ctx.timestamp, + }); + }); +} + +// A* over the current cost map; optionally writes entity_path. +export const computePathParams = { + gridId: t.u64(), + startX: t.i32(), + startY: t.i32(), + endX: t.i32(), + endY: t.i32(), + storeFor: t.option(t.u64()), + maxExpansions: t.option(t.i32()), +}; + +export const computePathReturn = pathResult; + +export function computePathImpl( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +) { + return ctx.withTx(tx => { + const grid = requireGridForAccess(tx, args.gridId, owner); + assertInBounds(grid, { x: args.startX, y: args.startY }); + assertInBounds(grid, { x: args.endX, y: args.endY }); + + const costMap = buildCostMap(tx, grid); + const requestedCap = Number( + args.maxExpansions ?? PATH_DEFAULT_MAX_EXPANSIONS + ); + if ( + !Number.isInteger(requestedCap) || + requestedCap < 1 || + requestedCap > PATH_MAX_EXPANSIONS + ) { + throw new SenderError('grid.invalid_max_expansions'); + } + const cap = requestedCap; + + const neighborsFn = (c: Coord) => + filterInBounds( + grid, + neighbors(grid.kind as GridKind, c, grid.connectivity as Connectivity) + ); + + const costFn = (c: Coord) => { + const v = costMap.get(coordKey(c)); + return v === undefined ? grid.defaultCost : v; + }; + + const result = findPathAstar({ + start: { x: args.startX, y: args.startY }, + goal: { x: args.endX, y: args.endY }, + cost: costFn, + neighbors: neighborsFn, + heuristic: (a, b) => + distance( + grid.kind as GridKind, + a, + b, + grid.connectivity as Connectivity + ), + maxExpansions: cap, + }); + + if (result.found && args.storeFor !== undefined && args.storeFor !== null) { + const entity = tx.db.gridEntity.id.find(args.storeFor); + if (!entity) + throw new SenderError(`grid.entity_not_found:${args.storeFor}`); + if (entity.gridId !== grid.id) + throw new SenderError('grid.entity_grid_mismatch'); + if (entity.ownerUserId !== owner) + throw new SenderError(`grid.entity_not_owner:${args.storeFor}`); + if (result.cells.length > MAX_RESULT_CELLS) + throw new SenderError('grid.path_too_long'); + writeEntityPath( + tx, + ctx.timestamp, + args.storeFor, + grid.id, + result.cells, + result.cost + ); + } + + if (result.found) { + if (result.cells.length > MAX_RESULT_CELLS) + throw new SenderError('grid.path_too_long'); + return { + found: true, + cells: result.cells, + cost: result.cost, + expanded: result.expanded, + }; + } + return { found: false, cells: [], cost: 0, expanded: result.expanded }; + }); +} + +export const cellsInRangeParams = { + gridId: t.u64(), + originX: t.i32(), + originY: t.i32(), + maxCost: t.i32(), +}; + +export const cellsInRangeReturn = t.object('CellsInRangeResult', { + cells: t.array(reachableCell), +}); + +export function cellsInRangeImpl( + ctx: ProcedureModuleCtx, + args: InferTypeOfParams, + owner: string +) { + return ctx.withTx(tx => { + const grid = requireGridForAccess(tx, args.gridId, owner); + assertInBounds(grid, { x: args.originX, y: args.originY }); + + const costMap = buildCostMap(tx, grid); + const neighborsFn = (c: Coord) => + filterInBounds( + grid, + neighbors(grid.kind as GridKind, c, grid.connectivity as Connectivity) + ); + const costFn = (c: Coord) => { + const v = costMap.get(coordKey(c)); + return v === undefined ? grid.defaultCost : v; + }; + + const reached = dijkstra({ + start: { x: args.originX, y: args.originY }, + cost: costFn, + neighbors: neighborsFn, + maxCost: args.maxCost, + maxExpansions: PATH_MAX_EXPANSIONS, + }); + + if (reached.size > MAX_RESULT_CELLS) + throw new SenderError('grid.range_too_large'); + + const cells: Array<{ x: number; y: number; cost: number }> = []; + for (const node of reached.values()) { + cells.push({ x: node.cell.x, y: node.cell.y, cost: node.cost }); + } + return { cells }; + }); +} + +function requireGridForMutation( + tx: TransactionModuleCtx, + gridId: bigint, + owner: string +): GridRow { + const grid = tx.db.grid.id.find(gridId); + if (!grid) throw new SenderError(`grid.not_found:${gridId}`); + if (grid.mode === GRID_MODE_OWNER && grid.ownerUserId !== owner) { + throw new SenderError(`grid.not_owner:${gridId}`); + } + return grid; +} + +function requireGridForAccess( + tx: TransactionModuleCtx, + gridId: bigint, + owner: string +): GridRow { + const grid = tx.db.grid.id.find(gridId); + if (!grid) throw new SenderError(`grid.not_found:${gridId}`); + if (grid.mode === GRID_MODE_OWNER && grid.ownerUserId !== owner) { + throw new SenderError(`grid.not_owner:${gridId}`); + } + return grid; +} + +function assertInBounds(grid: GridRow, c: Coord): void { + if (c.x < 0 || c.y < 0 || c.x >= grid.width || c.y >= grid.height) { + throw new SenderError(`grid.out_of_bounds:${c.x},${c.y}`); + } +} + +function filterInBounds(grid: GridRow, list: Coord[]): Coord[] { + return list.filter( + c => c.x >= 0 && c.y >= 0 && c.x < grid.width && c.y < grid.height + ); +} + +function upsertCellState( + tx: TransactionModuleCtx, + grid: GridRow, + x: number, + y: number, + cost: number, + terrain: string | undefined +): void { + let existing = null; + for (const c of tx.db.cellState.gridId.filter(grid.id)) { + if (c.x === x && c.y === y) { + existing = c; + break; + } + } + if ( + cost === grid.defaultCost && + (terrain === undefined || terrain === null) + ) { + if (existing) tx.db.cellState.delete(existing); + return; + } + if (existing) { + tx.db.cellState.id.update({ ...existing, cost, terrain }); + return; + } + tx.db.cellState.insert({ id: 0n, gridId: grid.id, x, y, cost, terrain }); +} + +function buildCostMap( + tx: TransactionModuleCtx, + grid: GridRow +): Map { + const map = new Map(); + for (const c of tx.db.cellState.gridId.filter(grid.id)) { + map.set(coordKey({ x: c.x, y: c.y }), c.cost); + } + for (const e of tx.db.gridEntity.gridId.filter(grid.id)) { + if (!e.blocksMovement) continue; + const k = coordKey({ x: e.x, y: e.y }); + if (!map.has(k)) map.set(k, -1); + } + return map; +} + +function writeEntityPath( + tx: TransactionModuleCtx, + timestamp: Timestamp, + entityId: bigint, + gridId: bigint, + cells: Coord[], + cost: number +): void { + const existing = tx.db.entityPath.entityId.find(entityId); + const row = { + entityId, + gridId, + cells: cells.map(c => ({ x: c.x, y: c.y })), + cost, + computedAt: timestamp, + }; + if (existing) { + tx.db.entityPath.entityId.update(row); + } else { + tx.db.entityPath.insert(row); + } +} diff --git a/spacetime-grid-ts/src/rows.ts b/spacetime-grid-ts/src/rows.ts new file mode 100644 index 00000000000..a9c9425012e --- /dev/null +++ b/spacetime-grid-ts/src/rows.ts @@ -0,0 +1,74 @@ +// ownerUserId is opaque and may contain an identity, application user ID, or +// host-defined actor ID. Owner mode restricts access to that value. +import { t } from 'spacetimedb/server'; + +export const GRID_KIND_SQUARE = 'square'; +export const GRID_KIND_HEX = 'hex'; + +export const GRID_ORIENTATION_FLAT = 'flat'; +export const GRID_ORIENTATION_POINTY = 'pointy'; + +export const GRID_MODE_OWNER = 'owner'; +export const GRID_MODE_COLLABORATIVE = 'collaborative'; + +export const gridRow = { + id: t.u64().primaryKey().autoInc(), + ownerUserId: t.string().index(), + name: t.string(), + kind: t.string(), // 'square' | 'hex' + orientation: t.string(), // 'flat' | 'pointy' (ignored for square) + width: t.i32(), + height: t.i32(), + defaultCost: t.i32(), + connectivity: t.i32(), // square only: 4 | 8. Hex is always 6. + mode: t.string(), // 'owner' | 'collaborative' + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +// Sparse: only non-default cells have a row. cost<=0 means blocked. +export const cellStateRow = { + id: t.u64().primaryKey().autoInc(), + gridId: t.u64().index(), + x: t.i32(), + y: t.i32(), + cost: t.i32(), + terrain: t.option(t.string()), +}; + +export const gridEntityRow = { + id: t.u64().primaryKey().autoInc(), + gridId: t.u64().index(), + ownerUserId: t.string().index(), + x: t.i32(), + y: t.i32(), + kind: t.string(), // user-defined: 'player', 'npc', 'item', ... + blocksMovement: t.bool(), + label: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +// Not auto-invalidated; consumers re-call compute_path for fresh routes. +export const entityPathRow = { + entityId: t.u64().primaryKey(), + gridId: t.u64().index(), + cells: t.array(t.object('PathCell', { x: t.i32(), y: t.i32() })), + cost: t.i32(), + computedAt: t.timestamp(), +}; + +export const pathCell = t.object('PathCell', { x: t.i32(), y: t.i32() }); + +export const pathResult = t.object('PathResult', { + found: t.bool(), + cells: t.array(pathCell), + cost: t.i32(), + expanded: t.i32(), +}); + +export const reachableCell = t.object('ReachableCell', { + x: t.i32(), + y: t.i32(), + cost: t.i32(), +}); diff --git a/spacetime-grid-ts/src/submodule.ts b/spacetime-grid-ts/src/submodule.ts new file mode 100644 index 00000000000..881d273564f --- /dev/null +++ b/spacetime-grid-ts/src/submodule.ts @@ -0,0 +1,6 @@ +export { default } from './submodule/schema.ts'; +export { cellState, entityPath, grid, gridEntity } from './submodule/schema.ts'; +export { installGrid } from './submodule/install.ts'; +export * from './rows.ts'; +export * from './procedures.ts'; +export * from './math/index.ts'; diff --git a/spacetime-grid-ts/src/submodule/install.ts b/spacetime-grid-ts/src/submodule/install.ts new file mode 100644 index 00000000000..78101da7d80 --- /dev/null +++ b/spacetime-grid-ts/src/submodule/install.ts @@ -0,0 +1,6 @@ +import type { ReducerModuleCtx } from './schema.ts'; + +export function installGrid(_ctx: ReducerModuleCtx) { + // Grid owns only persistent map/entity tables. Host modules decide auth, + // ownership, and any seed data they want layered on top. +} diff --git a/spacetime-grid-ts/src/submodule/schema.ts b/spacetime-grid-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..27aad079e68 --- /dev/null +++ b/spacetime-grid-ts/src/submodule/schema.ts @@ -0,0 +1,46 @@ +import { + schema, + table, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { + cellStateRow, + entityPathRow, + gridEntityRow, + gridRow, +} from '../rows.ts'; + +export const grid = table({ name: 'grid', public: false }, gridRow); + +export const cellState = table( + { name: 'cell_state', public: false }, + cellStateRow +); + +export const gridEntity = table( + { name: 'grid_entity', public: false }, + gridEntityRow +); + +export const entityPath = table( + { name: 'entity_path', public: false }, + entityPathRow +); + +export const spacetimedb = schema({ + grid, + cellState, + gridEntity, + entityPath, +}); +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; diff --git a/spacetime-grid-ts/tsconfig.json b/spacetime-grid-ts/tsconfig.json new file mode 100644 index 00000000000..e6a8236bbab --- /dev/null +++ b/spacetime-grid-ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +} diff --git a/spacetime-lobby-ts/LICENSE.txt b/spacetime-lobby-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-lobby-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-lobby-ts/README.md b/spacetime-lobby-ts/README.md new file mode 100644 index 00000000000..03b10e64b6f --- /dev/null +++ b/spacetime-lobby-ts/README.md @@ -0,0 +1,177 @@ +# @spacetimedb/lobby + +SpacetimeDB lobby and matchmaking submodule. + +This package provides queue tickets, deterministic same-pool matchmaking, +ranked two-player results with Elo ratings, rooms, seats, lifecycle state, +admin observability, and mountable helpers for host modules. Host applications +define parties, backfill, and product-specific match rules. + +## Install + +```bash +npm install @spacetimedb/lobby spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +## Usage + +### Integrate into an application + +For a host application, mount the namespace and keep the lifecycle hook in the +host module: + +```ts +import { schema } from 'spacetimedb/server'; +import * as lobby from '@spacetimedb/lobby/submodule'; + +const spacetimedb = schema({ lobby }); + +export const init = spacetimedb.init(ctx => { + lobby.installLobby(ctx.as.lobby); +}); + +export default spacetimedb; +``` + +Mounted host modules can call helpers with an explicit subject after they have +validated auth or mapped the STDB identity to an app user ID: + +```ts +lobby.joinQueue(ctx.as.lobby, { + pool: 'duel', + subject: userId, + matchSize: 2, + attributesJson: JSON.stringify({ region: 'iad' }), +}); +``` + +Public submodule reducers derive the subject from `ctx.sender.toHexString()`. +Host wrappers can map the caller to an application user ID. +See the +[Starclash host module](./example/spacetimedb/) +for profile mapping, matchmaking, match results, and caller-scoped views. + +After generating bindings, a client joins through the host operation and reads +match state through subscriptions: + +```ts +await conn.reducers.findDuel({}); + +conn + .subscriptionBuilder() + .subscribe([ + 'SELECT * FROM my_lobby_tickets', + 'SELECT * FROM my_lobby_rooms', + 'SELECT * FROM my_lobby_room_seats', + ]); +``` + +`findDuel` is the example's product-facing wrapper. A host can instead expose +its own reducer around `lobby.joinQueue(ctx.as.lobby, ...)`. + +### Publish Lobby as the database + +The root entrypoint includes the standalone lifecycle hook for databases +dedicated to Lobby: + +```ts +export { default, init } from '@spacetimedb/lobby'; +export { + join_queue, + join_ranked_queue, + cancel_ticket, + my_lobby_tickets, + my_lobby_rooms, + lobby_queue_summary, + lobby_ranked_leaderboard, +} from '@spacetimedb/lobby'; +``` + +## API + +Reducers: + +- `join_queue({ pool, matchSize, attributesJson, ttlSeconds})` +- `join_ranked_queue({ pool, matchSize, attributesJson, ttlSeconds, ratingPool })` +- `cancel_ticket({ ticketId })` +- `join_room({ roomId })` +- `leave_room({ roomId })` +- `close_room({ roomId })` +- `expire_tickets({ limit })` (admin only, up to 1,000 rows) +- `set_rating({ pool, subject, rating })` (admin only) +- `update_config({ defaultTicketTtlSeconds, maxMatchSize })` (admin only) +- `add_admin_identity({ identity })` (admin only) +- `remove_admin_identity({ identity })` (admin only) + +Procedure: + +- `get_lobby_status()` returns a JSON string. + +Views: + +- `my_lobby_tickets` +- `my_lobby_ratings` +- `my_lobby_rooms` +- `my_lobby_room_seats` +- `lobby_queue_summary` +- `lobby_ranked_leaderboard` +- `lobby_admin_tickets` +- `lobby_admin_rooms` +- `lobby_admin_room_seats` +- `lobby_admin_match_results` + +Host helper API: + +- Queue lifecycle: `joinQueue`, `joinRankedQueue`, and `cancelTicket`. +- Room lifecycle: `joinRoom`, `leaveRoom`, and `closeRoom`. +- Ranking: `reportMatchResult`. + +Mounted administrator operations include `set_rating`, `expire_tickets`, and +`update_config`. + +Package entrypoints: + +- `@spacetimedb/lobby` can run as a standalone Lobby database. +- `@spacetimedb/lobby/submodule` supplies the mounted namespace and host + helpers. + +## Matching + +Matching is deterministic: + +- tickets match only within the same `pool` +- tickets match only with the same `matchSize` +- an indexed `(pool, status, createdAt)` scan selects the oldest eligible tickets +- matched tickets create one room and one reserved seat per ticket +- rooms become `Active` when every reserved seat joins + +`attributesJson` stores host-defined matching metadata. Host wrappers interpret +the metadata when applying product-specific rules. + +Ranked queues use a 1,000 starting rating and a widening rating band: 100 +points initially, 50 more for each 10 seconds waited, capped at 800. A mounted +host reports a two-player result through `reportMatchResult` after validating +its game-specific completion rules. Results are idempotent per room and update +both players with Elo K=32. The room must be active. Result reporting is a host +helper and is absent from the generic client-callable API. + +Any participant may close a room through `close_room`. Hosts that need stricter +completion rules should expose their own reducer and call `closeRoom` after +validating the game state. + +## Testing + +```bash +pnpm test +pnpm run typecheck +pnpm run build +``` + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-lobby-ts/example/.env.example b/spacetime-lobby-ts/example/.env.example new file mode 100644 index 00000000000..e773b86eccf --- /dev/null +++ b/spacetime-lobby-ts/example/.env.example @@ -0,0 +1,4 @@ +HOST=127.0.0.1 +PORT=8797 +STDB_URI=ws://127.0.0.1:3000 +STDB_DATABASE=spacetime-lobby-example diff --git a/spacetime-lobby-ts/example/.gitignore b/spacetime-lobby-ts/example/.gitignore new file mode 100644 index 00000000000..b14dc6d3e62 --- /dev/null +++ b/spacetime-lobby-ts/example/.gitignore @@ -0,0 +1,6 @@ +node_modules +public/app.js +public/app.js.map +src/codegen +*.log +.env diff --git a/spacetime-lobby-ts/example/README.md b/spacetime-lobby-ts/example/README.md new file mode 100644 index 00000000000..ecb60ef0cf9 --- /dev/null +++ b/spacetime-lobby-ts/example/README.md @@ -0,0 +1,167 @@ +# Starclash lobby example + +Starclash is a ranked one-on-one spaceship duel built with +[`@spacetimedb/lobby`](../). The mounted Lobby component owns queue tickets, +rooms, seats, and ratings; the host module owns ship selection, duel state, +maneuvers, combat resolution, and round logs. + +## What this demonstrates + +- Mounting the Lobby component in a host game module. +- Ranked queue matching, room joining, rematches, and rating updates. +- Falling back from a public queue to a server-controlled AI opponent. +- Keeping component matchmaking state separate from application game state. +- Caller-scoped ticket, room, seat, rating, duel, and maneuver views. +- Driving a realtime UI entirely from SpacetimeDB subscriptions. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity for publishing the example. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-lobby-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open in two independent browser profiles or one normal +and one private/incognito window. Choose ships and queue both pilots. Separate +profiles are important because ordinary tabs share the same persisted development +SpacetimeDB identity. + +`build:module:fresh` deletes and recreates only the local `spacetime-lobby-example` +database. Use `pnpm run build:module` when existing ratings and duel history must +be preserved. + +## Use in your project + +This workspace tests the component source in this repository. Consumer +applications install the published release: + +```bash +npm install @spacetimedb/lobby spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +subject-mapping and result-reporting boundaries; ships, maneuvers, and duel +simulation are application-specific demonstration code. + +## Configuration + +| Variable | Default | Purpose | +| --------------- | ------------------------- | ------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8797` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_DATABASE` | `spacetime-lobby-example` | Published database name. | + +The Node process serves static files, `GET /api/health`, and browser-safe +`GET /api/config`. Matchmaking and combat calls travel directly to SpacetimeDB. + +## Match and duel lifecycle + +1. A player sets a display name and ship class. +2. `find_duel` joins the ranked public pool through the Lobby component. +3. Once two compatible tickets are matched, both subjects join the resulting + room and the host module creates duel state. +4. Each pilot chooses a maneuver. The module resolves the round only when the + required choices exist, then records combat changes and a round log. +5. A completed or abandoned duel reports its result to Lobby and closes the room. +6. The component updates ratings; players can queue again. + +The fallback action cancels the player's public ticket and creates a match in an +AI-specific pool with a server-controlled subject. + +## Visibility and authority + +The browser subscribes to public catalogs and leaderboard summaries plus +caller-scoped views including `my_lobby_tickets`, `my_lobby_rooms`, +`my_lobby_room_seats`, `my_lobby_ratings`, `my_duels`, and +`my_duel_maneuvers`. Scoped views derive their subject from `ctx.sender`. +The public display-name roster returns at most 1,000 pilots. + +Reducers repeat the membership, room, turn, and combat checks. A player cannot +choose for the opponent, resolve an unrelated room, or read another room merely +by changing a client query. + +This example uses anonymous SpacetimeDB identities. Display names are profile +metadata. Applications that need verified accounts can mount Auth. + +## Security and deployment boundaries + +- Persisted browser tokens are development identity credentials. Do not log or + commit them. +- Matchmaking and combat inputs are untrusted even when generated by the bundled + UI; validate all state transitions in reducers. +- Rating changes should be reported once for each terminal room. Production game + logic should include durable idempotency and abuse controls. +- Anonymous identities suit this demo. Account recovery, moderation, purchases, + and durable competitive identity require application authentication. +- The included Express process is a local static server. Production needs TLS, + explicit binding, origin policy, asset hardening, and supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test: + +1. Queue two independent identities and verify exactly one room with two seats. +2. Join both seats, play several rounds, and confirm both browsers receive the same + combat state and logs. +3. Finish a duel and verify room closure, winner state, and rating changes happen + once. +4. Exercise leave/abandon and queue-again behavior. +5. Exercise AI fallback and complete a solo duel. +6. Attempt to submit a maneuver or room action from an unrelated third identity + and confirm the module rejects it. + +## Troubleshooting + +- **Both windows appear as one pilot:** use independent browser profiles or an + incognito window to give each player a separate SpacetimeDB token. +- **A ticket never matches:** confirm both pilots selected the public duel pool and + inspect `lobby_queue_summary` for queued tickets. +- **The page connects to stale state:** verify `STDB_URI` targets the server + registered as `local` by the publish scripts. +- **State disappears after republishing:** `build:module:fresh` deliberately + deletes all local rows, including ratings. + +## Important files + +- `spacetimedb/src/index.ts` - Lobby mount, scoped views, matchmaking, combat, + ratings, and AI fallback. +- `spacetimedb/src/catalog.ts` - ship and maneuver definitions used to seed the + public catalogs. +- `src/app.ts` - browser identity, subscriptions, rendering, and controls. +- `server.ts` - static development server and browser-safe configuration. +- `public/index.html` - Starclash interface. +- `public/styles.css` - Starclash presentation. diff --git a/spacetime-lobby-ts/example/package.json b/spacetime-lobby-ts/example/package.json new file mode 100644 index 00000000000..f3dc5f443a0 --- /dev/null +++ b/spacetime-lobby-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-lobby-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "test:unit": "tsx scripts/test-model.ts", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-lobby-ts/example/public/assets/brand.svg b/spacetime-lobby-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-lobby-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-lobby-ts/example/public/index.html b/spacetime-lobby-ts/example/public/index.html new file mode 100644 index 00000000000..7a220d3272f --- /dev/null +++ b/spacetime-lobby-ts/example/public/index.html @@ -0,0 +1,132 @@ + + + + + + + + Starclash + + + +
        +
        +
        +
        +
        +
        +

        Starclash

        +

        Pick your ship and find a match.

        +
        +
        + + +
        +
        +
        + Ranked rating + 1000 +
        + 0W 0L +
        + +
        + Top Pilots +
        +
        +
        + +
        +
        + +
        +
        +
        +
        +

        Searching for Rival

        + +
        +
        +
        + +
        +
        +
        + +
        +
        + +
        + + + +
        +
        +
        +
        + + +
        +
        + Confirm forfeit +

        Leave this duel?

        +
        +

        + Forfeiting ends the match, gives your opponent the win, and returns + you to the hangar. +

        +
        + + +
        +
        +
        + +
        Connecting to SpacetimeDB.
        + +
        + Built on + SpacetimeDB +
        +
        + + + + diff --git a/spacetime-lobby-ts/example/public/styles.css b/spacetime-lobby-ts/example/public/styles.css new file mode 100644 index 00000000000..6e4e3329483 --- /dev/null +++ b/spacetime-lobby-ts/example/public/styles.css @@ -0,0 +1,1480 @@ +:root { + color-scheme: dark; + --bg: #090a0d; + --surface: #10151a; + --surface-2: #141b22; + --line: #2b3f47; + --line-strong: #3e6571; + --text: #f2f6f4; + --muted: #91a3a3; + --cyan: #18c5d9; + --green: #6bd17b; + --yellow: #f3c969; + --red: #ee6f6f; + --violet: #b998ff; + --button: #d7dedb; + --button-text: #090d10; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient( + circle at 50% -8%, + rgba(24, 197, 217, 0.08), + transparent 42% + ), + radial-gradient( + circle at 88% 108%, + rgba(185, 152, 255, 0.06), + transparent 40% + ), + var(--bg); + background-attachment: fixed; + color: var(--text); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + letter-spacing: 0; +} + +button, +input { + font: inherit; +} + +button { + height: 36px; + border: 1px solid var(--line-strong); + border-radius: 6px; + background: #14222a; + color: var(--text); + font-weight: 800; + cursor: pointer; +} + +button:hover:not(:disabled) { + border-color: var(--cyan); + background: #17313a; +} + +button.primary { + border-color: transparent; + background: var(--button); + color: var(--button-text); +} + +button.primary:hover:not(:disabled) { + background: #eef4ef; + color: var(--button-text); +} + +button.danger { + border-color: #7d3838; + background: #241010; + color: #ffb8b3; +} + +button.danger:hover:not(:disabled) { + border-color: var(--red); + background: #361313; +} + +button:disabled { + opacity: 0.42; + cursor: default; +} + +input { + width: 100%; + height: 38px; + border: 1px solid var(--line); + border-radius: 6px; + background: #090f12; + color: var(--text); + padding: 0 11px; + outline: none; +} + +input:focus { + border-color: var(--cyan); +} + +h1, +h2, +h3, +p { + margin: 0; +} +h1 { + font-size: 22px; + line-height: 1.1; +} +h2 { + font-size: 17px; + line-height: 1.2; +} +h3 { + font-size: 20px; + line-height: 1.1; +} +p, +small { + color: var(--muted); +} + +.app { + width: min(1280px, calc(100vw - 28px)); + margin: 0 auto; + padding: 28px 0 18px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.shell { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 14px; + margin-top: 14px; + align-items: start; + flex: 1; +} + +.screen { + display: none; +} + +.screen.active { + display: grid; + gap: 14px; +} + +.setup-screen { + width: min(980px, 100%); + margin: 0 auto; + grid-template-columns: 1fr; + align-items: start; +} + +.waiting-screen { + width: min(620px, 100%); + margin: 0 auto; + align-content: center; +} + +.duel-screen { + width: min(1280px, 100%); + margin: 0 auto; + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + +.panel { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 16px; +} + +.launch-card { + --ship: var(--cyan); + padding: 28px; + display: grid; + grid-template-columns: minmax(280px, 0.7fr) minmax(360px, 1fr); + gap: 24px; + align-items: start; +} + +.launch-hero { + display: grid; + gap: 8px; +} + +.launch-hero h1 { + font-size: 42px; + line-height: 0.96; +} + +.launch-hero p { + max-width: 520px; + font-size: 16px; +} + +.launch-controls { + display: grid; + gap: 16px; +} + +.ship-carousel { + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + padding: 16px; + display: grid; + gap: 14px; +} + +.ship-roster { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.ship-chip { + height: auto; + min-height: 66px; + padding: 9px 4px 7px; + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + display: grid; + gap: 7px; + justify-items: center; + align-content: center; + color: var(--muted); + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ship-chip:hover:not(.active) { + border-color: var(--line-strong); + background: #0c141a; +} + +.ship-chip.active { + border-color: var(--chip); + color: var(--text); + background: color-mix(in srgb, var(--chip) 12%, #0a1014); + box-shadow: 0 0 16px color-mix(in srgb, var(--chip) 22%, transparent); +} + +.chip-ship { + width: 22px; + height: 22px; + background: var(--chip); + filter: drop-shadow(0 0 6px var(--chip)); + clip-path: polygon(50% 0, 100% 100%, 50% 78%, 0 100%); +} + +.ship-chip:not(.active) .chip-ship { + opacity: 0.68; +} +.ship-chip.bulwark .chip-ship { + clip-path: polygon(50% 0, 94% 42%, 78% 100%, 50% 82%, 22% 100%, 6% 42%); +} +.ship-chip.interceptor .chip-ship { + width: 16px; + clip-path: polygon(50% 0, 96% 100%, 50% 78%, 4% 100%); +} +.ship-chip.phantom .chip-ship { + clip-path: polygon(50% 0, 92% 58%, 70% 100%, 50% 72%, 30% 100%, 8% 58%); +} +.ship-chip.artillery .chip-ship { + clip-path: polygon(50% 0, 76% 30%, 100% 100%, 50% 82%, 0 100%, 24% 30%); +} + +.ship-preview { + position: relative; + min-height: 232px; + border: 1px solid var(--line-strong); + border-radius: 10px; + overflow: hidden; + background: + radial-gradient( + circle at 50% 44%, + color-mix(in srgb, var(--ship) 22%, transparent), + transparent 56% + ), + #070d11; +} + +.ship-preview::after { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(120, 170, 190, 0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(120, 170, 190, 0.1) 1px, transparent 1px); + background-size: 30px 30px; + -webkit-mask: radial-gradient(circle at 50% 50%, #000 38%, transparent 74%); + mask: radial-gradient(circle at 50% 50%, #000 38%, transparent 74%); +} + +.ship-preview::before { + content: ''; + position: absolute; + left: 50%; + top: 60px; + z-index: 1; + width: 54px; + height: 112px; + clip-path: polygon(50% 0, 100% 100%, 50% 78%, 0 100%); + background: var(--cyan); + filter: drop-shadow(0 0 18px var(--ship)); + animation: ship-float 3.6s ease-in-out infinite; +} + +@keyframes ship-float { + 0%, + 100% { + transform: translateX(-50%) translateY(0); + } + 50% { + transform: translateX(-50%) translateY(-9px); + } +} + +.ship-preview.bulwark::before { + width: 72px; + background: var(--green); + clip-path: polygon(50% 0, 94% 42%, 78% 100%, 50% 82%, 22% 100%, 6% 42%); +} + +.ship-preview.interceptor::before { + width: 38px; + background: var(--yellow); + clip-path: polygon(50% 0, 96% 100%, 50% 78%, 4% 100%); +} + +.ship-preview.phantom::before { + width: 66px; + background: var(--violet); + clip-path: polygon(50% 0, 92% 58%, 70% 100%, 50% 72%, 30% 100%, 8% 58%); +} + +.ship-preview.artillery::before { + width: 78px; + background: var(--red); + clip-path: polygon(50% 0, 76% 30%, 100% 100%, 50% 82%, 0 100%, 24% 30%); +} + +.ship-details { + display: grid; + gap: 5px; +} + +.ship-details p { + max-width: 560px; +} + +.ship-stat-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.ship-stat { + border: 1px solid var(--line); + border-radius: 6px; + background: #090f12; + padding: 9px; + display: grid; + gap: 6px; +} + +.ship-stat-label { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--muted); + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.ship-stat-track { + height: 8px; + border-radius: 999px; + background: #05090b; + overflow: hidden; + border: 1px solid #24383f; +} + +.ship-stat-track i { + display: block; + height: 100%; + background: var(--ship); + transition: + width 220ms ease, + background 220ms ease; +} + +.ranked-card { + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + padding: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.ranked-card strong { + display: block; + font-size: 24px; + line-height: 1; +} + +.ranked-card span, +.ranked-card small, +.leaderboard-row span { + color: var(--muted); + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + text-transform: uppercase; +} + +.leaderboard-section { + display: grid; + gap: 10px; + margin-top: 4px; + padding-top: 18px; + border-top: 1px solid var(--line); +} + +.leaderboard { + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + overflow: hidden; + max-height: 212px; + overflow-y: auto; +} + +.leaderboard-empty { + margin: 0; + padding: 12px; + color: var(--muted); + font-size: 13px; +} + +.leaderboard-row { + min-height: 40px; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 8px 11px; + border-bottom: 1px solid var(--line); +} + +.leaderboard-row:last-child { + border-bottom: 0; +} + +.leaderboard-row.me { + background: color-mix(in srgb, var(--cyan) 12%, transparent); +} + +.lb-rank { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border-radius: 999px; + border: 1px solid var(--line-strong); + background: #11181d; + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.lb-rank.rank-1 { + background: var(--yellow); + border-color: var(--yellow); + color: #1b1407; +} +.lb-rank.rank-2 { + background: #c7d2d8; + border-color: #c7d2d8; + color: #0b0f12; +} +.lb-rank.rank-3 { + background: #cf9a63; + border-color: #cf9a63; + color: #1a0f06; +} + +.leaderboard-row strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: var(--text); +} + +.leaderboard-row em { + font-style: normal; + font: + 800 13px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + color: var(--text); +} + +.lb-you { + margin-left: 5px; + color: var(--muted); + font-weight: 600; +} + +.panel.compact { + padding: 14px; +} +.panel > p { + margin-top: 6px; +} + +.eyebrow, +label { + display: block; + color: var(--muted); + font: + 800 11px/1.2 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + text-transform: uppercase; +} + +.field { + display: grid; + gap: 8px; +} + +.inline { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; +} + +.queue-button { + width: 100%; + height: 50px; + font-size: 16px; + letter-spacing: 0.04em; + box-shadow: 0 0 26px color-mix(in srgb, var(--ship) 28%, transparent); + transition: box-shadow 220ms ease; +} + +.arena { + display: grid; + grid-template-rows: auto 1fr auto; + gap: 16px; + background: #0f1518; +} + +/* Fixed-height slot shared by the round indicator and the win/lose result, + so the outcome swaps in without shifting the page. */ +.arena-head { + display: flex; + align-items: center; + justify-content: center; + min-height: 34px; +} + +#duelStatus { + color: var(--muted); + font: + 800 12px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +#duelStatus[hidden] { + display: none; +} + +#duelStatus.win, +#duelStatus.lose, +#duelStatus.abandoned { + font: + 900 26px/1 Inter, + ui-sans-serif, + system-ui, + sans-serif; + letter-spacing: 0.04em; +} + +#duelStatus.win { + color: var(--green); + text-shadow: 0 0 22px rgba(107, 209, 123, 0.4); +} +#duelStatus.lose { + color: var(--red); + text-shadow: 0 0 22px rgba(238, 111, 111, 0.35); +} +#duelStatus.abandoned { + color: var(--muted); +} +#duelStatus.show { + animation: outcome-in 0.4s ease; +} + +.combatants { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-content: stretch; +} + +.combatant { + position: relative; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-2); + padding: 14px; + display: flex; + flex-direction: column; + gap: 13px; +} + +.combatant.mine { + border-color: #4d8c5c; +} + +.ship-visual { + min-height: 156px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: #0b1114; + position: relative; + overflow: hidden; +} + +.ship-visual::before { + content: ''; + position: absolute; + left: 50%; + top: 32px; + width: 44px; + height: 86px; + transform: translateX(-50%); + clip-path: polygon(50% 0, 100% 100%, 50% 78%, 0 100%); + background: var(--cyan); +} + +.ship-visual.bulwark::before { + background: var(--green); + width: 58px; + clip-path: polygon(50% 0, 94% 42%, 78% 100%, 50% 82%, 22% 100%, 6% 42%); +} + +.ship-visual.interceptor::before { + background: var(--yellow); + width: 32px; + clip-path: polygon(50% 0, 96% 100%, 50% 78%, 4% 100%); +} + +.ship-visual.phantom::before { + background: var(--violet); + width: 54px; + clip-path: polygon(50% 0, 92% 58%, 70% 100%, 50% 72%, 30% 100%, 8% 58%); +} + +.ship-visual.artillery::before { + background: var(--red); + width: 62px; + clip-path: polygon(50% 0, 76% 30%, 100% 100%, 50% 82%, 0 100%, 24% 30%); +} + +.combatant p { + margin-top: 3px; +} + +.ship-abilities { + min-height: 68px; + display: grid; + gap: 7px; +} + +.ability-card.active { + border-color: var(--slot, var(--cyan)); + box-shadow: + inset 0 0 0 1px + color-mix(in srgb, var(--slot, var(--cyan)) 45%, transparent), + 0 0 18px color-mix(in srgb, var(--slot, var(--cyan)) 24%, transparent); +} + +.bar-block { + display: grid; + gap: 6px; + margin-top: auto; +} + +.bar-label { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 13px; +} + +.bar { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: #070b0d; + border: 1px solid #24383f; +} + +.bar i { + display: block; + height: 100%; + background: var(--green); + transition: + width 450ms cubic-bezier(0.4, 0, 0.2, 1), + background 200ms ease; +} + +.bar.shield i { + background: var(--cyan); +} + +.combatant.low .bar:not(.shield) i { + background: var(--red); +} + +/* --- combat animation layer --- */ +.ship-visual { + transition: + transform 170ms ease, + opacity 400ms ease, + filter 400ms ease; +} + +.combatant.attacking.mine .ship-visual { + transform: translateX(16px); +} +.combatant.attacking:not(.mine) .ship-visual { + transform: translateX(-16px); +} + +.pops { + position: absolute; + left: 14px; + right: 14px; + top: 14px; + height: 156px; + pointer-events: none; + overflow: visible; + z-index: 4; +} + +.pop { + position: absolute; + left: 50%; + top: 42%; + transform: translate(-50%, -50%); + font: + 900 26px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.8); + animation: pop-rise 900ms ease-out forwards; + will-change: transform, opacity; +} + +.pop.hit { + color: var(--red); +} +.pop.shield { + color: var(--cyan); + font-size: 22px; +} +.pop.evade { + color: var(--muted); + font-size: 19px; + letter-spacing: 0.06em; +} +.pop.crit { + color: var(--yellow); + font-size: 36px; + text-shadow: + 0 0 18px rgba(243, 201, 105, 0.6), + 0 2px 12px rgba(0, 0, 0, 0.8); +} + +@keyframes pop-rise { + 0% { + opacity: 0; + transform: translate(-50%, -30%) scale(0.8); + } + 16% { + opacity: 1; + transform: translate(calc(-50% + var(--pop-x, 0px)), -62%) scale(1.06); + } + 100% { + opacity: 0; + transform: translate(calc(-50% + var(--pop-x, 0px)), -150%) scale(0.95); + } +} + +.combatant.shake { + animation: ship-shake 0.42s ease; +} +.combatant.shake-hard { + animation: ship-shake-hard 0.46s ease; +} + +@keyframes ship-shake { + 10%, + 90% { + transform: translateX(-2px); + } + 30%, + 70% { + transform: translateX(4px); + } + 50% { + transform: translateX(-4px); + } +} + +@keyframes ship-shake-hard { + 10%, + 90% { + transform: translate(-4px, 1px); + } + 30%, + 70% { + transform: translate(7px, -1px); + } + 50% { + transform: translate(-8px, 1px); + } +} + +.combatant.flash-hit .ship-visual { + animation: flash-hit 0.36s ease; +} +.combatant.flash-crit .ship-visual { + animation: flash-crit 0.42s ease; +} +.combatant.flash-shield .ship-visual { + animation: flash-shield 0.36s ease; +} + +@keyframes flash-hit { + 35% { + box-shadow: + inset 0 0 0 2px var(--red), + 0 0 26px rgba(238, 111, 111, 0.45); + } +} +@keyframes flash-crit { + 35% { + box-shadow: + inset 0 0 0 2px var(--yellow), + 0 0 34px rgba(243, 201, 105, 0.6); + } +} +@keyframes flash-shield { + 35% { + box-shadow: + inset 0 0 0 2px var(--cyan), + 0 0 26px rgba(24, 197, 217, 0.5); + } +} + +.combatant.dead .ship-visual { + opacity: 0.3; + filter: grayscale(0.85); +} + +.combatant.winner { + border-color: var(--yellow); +} +.combatant.winner .ship-visual { + animation: winner-pulse 1.5s ease-in-out infinite; +} +.combatant.loser { + opacity: 0.55; +} +.combatant.loser .ship-visual { + animation: loser-out 0.6s ease forwards; +} + +@keyframes winner-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(243, 201, 105, 0); + } + 50% { + box-shadow: 0 0 26px 2px rgba(243, 201, 105, 0.35); + } +} + +@keyframes loser-out { + to { + opacity: 0.25; + transform: scale(0.92) rotate(-2deg); + filter: grayscale(1); + } +} + +@keyframes outcome-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.96); + } + to { + opacity: 1; + transform: none; + } +} + +.actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 180px)); + justify-content: center; + gap: 8px; +} + +.actions button { + width: 100%; + min-height: 44px; +} + +.maneuver-actions { + display: grid; + gap: 8px; +} + +.maneuver-actions[hidden] { + display: none; +} + +.maneuver-head { + display: flex; + align-items: center; + justify-content: center; + min-height: 14px; +} + +.maneuver-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.maneuver-button { + height: auto; + min-height: 96px; + padding: 11px 12px; + display: grid; + align-content: start; + gap: 8px; + text-align: left; + border: 1px solid var(--line); + border-radius: 8px; + background: + radial-gradient( + 130% 90% at 50% -20%, + color-mix(in srgb, var(--slot, var(--cyan)) 14%, transparent), + transparent 62% + ), + #0a1014; + transition: + transform 120ms ease, + border-color 160ms ease, + box-shadow 180ms ease; +} + +.maneuver-button:hover:not(:disabled) { + transform: translateY(-2px); + border-color: color-mix( + in srgb, + var(--slot, var(--cyan)) 55%, + var(--line-strong) + ); + background: + radial-gradient( + 130% 90% at 50% -20%, + color-mix(in srgb, var(--slot, var(--cyan)) 20%, transparent), + transparent 62% + ), + #0c1620; +} + +.maneuver-button.active { + border-color: var(--slot, var(--cyan)); + opacity: 1; + box-shadow: + inset 0 0 0 1px + color-mix(in srgb, var(--slot, var(--cyan)) 45%, transparent), + 0 0 22px color-mix(in srgb, var(--slot, var(--cyan)) 26%, transparent); +} + +.maneuver-button.dimmed { + opacity: 0.42; +} + +.maneuver-top { + display: flex; + align-items: center; + gap: 7px; +} + +.maneuver-icon { + width: 18px; + height: 18px; + display: inline-flex; + color: var(--slot, var(--cyan)); +} + +.maneuver-icon svg { + width: 18px; + height: 18px; +} + +.maneuver-slot { + color: var(--slot, var(--cyan)); + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.maneuver-lock { + margin-left: auto; + color: var(--slot, var(--cyan)); + font-weight: 900; +} + +.maneuver-button strong { + font-size: 14px; + color: var(--text); +} + +.maneuver-fx { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.fx { + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + font-style: normal; + padding: 3px 6px; + border-radius: 999px; + border: 1px solid var(--line); + color: var(--muted); + white-space: nowrap; +} + +.fx-buff { + color: color-mix(in srgb, var(--slot, var(--cyan)) 78%, var(--text)); + border-color: color-mix(in srgb, var(--slot, var(--cyan)) 38%, transparent); + background: color-mix(in srgb, var(--slot, var(--cyan)) 10%, transparent); +} + +.fx-cost { + color: #ffb3ae; + border-color: rgba(238, 111, 111, 0.38); + background: rgba(238, 111, 111, 0.08); +} + +.maneuver-head .eyebrow.locked { + color: var(--green); +} + +.ship-abilities { + display: grid; + gap: 8px; +} + +.ability-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.ability-card { + padding: 10px; + display: grid; + align-content: start; + gap: 7px; + border: 1px solid var(--line); + border-radius: 8px; + background: + radial-gradient( + 130% 90% at 50% -20%, + color-mix(in srgb, var(--slot, var(--cyan)) 12%, transparent), + transparent 62% + ), + #0a1014; +} + +.ability-card strong { + font-size: 13px; + color: var(--text); +} + +.ability-cards.compact .ability-card { + cursor: help; +} + +.tooltip { + position: fixed; + z-index: 50; + max-width: 250px; + pointer-events: none; + display: grid; + gap: 7px; + padding: 10px 11px; + border: 1px solid + color-mix(in srgb, var(--slot, var(--line-strong)) 55%, var(--line-strong)); + border-radius: 8px; + background: #0c141a; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6); + transform: translateY(4px); + transition: transform 120ms ease; +} + +.tooltip[hidden] { + display: none; +} +.tooltip.show { + transform: none; +} + +.tooltip .tip-head { + display: flex; + align-items: baseline; + gap: 8px; +} + +.tooltip .tip-slot { + color: var(--slot, var(--cyan)); + font: + 800 9px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.tooltip .tip-name { + font-size: 13px; + font-weight: 800; + color: var(--text); +} + +.tooltip .tip-fx { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.tooltip .tip-desc { + color: var(--muted); + font-size: 12px; + line-height: 1.3; +} + +.confirm-dialog { + width: min(420px, calc(100vw - 32px)); + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--surface); + color: var(--text); + padding: 0; + box-shadow: 0 24px 90px rgba(0, 0, 0, 0.62); +} + +.confirm-dialog::backdrop { + background: rgba(0, 0, 0, 0.68); +} + +.confirm-card { + display: grid; + gap: 14px; + padding: 18px; +} + +.confirm-card p { + line-height: 1.45; +} + +.confirm-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.waiting-card { + min-height: clamp(390px, calc(100vh - 220px), 560px); + display: grid; + place-items: center; + text-align: center; +} + +.waiting-card > div { + display: grid; + gap: 14px; + justify-items: center; +} + +.waiting-card h1 { + margin: 0; +} + +.waiting-card h1::after { + content: ''; + display: inline-block; + width: 1.2em; + text-align: left; + animation: loading-dots 1.2s steps(4, end) infinite; +} + +.waiting-card p { + max-width: 460px; + margin: 0; +} + +#cancelSearch { + margin-top: 10px; + height: 38px; + padding: 0 26px; + background: transparent; + border-color: var(--line); + color: var(--muted); +} + +#cancelSearch:hover:not(:disabled) { + color: var(--text); + border-color: var(--cyan); + background: #14222a; +} + +.scanner { + width: 104px; + height: 104px; + border: 1px solid #2f6470; + border-radius: 999px; + position: relative; + animation: scanner-pulse 1.6s ease-in-out infinite; +} + +.scanner::before { + content: ''; + position: absolute; + left: 50%; + top: 24px; + width: 18px; + height: 56px; + transform: translateX(-50%); + background: var(--cyan); + clip-path: polygon(50% 0, 100% 100%, 50% 78%, 0 100%); +} + +.scanner::after { + content: ''; + position: absolute; + inset: 11px; + border: 1px solid rgba(24, 197, 217, 0.26); + border-radius: 999px; + animation: scanner-spin 2.2s linear infinite; + clip-path: polygon(50% 0, 100% 0, 100% 38%, 50% 50%); +} + +@keyframes scanner-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(24, 197, 217, 0.18); + } + 50% { + box-shadow: 0 0 0 10px rgba(24, 197, 217, 0); + } +} + +@keyframes scanner-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes loading-dots { + 0% { + content: ''; + } + 25% { + content: '.'; + } + 50% { + content: '..'; + } + 75%, + 100% { + content: '...'; + } +} + +.empty { + color: var(--muted); + border: 1px dashed var(--line-strong); + border-radius: 8px; + padding: 14px; + text-align: center; +} + +.empty.wide { + grid-column: 1 / -1; + min-height: 250px; + display: grid; + place-items: center; +} + +.toast { + display: none; + position: fixed; + top: 24px; + left: 50%; + z-index: 20; + transform: translateX(-50%); + min-height: 42px; + width: min(420px, calc(100vw - 32px)); + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: #0a1014; + color: #d8e5e2; + padding: 0 14px; + font: + 800 12px/1.2 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.toast.error { + display: flex; + border-color: #823837; + background: #241010; + color: #ffb8b3; +} + +.page-foot { + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + width: 100%; + margin-left: auto; + margin-right: auto; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid rgba(120, 170, 190, 0.1); +} + +.page-foot .by { + color: #7e9cad; + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.page-foot img { + height: 28px; + width: auto; + opacity: 0.9; +} + +@media (max-width: 1180px) { + .setup-screen, + .duel-screen { + grid-template-columns: 1fr; + } +} + +@media (max-width: 860px) { + .app { + width: min(100vw - 20px, 720px); + padding-top: 10px; + } + + .topbar { + align-items: flex-start; + flex-direction: column; + } + + .launch-card { + grid-template-columns: 1fr; + } + + .ship-preview { + min-height: 196px; + } + + .combatants { + grid-template-columns: 1fr; + } + + .maneuver-grid, + .ability-cards { + grid-template-columns: 1fr; + } +} + +@media (max-width: 540px) { + .inline { + grid-template-columns: 1fr; + } +} diff --git a/spacetime-lobby-ts/example/scripts/test-model.ts b/spacetime-lobby-ts/example/scripts/test-model.ts new file mode 100644 index 00000000000..baac9dd550c --- /dev/null +++ b/spacetime-lobby-ts/example/scripts/test-model.ts @@ -0,0 +1,181 @@ +import assert from 'node:assert/strict'; +import { + selectActiveRoom, + selectHighlightedManeuver, + selectLatestDuel, + selectLatestTicket, + selectLobbyScreen, + type Combatant, + type Duel, + type DuelManeuver, + type LobbyRoom, + type LobbyTicket, +} from '../src/model'; + +const timestamp = (value: bigint) => ({ microsSinceUnixEpoch: value }); +const ticket = ( + ticketId: string, + createdAt: bigint, + status: string, + roomId?: bigint +): LobbyTicket => ({ + ticketId, + pool: 'spaceship_duel', + status: { tag: status }, + roomId, + createdAt: timestamp(createdAt), +}); +const duel = ( + roomId: bigint, + updatedAt: bigint, + status: string, + round = 0 +): Duel => ({ + roomId, + status: { tag: status }, + round, + updatedAt: timestamp(updatedAt), +}); +const room = (roomId: bigint): LobbyRoom => ({ + roomId, + pool: 'spaceship_duel', + status: { tag: 'Ready' }, + capacity: 2, + createdAt: timestamp(roomId), +}); + +const olderTicket = ticket('older', 1n, 'Cancelled'); +const currentTicket = ticket('current', 2n, 'Matched', 20n); +assert.equal(selectLatestTicket([currentTicket, olderTicket]), currentTicket); +assert.equal(selectLatestTicket([]), undefined); + +const unrelatedActiveDuel = duel(10n, 3n, 'Active'); +const ticketDuel = duel(20n, 1n, 'Complete'); +assert.equal( + selectLatestDuel([unrelatedActiveDuel, ticketDuel], currentTicket), + ticketDuel +); +assert.equal( + selectLatestDuel([ticketDuel, unrelatedActiveDuel], undefined), + unrelatedActiveDuel +); +assert.equal(selectLatestDuel([ticketDuel], undefined), undefined); + +const rooms = [room(10n), room(20n)]; +assert.equal( + selectActiveRoom(rooms, unrelatedActiveDuel, currentTicket), + rooms[0] +); +assert.equal(selectActiveRoom(rooms, ticketDuel, currentTicket), rooms[1]); +assert.equal(selectActiveRoom(rooms, undefined, undefined), undefined); + +assert.equal( + selectLobbyScreen({ + screenOverride: 'setup', + ticket: ticket('queued', 3n, 'Queued'), + duel: unrelatedActiveDuel, + room: rooms[0], + playedRoomId: '10', + }), + 'setupScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: ticket('queued', 3n, 'Queued'), + duel: undefined, + room: undefined, + playedRoomId: null, + }), + 'waitingScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: currentTicket, + duel: ticketDuel, + room: rooms[1], + playedRoomId: '20', + }), + 'duelScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: currentTicket, + duel: ticketDuel, + room: rooms[1], + playedRoomId: null, + }), + 'setupScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: undefined, + duel: undefined, + room: rooms[0], + playedRoomId: null, + }), + 'duelScreen' +); +assert.equal( + selectLobbyScreen({ + screenOverride: null, + ticket: undefined, + duel: undefined, + room: undefined, + playedRoomId: null, + }), + 'setupScreen' +); + +const combatant: Combatant = { + roomId: 10n, + subject: 'pilot', + displayName: 'Pilot', + shipClass: { tag: 'Interceptor' }, + hull: 100, + maxHull: 100, + shields: 50, + maxShields: 50, + attack: 20, + defense: 10, + speed: 8, + critBps: 500, + dodgeBps: 1000, +}; +const choice = ( + round: number, + slot: 'Primary' | 'Defensive' +): DuelManeuver => ({ + choiceId: `${round}:${slot}`, + roomId: 10n, + round, + subject: 'pilot', + slot: { tag: slot }, + maneuverId: slot.toLowerCase(), +}); +const choices = [choice(2, 'Defensive'), choice(1, 'Primary')]; +const unrelatedChoice = { + ...choice(99, 'Primary'), + choiceId: 'unrelated', + roomId: 99n, +}; +assert.equal( + selectHighlightedManeuver(duel(10n, 5n, 'Active', 1), combatant, [ + unrelatedChoice, + ...choices, + ]), + choices[0] +); +assert.equal( + selectHighlightedManeuver(duel(10n, 5n, 'Complete', 2), combatant, choices), + choices[0] +); +assert.equal( + selectHighlightedManeuver(duel(10n, 5n, 'Configuring'), combatant, choices), + undefined +); + +console.log('lobby model tests passed'); diff --git a/spacetime-lobby-ts/example/server.ts b/spacetime-lobby-ts/example/server.ts new file mode 100644 index 00000000000..5cbee4cedfe --- /dev/null +++ b/spacetime-lobby-ts/example/server.ts @@ -0,0 +1,47 @@ +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import * as dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + const parsed = dotenv.parse(readFileSync(pathname)) as Record; + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) process.env[key] = value; + } +} + +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8797', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_DATABASE = process.env.STDB_DATABASE ?? 'spacetime-lobby-example'; + +const app = express(); +app.use(express.json({ limit: '128kb' })); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, database: STDB_DATABASE }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ stdbUri: STDB_URI, database: STDB_DATABASE }); +}); + +app.listen(PORT, HOST, () => { + process.stdout.write( + `\nspacetime-lobby-example listening on http://${HOST}:${PORT}\n` + ); + process.stdout.write(` database: ${STDB_URI}/${STDB_DATABASE}\n\n`); +}); diff --git a/spacetime-lobby-ts/example/spacetimedb/package.json b/spacetime-lobby-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..8f9e7d95421 --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-lobby-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-lobby-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-lobby-example" + }, + "dependencies": { + "@spacetimedb/lobby": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-lobby-ts/example/spacetimedb/src/catalog.ts b/spacetime-lobby-ts/example/spacetimedb/src/catalog.ts new file mode 100644 index 00000000000..59a2630cc38 --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/src/catalog.ts @@ -0,0 +1,210 @@ +import { ManeuverSlot, ShipClass } from './schema'; + +export const SHIP_CATALOG = [ + { + shipId: 'Bulwark', + shipClass: ShipClass.Bulwark, + role: 'Heavy defender', + description: + 'High hull and shields. Wins by absorbing punishment and grinding the opponent down.', + hull: 160, + shields: 80, + attack: 18, + defense: 7, + speed: 3, + critBps: 500, + dodgeBps: 400, + }, + { + shipId: 'Interceptor', + shipClass: ShipClass.Interceptor, + role: 'Fast attack', + description: + 'High damage and speed. Fragile, but dangerous if it can end the duel quickly.', + hull: 95, + shields: 45, + attack: 30, + defense: 2, + speed: 8, + critBps: 1400, + dodgeBps: 900, + }, + { + shipId: 'Phantom', + shipClass: ShipClass.Phantom, + role: 'Evasion striker', + description: + 'Dodge and crit focused. Swingy, slippery, and built around high-risk turns.', + hull: 110, + shields: 50, + attack: 22, + defense: 3, + speed: 7, + critBps: 1200, + dodgeBps: 2200, + }, + { + shipId: 'Artillery', + shipClass: ShipClass.Artillery, + role: 'Burst cannon', + description: + 'Slow, heavy burst damage. Weak shields, but every shot can change the duel.', + hull: 105, + shields: 35, + attack: 38, + defense: 1, + speed: 2, + critBps: 900, + dodgeBps: 300, + }, +]; + +export const MANEUVER_CATALOG = [ + { + shipClass: ShipClass.Bulwark, + slot: ManeuverSlot.Primary, + name: 'Cannon Volley', + description: 'Reliable pressure with no tradeoff.', + damageBps: 10000, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Bulwark, + slot: ManeuverSlot.Defensive, + name: 'Fortify', + description: 'Restore shields and absorb the next hit.', + damageBps: 5500, + defenseBps: 4500, + shieldRestore: 18, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Bulwark, + slot: ManeuverSlot.Risky, + name: 'Ramming Burn', + description: 'Spend shields for a heavy strike.', + damageBps: 15000, + defenseBps: -1500, + shieldRestore: 0, + selfShieldCost: 12, + critBonusBps: 0, + dodgeBonusBps: -300, + }, + { + shipClass: ShipClass.Interceptor, + slot: ManeuverSlot.Primary, + name: 'Pulse Lasers', + description: 'Fast, clean damage.', + damageBps: 10500, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Interceptor, + slot: ManeuverSlot.Defensive, + name: 'Evasive Roll', + description: 'Light damage while boosting dodge.', + damageBps: 6500, + defenseBps: 0, + shieldRestore: 6, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 2500, + }, + { + shipClass: ShipClass.Interceptor, + slot: ManeuverSlot.Risky, + name: 'Afterburn Strike', + description: 'High crit attack with shield burn.', + damageBps: 13000, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 10, + critBonusBps: 1800, + dodgeBonusBps: -500, + }, + { + shipClass: ShipClass.Phantom, + slot: ManeuverSlot.Primary, + name: 'Phase Lance', + description: 'A balanced strike with evasive drift.', + damageBps: 9500, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 600, + }, + { + shipClass: ShipClass.Phantom, + slot: ManeuverSlot.Defensive, + name: 'Cloak', + description: 'Low damage, huge dodge, and a small shield refresh.', + damageBps: 4500, + defenseBps: 0, + shieldRestore: 4, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 4200, + }, + { + shipClass: ShipClass.Phantom, + slot: ManeuverSlot.Risky, + name: 'Ambush', + description: 'Crit-heavy attack that lowers defenses.', + damageBps: 12500, + defenseBps: -1000, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 2600, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Artillery, + slot: ManeuverSlot.Primary, + name: 'Rail Shot', + description: 'Slow but punishing direct fire.', + damageBps: 11500, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Artillery, + slot: ManeuverSlot.Defensive, + name: 'Brace', + description: 'Hold position, restore shields, reduce incoming damage.', + damageBps: 6500, + defenseBps: 3500, + shieldRestore: 10, + selfShieldCost: 0, + critBonusBps: 0, + dodgeBonusBps: 0, + }, + { + shipClass: ShipClass.Artillery, + slot: ManeuverSlot.Risky, + name: 'Overload Cannon', + description: 'Massive shot that drains shields and hurts evasiveness.', + damageBps: 16500, + defenseBps: 0, + shieldRestore: 0, + selfShieldCost: 16, + critBonusBps: 900, + dodgeBonusBps: -400, + }, +].map(row => ({ + ...row, + maneuverId: `${row.shipClass.tag}:${row.slot.tag}`, +})); diff --git a/spacetime-lobby-ts/example/spacetimedb/src/index.ts b/spacetime-lobby-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..afdcce078cd --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,835 @@ +import { SenderError, t } from 'spacetimedb/server'; +import * as lobby from '@spacetimedb/lobby/submodule'; + +import { + DUEL_POOL, + AI_DUEL_POOL_PREFIX, + RATING_POOL, + MATCH_SIZE, + DISPLAY_NAME_MAX, + shipClass, + ShipClass, + maneuverSlot, + ManeuverSlot, + DuelStatus, + spacetimedb, + type WriteCtx, + type CombatantRow, + type ManeuverRow, + type DuelRow, + type ShipClassValue, + type ManeuverSlotValue, +} from './schema'; +import { MANEUVER_CATALOG, SHIP_CATALOG } from './catalog'; +export { default } from './schema'; +export * from './views'; + +function fail(message: string): never { + throw new SenderError(`duel.${message}`); +} + +function subjectFor(ctx: { sender: { toHexString(): string } }): string { + return ctx.sender.toHexString(); +} + +function displaySubject(subject: string): string { + return `Pilot ${subject.slice(0, 6).toUpperCase()}`; +} + +function aiSubjectFor(subject: string): string { + return `ai:${subject}`; +} + +function aiPoolFor(subject: string): string { + return `${AI_DUEL_POOL_PREFIX}:${subject.slice(0, 32)}`; +} + +function isDuelPool(pool: string): boolean { + return pool === DUEL_POOL || pool.startsWith(`${AI_DUEL_POOL_PREFIX}:`); +} + +function normalizeDisplayName(value: string): string { + const out = value.trim().replace(/\s+/g, ' '); + if (!out) fail('invalid_display_name'); + return out.slice(0, DISPLAY_NAME_MAX); +} + +function combatantId(roomId: bigint, subject: string): string { + return `${roomId.toString()}:${subject}`; +} + +function choiceId(roomId: bigint, round: number, subject: string): string { + return `${roomId.toString()}:${round}:${subject}`; +} + +function maneuverId(ship: ShipClassValue, slot: ManeuverSlotValue): string { + return `${ship.tag}:${slot.tag}`; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function shipStats(ctx: WriteCtx, cls: ShipClassValue) { + const row = ctx.db.shipCatalog.shipId.find(cls.tag); + if (!row) fail('ship_catalog_missing'); + return row; +} + +function maneuverFor( + ctx: WriteCtx, + ship: ShipClassValue, + slot: ManeuverSlotValue +) { + const row = ctx.db.maneuverCatalog.maneuverId.find(maneuverId(ship, slot)); + if (!row) fail('maneuver_missing'); + return row; +} + +function choiceFor( + ctx: WriteCtx, + roomId: bigint, + round: number, + subject: string +) { + return ctx.db.duelManeuver.choiceId.find(choiceId(roomId, round, subject)); +} + +function upsertManeuverChoice( + ctx: WriteCtx, + roomId: bigint, + round: number, + subject: string, + slot: ManeuverSlotValue, + ship: ShipClassValue +) { + const id = choiceId(roomId, round, subject); + const maneuver = maneuverFor(ctx, ship, slot); + const row = { + choiceId: id, + roomId, + round, + subject, + slot, + maneuverId: maneuver.maneuverId, + chosenAt: ctx.timestamp, + }; + if (ctx.db.duelManeuver.choiceId.find(id)) + ctx.db.duelManeuver.choiceId.update(row); + else ctx.db.duelManeuver.insert(row); + return row; +} + +function seedCatalog(ctx: WriteCtx): void { + for (const row of SHIP_CATALOG) { + if (ctx.db.shipCatalog.shipId.find(row.shipId)) + ctx.db.shipCatalog.shipId.update(row); + else ctx.db.shipCatalog.insert(row); + } + for (const row of MANEUVER_CATALOG) { + if (ctx.db.maneuverCatalog.maneuverId.find(row.maneuverId)) + ctx.db.maneuverCatalog.maneuverId.update(row); + else ctx.db.maneuverCatalog.insert(row); + } +} + +function ensurePilot(ctx: WriteCtx, subject: string) { + const existing = ctx.db.pilot.subject.find(subject); + if (existing) return existing; + const row = { + subject, + displayName: displaySubject(subject), + shipClass: ShipClass.Interceptor, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }; + ctx.db.pilot.insert(row); + return row; +} + +function ensureAiPilot(ctx: WriteCtx, subject: string) { + const existing = ensurePilot(ctx, subject); + const seed = hashSeed(subject); + const ship = [ + ShipClass.Bulwark, + ShipClass.Interceptor, + ShipClass.Phantom, + ShipClass.Artillery, + ][seed % 4]; + const displayName = 'Arena AI'; + const next = { + ...existing, + displayName, + shipClass: ship, + updatedAt: ctx.timestamp, + }; + ctx.db.pilot.subject.update(next); + return next; +} + +function seatsForRoom(ctx: WriteCtx, roomId: bigint) { + return [...ctx.db.lobby.lobbyRoomSeat.byRoom.filter(roomId)]; +} + +function hasSeat(ctx: WriteCtx, roomId: bigint, subject: string): boolean { + return [...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subject)].some( + seat => seat.roomId === roomId + ); +} + +function roomFor(ctx: WriteCtx, roomId: bigint) { + return ctx.db.lobby.lobbyRoom.roomId.find(roomId); +} + +function log( + ctx: WriteCtx, + roomId: bigint, + round: number, + message: string +): void { + ctx.db.duelRoundLog.insert({ + logId: 0n, + roomId, + round, + message, + createdAt: ctx.timestamp, + }); +} + +function ensureCombatant(ctx: WriteCtx, roomId: bigint, subject: string) { + const id = combatantId(roomId, subject); + const existing = ctx.db.duelCombatant.combatantId.find(id); + if (existing) return existing; + const p = ensurePilot(ctx, subject); + const stats = shipStats(ctx, p.shipClass); + const row = { + combatantId: id, + roomId, + subject, + displayName: p.displayName, + shipClass: p.shipClass, + hull: stats.hull, + maxHull: stats.hull, + shields: stats.shields, + maxShields: stats.shields, + attack: stats.attack, + defense: stats.defense, + speed: stats.speed, + critBps: stats.critBps, + dodgeBps: stats.dodgeBps, + updatedAt: ctx.timestamp, + }; + ctx.db.duelCombatant.insert(row); + return row; +} + +function ensureDuelForRoom(ctx: WriteCtx, roomId: bigint) { + const existing = ctx.db.duel.roomId.find(roomId); + if (existing) return existing; + const room = roomFor(ctx, roomId); + if (!room || !isDuelPool(room.pool)) fail('room_not_found'); + const seats = seatsForRoom(ctx, roomId); + if (seats.length < MATCH_SIZE) fail('room_not_ready'); + const row = { + roomId, + status: DuelStatus.Configuring, + round: 0, + winnerSubject: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }; + ctx.db.duel.insert(row); + for (const seat of seats.slice(0, MATCH_SIZE)) { + ensureCombatant(ctx, roomId, seat.subject); + } + log(ctx, roomId, 0, 'Match found. Pilots are docking into the arena.'); + return row; +} + +function refreshDuelStatus(ctx: WriteCtx, roomId: bigint) { + const current = ensureDuelForRoom(ctx, roomId); + if (current.status.tag !== DuelStatus.Configuring.tag) return current; + const seats = seatsForRoom(ctx, roomId); + const allJoined = + seats.length >= MATCH_SIZE && + seats.every(seat => seat.status.tag === lobby.SeatStatus.Joined.tag); + if (!allJoined) return current; + const updated = { + ...current, + status: DuelStatus.Active, + updatedAt: ctx.timestamp, + }; + ctx.db.duel.roomId.update(updated); + log(ctx, roomId, 0, 'Both pilots joined. Duel is live.'); + return updated; +} + +function hashSeed(input: string): number { + let h = 2166136261; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function roll(seed: string): number { + let x = hashSeed(seed) || 1; + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + return (x >>> 0) % 10_000; +} + +function requireMapped( + values: Map, + key: string, + reason: string +): T { + const value = values.get(key); + if (value === undefined) fail(reason); + return value; +} + +function applyDamage(target: CombatantRow, amount: number): CombatantRow { + let remaining = Math.max(0, Math.floor(amount)); + const shieldDamage = Math.min(target.shields, remaining); + remaining -= shieldDamage; + return { + ...target, + shields: target.shields - shieldDamage, + hull: Math.max(0, target.hull - remaining), + }; +} + +function applyManeuverSetup( + ctx: WriteCtx, + roomId: bigint, + round: number, + combatant: CombatantRow, + maneuver: ManeuverRow +): CombatantRow { + let next = combatant; + if (maneuver.selfShieldCost > 0) { + const cost = Math.min(next.shields, maneuver.selfShieldCost); + next = { ...next, shields: next.shields - cost }; + if (cost > 0) + log( + ctx, + roomId, + round, + `${next.displayName} burns ${cost} shields to power ${maneuver.name}.` + ); + } + if (maneuver.shieldRestore > 0 && next.shields < next.maxShields) { + const restored = Math.min( + maneuver.shieldRestore, + next.maxShields - next.shields + ); + next = { ...next, shields: next.shields + restored }; + if (restored > 0) + log( + ctx, + roomId, + round, + `${next.displayName} restores ${restored} shields with ${maneuver.name}.` + ); + } + return next; +} + +function attackOnce( + ctx: WriteCtx, + roomId: bigint, + round: number, + attacker: CombatantRow, + defender: CombatantRow, + attackerMove: ManeuverRow, + defenderMove: ManeuverRow +): CombatantRow { + if (attacker.hull <= 0 || defender.hull <= 0) return defender; + const prefix = `${roomId.toString()}:${round}:${attacker.subject}:${defender.subject}`; + const dodgeBps = clamp( + defender.dodgeBps + defenderMove.dodgeBonusBps, + 0, + 9000 + ); + if (roll(`${prefix}:dodge`) < dodgeBps) { + log( + ctx, + roomId, + round, + `${defender.displayName}'s ${defender.shipClass.tag} evades ${attackerMove.name}.` + ); + return defender; + } + const critBps = clamp(attacker.critBps + attackerMove.critBonusBps, 0, 9000); + const crit = roll(`${prefix}:crit`) < critBps; + const baseDamage = Math.max(1, attacker.attack - defender.defense); + const attackDamage = Math.max( + 1, + Math.floor((baseDamage * Math.max(0, attackerMove.damageBps)) / 10_000) + ); + const defenseBps = clamp(defenderMove.defenseBps, -5000, 8500); + const mitigated = Math.max( + 1, + Math.floor((attackDamage * (10_000 - defenseBps)) / 10_000) + ); + const damage = crit ? Math.floor(mitigated * 1.75) : mitigated; + const updated = applyDamage(defender, damage); + log( + ctx, + roomId, + round, + `${attacker.displayName} uses ${attackerMove.name} on ${defender.displayName} for ${damage}${crit ? ' critical' : ''} damage.` + ); + return updated; +} + +function aiSlotFor( + roomId: bigint, + round: number, + subject: string +): ManeuverSlotValue { + const slots = [ + ManeuverSlot.Primary, + ManeuverSlot.Defensive, + ManeuverSlot.Risky, + ]; + return slots[ + hashSeed(`${roomId.toString()}:${round}:${subject}:ai-move`) % slots.length + ]; +} + +function ensureAiChoices( + ctx: WriteCtx, + roomId: bigint, + round: number, + combatants: CombatantRow[] +): void { + for (const combatant of combatants) { + if (!combatant.subject.startsWith('ai:')) continue; + if (choiceFor(ctx, roomId, round, combatant.subject)) continue; + upsertManeuverChoice( + ctx, + roomId, + round, + combatant.subject, + aiSlotFor(roomId, round, combatant.subject), + combatant.shipClass + ); + } +} + +function completeDuel( + ctx: WriteCtx, + d: DuelRow, + round: number, + winnerSubject: string, + reporterSubject: string +): void { + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Complete, + round, + winnerSubject, + updatedAt: ctx.timestamp, + }); + const winner = ctx.db.duelCombatant.combatantId.find( + combatantId(d.roomId, winnerSubject) + ); + log( + ctx, + d.roomId, + round, + `${winner?.displayName ?? 'A pilot'} wins the duel.` + ); + lobby.reportMatchResult(ctx.as.lobby, { + roomId: d.roomId, + subject: reporterSubject, + winnerSubject, + }); + lobby.closeRoom(ctx.as.lobby, { roomId: d.roomId, subject: reporterSubject }); +} + +function maybeResolveRound( + ctx: WriteCtx, + roomId: bigint, + reporterSubject: string +): void { + const d = refreshDuelStatus(ctx, roomId); + if ( + d.status.tag === DuelStatus.Complete.tag || + d.status.tag === DuelStatus.Abandoned.tag + ) + return; + if (d.status.tag !== DuelStatus.Active.tag) return; + const combatants = sortedCombatants(ctx, roomId); + if (combatants.length < MATCH_SIZE) fail('combatants_missing'); + const round = d.round + 1; + ensureAiChoices(ctx, roomId, round, combatants); + const moves = new Map(); + for (const combatant of combatants) { + const choice = choiceFor(ctx, roomId, round, combatant.subject); + if (!choice) return; + const move = ctx.db.maneuverCatalog.maneuverId.find(choice.maneuverId); + if (!move) fail('maneuver_missing'); + moves.set(combatant.subject, move); + } + + log( + ctx, + roomId, + round, + `Round ${round}. ${combatants.map(c => `${c.displayName}: ${requireMapped(moves, c.subject, 'maneuver_missing').name}`).join(' | ')}` + ); + const next = new Map(); + for (const combatant of combatants) { + next.set( + combatant.subject, + applyManeuverSetup( + ctx, + roomId, + round, + combatant, + requireMapped(moves, combatant.subject, 'maneuver_missing') + ) + ); + } + const ordered = [...next.values()].sort((a, b) => { + if (a.speed !== b.speed) return b.speed - a.speed; + return a.subject.localeCompare(b.subject); + }); + + const firstInitial = ordered[0]; + const secondInitial = ordered[1]; + if (!firstInitial || !secondInitial) fail('combatants_missing'); + let first = firstInitial; + let second = secondInitial; + second = attackOnce( + ctx, + roomId, + round, + first, + second, + requireMapped(moves, first.subject, 'maneuver_missing'), + requireMapped(moves, second.subject, 'maneuver_missing') + ); + next.set(second.subject, second); + if (second.hull > 0) { + first = requireMapped(next, first.subject, 'combatants_missing'); + second = requireMapped(next, second.subject, 'combatants_missing'); + first = attackOnce( + ctx, + roomId, + round, + second, + first, + requireMapped(moves, second.subject, 'maneuver_missing'), + requireMapped(moves, first.subject, 'maneuver_missing') + ); + next.set(first.subject, first); + } + + const updatedCombatants = [...next.values()]; + for (const combatant of updatedCombatants) { + ctx.db.duelCombatant.combatantId.update({ + ...combatant, + updatedAt: ctx.timestamp, + }); + } + const alive = updatedCombatants.filter(c => c.hull > 0); + if (alive.length === 1) { + completeDuel(ctx, d, round, alive[0].subject, reporterSubject); + } else if (alive.length === 0) { + const winner = + updatedCombatants[0].hull >= updatedCombatants[1].hull + ? updatedCombatants[0] + : updatedCombatants[1]; + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Complete, + round, + winnerSubject: winner.subject, + updatedAt: ctx.timestamp, + }); + log( + ctx, + roomId, + round, + `${winner.displayName} wins by emergency adjudication.` + ); + lobby.reportMatchResult(ctx.as.lobby, { + roomId, + subject: reporterSubject, + winnerSubject: winner.subject, + }); + lobby.closeRoom(ctx.as.lobby, { roomId, subject: reporterSubject }); + } else { + ctx.db.duel.roomId.update({ ...d, round, updatedAt: ctx.timestamp }); + } +} + +function sortedCombatants(ctx: WriteCtx, roomId: bigint) { + return [...ctx.db.duelCombatant.byRoom.filter(roomId)].sort((a, b) => { + if (a.speed !== b.speed) return b.speed - a.speed; + return a.subject.localeCompare(b.subject); + }); +} + +export const set_display_name = spacetimedb.reducer( + { displayName: t.string() }, + (ctx, args) => { + const subject = subjectFor(ctx); + const existing = ensurePilot(ctx, subject); + const displayName = normalizeDisplayName(args.displayName); + ctx.db.pilot.subject.update({ + ...existing, + displayName, + updatedAt: ctx.timestamp, + }); + for (const combatant of [ + ...ctx.db.duelCombatant.bySubject.filter(subject), + ]) { + const d = ctx.db.duel.roomId.find(combatant.roomId); + if (d && d.status.tag === DuelStatus.Configuring.tag) { + ctx.db.duelCombatant.combatantId.update({ + ...combatant, + displayName, + updatedAt: ctx.timestamp, + }); + } + } + } +); + +export const select_ship = spacetimedb.reducer({ shipClass }, (ctx, args) => { + const subject = subjectFor(ctx); + const existing = ensurePilot(ctx, subject); + ctx.db.pilot.subject.update({ + ...existing, + shipClass: args.shipClass, + updatedAt: ctx.timestamp, + }); + for (const combatant of [...ctx.db.duelCombatant.bySubject.filter(subject)]) { + const d = ctx.db.duel.roomId.find(combatant.roomId); + if (!d || d.status.tag !== DuelStatus.Configuring.tag) continue; + const stats = shipStats(ctx, args.shipClass); + ctx.db.duelCombatant.combatantId.update({ + ...combatant, + shipClass: args.shipClass, + hull: stats.hull, + maxHull: stats.hull, + shields: stats.shields, + maxShields: stats.shields, + attack: stats.attack, + defense: stats.defense, + speed: stats.speed, + critBps: stats.critBps, + dodgeBps: stats.dodgeBps, + updatedAt: ctx.timestamp, + }); + } +}); + +export const find_duel = spacetimedb.reducer({}, ctx => { + const subject = subjectFor(ctx); + const p = ensurePilot(ctx, subject); + const result = lobby.joinRankedQueue(ctx.as.lobby, { + pool: DUEL_POOL, + subject, + matchSize: MATCH_SIZE, + ratingPool: RATING_POOL, + attributesJson: JSON.stringify({ shipClass: p.shipClass.tag }), + ttlSeconds: 120, + }); + if (result.roomId !== undefined) ensureDuelForRoom(ctx, result.roomId); +}); + +export const fallback_to_ai = spacetimedb.reducer({}, ctx => { + const subject = subjectFor(ctx); + const p = ensurePilot(ctx, subject); + const publicTickets = [ + ...ctx.db.lobby.lobbyQueueTicket.bySubject.filter(subject), + ].filter( + ticket => + ticket.pool === DUEL_POOL && + ticket.status.tag === lobby.TicketStatus.Queued.tag + ); + for (const ticket of publicTickets) { + lobby.cancelTicket(ctx.as.lobby, { ticketId: ticket.ticketId, subject }); + } + + const aiSubject = aiSubjectFor(subject); + const aiPilot = ensureAiPilot(ctx, aiSubject); + const pool = aiPoolFor(subject); + lobby.joinRankedQueue(ctx.as.lobby, { + pool, + subject, + matchSize: MATCH_SIZE, + ratingPool: RATING_POOL, + attributesJson: JSON.stringify({ + shipClass: p.shipClass.tag, + fallback: 'human', + }), + ttlSeconds: 120, + }); + const result = lobby.joinRankedQueue(ctx.as.lobby, { + pool, + subject: aiSubject, + matchSize: MATCH_SIZE, + ratingPool: RATING_POOL, + attributesJson: JSON.stringify({ + shipClass: aiPilot.shipClass.tag, + fallback: 'ai', + }), + ttlSeconds: 120, + }); + if (result.roomId === undefined) fail('ai_match_failed'); + lobby.joinRoom(ctx.as.lobby, { roomId: result.roomId, subject }); + lobby.joinRoom(ctx.as.lobby, { roomId: result.roomId, subject: aiSubject }); + ensureDuelForRoom(ctx, result.roomId); + refreshDuelStatus(ctx, result.roomId); + log( + ctx, + result.roomId, + 0, + 'No rival found. Arena AI accepted the challenge.' + ); +}); + +export const join_duel_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + const subject = subjectFor(ctx); + ensurePilot(ctx, subject); + lobby.joinRoom(ctx.as.lobby, { roomId: args.roomId, subject }); + refreshDuelStatus(ctx, args.roomId); + } +); + +export const choose_maneuver = spacetimedb.reducer( + { roomId: t.u64(), slot: maneuverSlot }, + (ctx, args) => { + const subject = subjectFor(ctx); + if (!hasSeat(ctx, args.roomId, subject)) fail('not_in_room'); + const d = refreshDuelStatus(ctx, args.roomId); + if (d.status.tag === DuelStatus.Complete.tag) return; + if (d.status.tag === DuelStatus.Abandoned.tag) fail('duel_abandoned'); + if (d.status.tag !== DuelStatus.Active.tag) fail('duel_not_ready'); + const round = d.round + 1; + const combatant = ctx.db.duelCombatant.combatantId.find( + combatantId(args.roomId, subject) + ); + if (!combatant) fail('combatant_missing'); + upsertManeuverChoice( + ctx, + args.roomId, + round, + subject, + args.slot, + combatant.shipClass + ); + maybeResolveRound(ctx, args.roomId, subject); + } +); + +export const advance_duel = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + const subject = subjectFor(ctx); + const combatant = ctx.db.duelCombatant.combatantId.find( + combatantId(args.roomId, subject) + ); + if (!combatant) fail('combatant_missing'); + upsertManeuverChoice( + ctx, + args.roomId, + (ctx.db.duel.roomId.find(args.roomId)?.round ?? 0) + 1, + subject, + ManeuverSlot.Primary, + combatant.shipClass + ); + maybeResolveRound(ctx, args.roomId, subject); + } +); + +export const leave_duel = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + const subject = subjectFor(ctx); + const d = ctx.db.duel.roomId.find(args.roomId); + if (!d || d.status.tag === DuelStatus.Complete.tag) return; + if (!hasSeat(ctx, args.roomId, subject)) fail('not_in_room'); + const opponent = seatsForRoom(ctx, args.roomId).find( + seat => seat.subject !== subject + ); + if (!opponent) { + lobby.leaveRoom(ctx.as.lobby, { roomId: args.roomId, subject }); + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Abandoned, + updatedAt: ctx.timestamp, + }); + log(ctx, args.roomId, d.round, 'A pilot left. Duel abandoned.'); + return; + } + const winner = ctx.db.duelCombatant.combatantId.find( + combatantId(args.roomId, opponent.subject) + ); + const loser = ctx.db.duelCombatant.combatantId.find( + combatantId(args.roomId, subject) + ); + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Complete, + winnerSubject: opponent.subject, + updatedAt: ctx.timestamp, + }); + log( + ctx, + args.roomId, + d.round, + `${loser?.displayName ?? 'A pilot'} forfeits. ${winner?.displayName ?? 'Opponent'} wins.` + ); + lobby.reportMatchResult(ctx.as.lobby, { + roomId: args.roomId, + subject, + winnerSubject: opponent.subject, + }); + lobby.closeRoom(ctx.as.lobby, { roomId: args.roomId, subject }); + } +); + +export const queue_again = spacetimedb.reducer( + { roomId: t.option(t.u64()) }, + (ctx, args) => { + const subject = subjectFor(ctx); + ensurePilot(ctx, subject); + if (args.roomId !== undefined && hasSeat(ctx, args.roomId, subject)) { + const d = ctx.db.duel.roomId.find(args.roomId); + if (d && d.status.tag !== DuelStatus.Complete.tag) { + ctx.db.duel.roomId.update({ + ...d, + status: DuelStatus.Abandoned, + updatedAt: ctx.timestamp, + }); + } + lobby.closeRoom(ctx.as.lobby, { roomId: args.roomId, subject }); + } + const p = ensurePilot(ctx, subject); + lobby.joinRankedQueue(ctx.as.lobby, { + pool: DUEL_POOL, + subject, + matchSize: MATCH_SIZE, + ratingPool: RATING_POOL, + attributesJson: JSON.stringify({ shipClass: p.shipClass.tag }), + ttlSeconds: 120, + }); + } +); + +export const init = spacetimedb.init(ctx => { + lobby.installLobby(ctx.as.lobby); + seedCatalog(ctx); +}); diff --git a/spacetime-lobby-ts/example/spacetimedb/src/schema.ts b/spacetime-lobby-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..dc8767ea217 --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,242 @@ +import { + schema, + table, + t, + type Infer, + type InferSchema, + type ReducerCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import * as lobby from '@spacetimedb/lobby/submodule'; + +export const DUEL_POOL = 'spaceship_duel'; +export const AI_DUEL_POOL_PREFIX = 'spaceship_duel_ai'; +export const RATING_POOL = DUEL_POOL; +export const MATCH_SIZE = 2; +export const DISPLAY_NAME_MAX = 32; + +export const shipClass = t.enum('ShipClass', [ + 'Bulwark', + 'Interceptor', + 'Phantom', + 'Artillery', +]); +export const ShipClass = { + Bulwark: { tag: 'Bulwark' as const }, + Interceptor: { tag: 'Interceptor' as const }, + Phantom: { tag: 'Phantom' as const }, + Artillery: { tag: 'Artillery' as const }, +}; + +export const maneuverSlot = t.enum('ManeuverSlot', [ + 'Primary', + 'Defensive', + 'Risky', +]); +export const ManeuverSlot = { + Primary: { tag: 'Primary' as const }, + Defensive: { tag: 'Defensive' as const }, + Risky: { tag: 'Risky' as const }, +}; + +export const duelStatus = t.enum('DuelStatus', [ + 'Configuring', + 'Active', + 'Complete', + 'Abandoned', +]); +export const DuelStatus = { + Configuring: { tag: 'Configuring' as const }, + Active: { tag: 'Active' as const }, + Complete: { tag: 'Complete' as const }, + Abandoned: { tag: 'Abandoned' as const }, +}; + +export const pilot = table( + { + name: 'pilot', + public: false, + indexes: [ + { accessor: 'byUpdatedAt', algorithm: 'btree', columns: ['updatedAt'] }, + ], + }, + { + subject: t.string().primaryKey(), + displayName: t.string(), + shipClass, + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +export const shipCatalog = table( + { + name: 'ship_catalog', + public: true, + indexes: [ + { accessor: 'byShipClass', algorithm: 'btree', columns: ['shipClass'] }, + ], + }, + { + shipId: t.string().primaryKey(), + shipClass, + role: t.string(), + description: t.string(), + hull: t.u32(), + shields: t.u32(), + attack: t.u32(), + defense: t.u32(), + speed: t.u32(), + critBps: t.u32(), + dodgeBps: t.u32(), + } +); + +export const maneuverCatalog = table( + { + name: 'maneuver_catalog', + public: true, + indexes: [ + { accessor: 'byShipClass', algorithm: 'btree', columns: ['shipClass'] }, + { accessor: 'bySlot', algorithm: 'btree', columns: ['slot'] }, + ], + }, + { + maneuverId: t.string().primaryKey(), + shipClass, + slot: maneuverSlot, + name: t.string(), + description: t.string(), + damageBps: t.i32(), + defenseBps: t.i32(), + shieldRestore: t.u32(), + selfShieldCost: t.u32(), + critBonusBps: t.i32(), + dodgeBonusBps: t.i32(), + } +); + +export const duel = table( + { + name: 'duel', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { accessor: 'byUpdatedAt', algorithm: 'btree', columns: ['updatedAt'] }, + ], + }, + { + roomId: t.u64().primaryKey(), + status: duelStatus, + round: t.u32(), + winnerSubject: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +export const duelCombatant = table( + { + name: 'duel_combatant', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + ], + }, + { + combatantId: t.string().primaryKey(), + roomId: t.u64(), + subject: t.string(), + displayName: t.string(), + shipClass, + hull: t.u32(), + maxHull: t.u32(), + shields: t.u32(), + maxShields: t.u32(), + attack: t.u32(), + defense: t.u32(), + speed: t.u32(), + critBps: t.u32(), + dodgeBps: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const duelRoundLog = table( + { + name: 'duel_round_log', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + logId: t.u64().primaryKey().autoInc(), + roomId: t.u64(), + round: t.u32(), + message: t.string(), + createdAt: t.timestamp(), + } +); + +export const duelManeuver = table( + { + name: 'duel_maneuver', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + ], + }, + { + choiceId: t.string().primaryKey(), + roomId: t.u64(), + round: t.u32(), + subject: t.string(), + slot: maneuverSlot, + maneuverId: t.string(), + chosenAt: t.timestamp(), + } +); + +export const queueSummaryRow = t.object('ExampleLobbyQueueSummaryRow', { + pool: t.string(), + queuedTickets: t.u32(), + readyRooms: t.u32(), + activeRooms: t.u32(), +}); + +export const ratingRow = t.object('ExampleLobbyRatingRow', { + pool: t.string(), + subject: t.string(), + rating: t.i32(), + wins: t.u32(), + losses: t.u32(), + draws: t.u32(), + matches: t.u32(), +}); + +export const spacetimedb = schema({ + lobby, + shipCatalog, + maneuverCatalog, + pilot, + duel, + duelCombatant, + duelRoundLog, + duelManeuver, +}); + +export type Schema = InferSchema; +export type WriteCtx = ReducerCtx; +export type ReadCtx = WriteCtx | ViewCtx; +export type CombatantRow = Infer; +export type ManeuverRow = Infer; +export type DuelRow = Infer; +export type ShipClassValue = (typeof ShipClass)[keyof typeof ShipClass]; +export type ManeuverSlotValue = + (typeof ManeuverSlot)[keyof typeof ManeuverSlot]; + +export default spacetimedb; diff --git a/spacetime-lobby-ts/example/spacetimedb/src/views.ts b/spacetime-lobby-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..77aeadd8a01 --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,254 @@ +import { Range, t } from 'spacetimedb/server'; +import * as lobby from '@spacetimedb/lobby/submodule'; + +import { + RATING_POOL, + pilot, + duel, + duelCombatant, + duelRoundLog, + duelManeuver, + queueSummaryRow, + ratingRow, + spacetimedb, + type ReadCtx, +} from './schema'; + +const PLAYER_ROSTER_LIMIT = 1000; + +function takeRows(rows: Iterable, limit: number): T[] { + const result: T[] = []; + for (const row of rows) { + if (result.length >= limit) break; + result.push(row); + } + return result; +} + +function subjectFor(ctx: { sender: { toHexString(): string } }): string { + return ctx.sender.toHexString(); +} + +function activeRoomIdsForSubject(ctx: ReadCtx, subject: string): bigint[] { + const roomIds: bigint[] = []; + const seen = new Set(); + for (const seat of [ + ...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subject), + ]) { + const key = seat.roomId.toString(); + if (seen.has(key)) continue; + seen.add(key); + roomIds.push(seat.roomId); + } + return roomIds; +} + +export const myProfile = spacetimedb.view( + { name: 'my_profile', public: true }, + t.array(pilot.rowType), + ctx => { + const row = ctx.db.pilot.subject.find(subjectFor(ctx)); + return row ? [row] : []; + } +); + +export const players = spacetimedb.view( + { name: 'players', public: true }, + t.array(pilot.rowType), + ctx => takeRows(ctx.db.pilot.iter(), PLAYER_ROSTER_LIMIT) +); + +export const myLobbyRatings = spacetimedb.view( + { name: 'my_lobby_ratings', public: true }, + t.array(ratingRow), + ctx => + [...ctx.db.lobby.lobbySubjectRating.bySubject.filter(subjectFor(ctx))] + .map(row => ({ + pool: row.pool, + subject: row.subject, + rating: row.rating, + wins: row.wins, + losses: row.losses, + draws: row.draws, + matches: row.matches, + })) + .sort((a, b) => a.pool.localeCompare(b.pool)) +); + +export const lobbyRankedLeaderboard = spacetimedb.view( + { name: 'lobby_ranked_leaderboard', public: true }, + t.array(ratingRow), + ctx => + [...ctx.db.lobby.lobbySubjectRating.byPool.filter(RATING_POOL)] + .sort((a, b) => { + if (a.rating !== b.rating) return b.rating - a.rating; + return a.subject.localeCompare(b.subject); + }) + .slice(0, 10) + .map(row => ({ + pool: row.pool, + subject: row.subject, + rating: row.rating, + wins: row.wins, + losses: row.losses, + draws: row.draws, + matches: row.matches, + })) +); + +export const myDuels = spacetimedb.view( + { name: 'my_duels', public: true }, + t.array(duel.rowType), + ctx => { + const roomIds = new Set(); + const rows = []; + for (const seat of [ + ...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subjectFor(ctx)), + ]) { + const key = seat.roomId.toString(); + if (roomIds.has(key)) continue; + roomIds.add(key); + const duelRow = ctx.db.duel.roomId.find(seat.roomId); + if (duelRow) rows.push(duelRow); + } + return rows.sort((a, b) => { + const av = a.updatedAt.microsSinceUnixEpoch; + const bv = b.updatedAt.microsSinceUnixEpoch; + return av < bv ? 1 : av > bv ? -1 : 0; + }); + } +); + +export const myDuelCombatants = spacetimedb.view( + { name: 'my_duel_combatants', public: true }, + t.array(duelCombatant.rowType), + ctx => { + const roomIds = activeRoomIdsForSubject(ctx, subjectFor(ctx)); + return roomIds.flatMap(roomId => [ + ...ctx.db.duelCombatant.byRoom.filter(roomId), + ]); + } +); + +export const myDuelRoundLogs = spacetimedb.view( + { name: 'my_duel_round_logs', public: true }, + t.array(duelRoundLog.rowType), + ctx => { + const roomIds = new Set( + activeRoomIdsForSubject(ctx, subjectFor(ctx)).map(roomId => + roomId.toString() + ) + ); + const logs = [ + ...ctx.db.duelRoundLog.byCreatedAt.filter(new Range()), + ].filter(row => roomIds.has(row.roomId.toString())); + return logs + .sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch; + const bv = b.createdAt.microsSinceUnixEpoch; + return av < bv ? -1 : av > bv ? 1 : 0; + }) + .slice(-80); + } +); + +export const myDuelManeuvers = spacetimedb.view( + { name: 'my_duel_maneuvers', public: true }, + t.array(duelManeuver.rowType), + ctx => { + const roomIds = new Set( + activeRoomIdsForSubject(ctx, subjectFor(ctx)).map(roomId => + roomId.toString() + ) + ); + return [...ctx.db.duelManeuver.byRoom.filter(new Range())] + .filter(row => roomIds.has(row.roomId.toString())) + .sort((a, b) => { + if (a.roomId !== b.roomId) return a.roomId < b.roomId ? -1 : 1; + if (a.round !== b.round) return a.round - b.round; + return a.subject.localeCompare(b.subject); + }); + } +); + +export const myLobbyTickets = spacetimedb.view( + { name: 'my_lobby_tickets', public: true }, + lobby.t.array(lobby.lobbyQueueTicket.rowType), + ctx => [...ctx.db.lobby.lobbyQueueTicket.bySubject.filter(subjectFor(ctx))] +); + +export const myLobbyRooms = spacetimedb.view( + { name: 'my_lobby_rooms', public: true }, + lobby.t.array(lobby.lobbyRoom.rowType), + ctx => { + const seen = new Set(); + const rooms = []; + for (const seat of [ + ...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subjectFor(ctx)), + ]) { + const key = seat.roomId.toString(); + if (seen.has(key)) continue; + seen.add(key); + const room = ctx.db.lobby.lobbyRoom.roomId.find(seat.roomId); + if (room) rooms.push(room); + } + return rooms; + } +); + +export const myLobbyRoomSeats = spacetimedb.view( + { name: 'my_lobby_room_seats', public: true }, + lobby.t.array(lobby.lobbyRoomSeat.rowType), + ctx => { + const roomIds = new Set( + [...ctx.db.lobby.lobbyRoomSeat.bySubject.filter(subjectFor(ctx))].map( + seat => seat.roomId.toString() + ) + ); + return [...ctx.db.lobby.lobbyRoomSeat.iter()].filter(seat => + roomIds.has(seat.roomId.toString()) + ); + } +); + +export const lobbyQueueSummary = spacetimedb.view( + { name: 'lobby_queue_summary', public: true }, + t.array(queueSummaryRow), + ctx => { + const summary = new Map< + string, + { + pool: string; + queuedTickets: number; + readyRooms: number; + activeRooms: number; + } + >(); + const ensure = (pool: string) => { + let row = summary.get(pool); + if (!row) { + row = { pool, queuedTickets: 0, readyRooms: 0, activeRooms: 0 }; + summary.set(pool, row); + } + return row; + }; + for (const ticket of [ + ...ctx.db.lobby.lobbyQueueTicket.byStatus.filter( + lobby.TicketStatus.Queued + ), + ]) { + ensure(ticket.pool).queuedTickets++; + } + for (const room of [ + ...ctx.db.lobby.lobbyRoom.byStatus.filter(lobby.RoomStatus.Ready), + ]) { + ensure(room.pool).readyRooms++; + } + for (const room of [ + ...ctx.db.lobby.lobbyRoom.byStatus.filter(lobby.RoomStatus.Active), + ]) { + ensure(room.pool).activeRooms++; + } + return [...summary.values()].sort((a, b) => a.pool.localeCompare(b.pool)); + } +); diff --git a/spacetime-lobby-ts/example/spacetimedb/tsconfig.json b/spacetime-lobby-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..4b599551afe --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-lobby-ts/example/src/app.ts b/spacetime-lobby-ts/example/src/app.ts new file mode 100644 index 00000000000..69035da222b --- /dev/null +++ b/spacetime-lobby-ts/example/src/app.ts @@ -0,0 +1,1069 @@ +import { DbConnection, type ErrorContext } from './codegen'; + +import { + TOKEN_KEY_PREFIX, + MATCH_FALLBACK_MS, + shipClasses, + maneuverSlots, + shipColors, + maneuverSlotMeta, + maneuverFx, + selectActiveRoom, + selectHighlightedManeuver, + selectLatestDuel, + selectLatestTicket, + selectLobbyScreen, + type ServerConfig, + type TableEvents, + type ShipClass, + type ManeuverSlot, + type Pilot, + type LobbyTicket, + type LobbyRoom, + type LobbySeat, + type Duel, + type Combatant, + type ShipCatalogRow, + type ManeuverCatalogRow, + type DuelManeuver, + type RoundLog, + type QueueSummary, + type RatingRow, +} from './model'; + +let conn: DbConnection | null = null; +let me = ''; +let selectedShip: ShipClass = 'Interceptor'; +let screenOverride: 'setup' | null = null; +let fallbackTimer: ReturnType | null = null; +let autoJoinedRoom: string | null = null; +let playedRoomId: string | null = null; +let arenaSignature = ''; +let animatedRound = -1; +const prevVitals = new Map(); + +// Maneuver cards appear on hover or focus for elements with [data-maneuver-id]. +function setupTooltip(): void { + const tip = $('tooltip'); + let current: Element | null = null; + let hideTimer: ReturnType | null = null; + let showTimer: ReturnType | null = null; + + const place = (el: Element) => { + const r = el.getBoundingClientRect(); + const tr = tip.getBoundingClientRect(); + const left = Math.max( + 8, + Math.min( + r.left + r.width / 2 - tr.width / 2, + window.innerWidth - tr.width - 8 + ) + ); + const above = r.top - tr.height - 8; + tip.style.left = `${Math.round(left)}px`; + tip.style.top = `${Math.round(above < 8 ? r.bottom + 8 : above)}px`; + }; + + const show = (el: Element) => { + const id = el.getAttribute('data-maneuver-id'); + const move = id + ? rows(maneuverCatalogTable()).find(m => m.maneuverId === id) + : undefined; + if (!move) return; + const meta = maneuverSlotMeta[move.slot.tag]; + const fx = maneuverFx(move); + tip.style.setProperty('--slot', meta.color); + tip.innerHTML = ` +
        ${meta.label}${escapeHtml(move.name)}
        + ${fx ? `
        ${fx}
        ` : ''} +
        ${escapeHtml(move.description)}
        + `; + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + if (showTimer) { + clearTimeout(showTimer); + showTimer = null; + } + tip.hidden = false; + place(el); + showTimer = setTimeout(() => tip.classList.add('show'), 10); + }; + + const hide = () => { + if (showTimer) { + clearTimeout(showTimer); + showTimer = null; + } + tip.classList.remove('show'); + hideTimer = setTimeout(() => { + tip.hidden = true; + }, 130); + }; + + const enter = (target: EventTarget | null) => { + const el = + target instanceof Element ? target.closest('[data-maneuver-id]') : null; + if (!el || el === current) return; + current = el; + show(el); + }; + const leave = (related: EventTarget | null) => { + if (!current) return; + if (related instanceof Node && current.contains(related)) return; + current = null; + hide(); + }; + + document.addEventListener('pointerover', ev => enter(ev.target)); + document.addEventListener('pointerout', ev => leave(ev.relatedTarget)); + document.addEventListener('focusin', ev => enter(ev.target)); + document.addEventListener('focusout', () => { + if (current) { + current = null; + hide(); + } + }); + window.addEventListener( + 'scroll', + () => { + if (current) { + current = null; + hide(); + } + }, + true + ); + window.addEventListener('resize', () => { + if (current) { + current = null; + hide(); + } + }); +} + +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el; +} + +function input(id: string): HTMLInputElement { + return $(id) as HTMLInputElement; +} + +function dialog(id: string): HTMLDialogElement { + return $(id) as HTMLDialogElement; +} + +function setText(id: string, value: string): void { + $(id).textContent = value; +} + +function defaultDisplayName(): string { + return me ? `Pilot ${me.slice(0, 6).toUpperCase()}` : 'Pilot'; +} + +function clearFallbackTimer(): void { + if (fallbackTimer == null) return; + clearTimeout(fallbackTimer); + fallbackTimer = null; +} + +function scheduleAiFallback(): void { + clearFallbackTimer(); + fallbackTimer = setTimeout(async () => { + fallbackTimer = null; + const ticket = latestTicket(); + if (!ticket || ticket.status.tag !== 'Queued') return; + try { + showToast('No rival found. Launching vs Arena AI.'); + requireConn().reducers.fallbackToAi({}); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), 'error'); + } + }, MATCH_FALLBACK_MS); +} + +function setScreen(id: 'setupScreen' | 'waitingScreen' | 'duelScreen'): void { + for (const screen of ['setupScreen', 'waitingScreen', 'duelScreen']) { + $(screen).classList.toggle('active', screen === id); + } +} + +function showToast(message: string, kind: 'ok' | 'error' = 'ok'): void { + const el = $('toast'); + el.textContent = message; + el.className = `toast ${kind}`; +} + +function errorMessage(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('duel.invalid_display_name')) + return 'Enter a name to start.'; + if (message.includes('duel.invalid_ship_class')) return 'Choose a ship.'; + if (message.includes('duel.room_not_found')) + return 'That duel is unavailable.'; + if (message.includes('duel.not_in_room')) + return 'Join the duel before advancing.'; + if (message.includes('duel.not_active')) return 'This duel is not active.'; + return message; +} + +// Suppress expected leave and teardown errors after a duel or room has ended. +function isBenignDuelError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /room_closed|not_in_room|room_not_found|duel_abandoned|duel_not_ready|not_active/i.test( + message + ); +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function pct(current: number, max: number): number { + if (max <= 0) return 0; + return Math.max(0, Math.min(100, Math.round((current / max) * 100))); +} + +function requireConn(): DbConnection { + if (!conn) throw new Error('stdb.disconnected'); + return conn; +} + +function rows(source: TableEvents): T[] { + return [...source.iter()]; +} + +function profileTable(): TableEvents { + return requireConn().db.myProfile; +} +function playersTable(): TableEvents { + return requireConn().db.players; +} + +function displayNameFor(subject: string): string { + const player = rows(playersTable()).find(row => row.subject === subject); + return player?.displayName ?? `Pilot ${subject.slice(0, 6).toUpperCase()}`; +} +function ticketsTable(): TableEvents { + return requireConn().db.myLobbyTickets; +} +function roomsTable(): TableEvents { + return requireConn().db.myLobbyRooms; +} +function seatsTable(): TableEvents { + return requireConn().db.myLobbyRoomSeats; +} +function summaryTable(): TableEvents { + return requireConn().db.lobbyQueueSummary; +} +function ratingsTable(): TableEvents { + return requireConn().db.myLobbyRatings; +} +function leaderboardTable(): TableEvents { + return requireConn().db.lobbyRankedLeaderboard; +} +function shipCatalogTable(): TableEvents { + return requireConn().db.shipCatalog; +} +function maneuverCatalogTable(): TableEvents { + return requireConn().db.maneuverCatalog; +} +function duelsTable(): TableEvents { + return requireConn().db.myDuels; +} +function combatantsTable(): TableEvents { + return requireConn().db.myDuelCombatants; +} +function logsTable(): TableEvents { + return requireConn().db.myDuelRoundLogs; +} +function maneuversTable(): TableEvents { + return requireConn().db.myDuelManeuvers; +} + +function tokenKey(config: ServerConfig): string { + return `${TOKEN_KEY_PREFIX}:${config.stdbUri}:${config.database}`; +} + +function loadToken(config: ServerConfig): string | undefined { + try { + return sessionStorage.getItem(tokenKey(config)) ?? undefined; + } catch { + return undefined; + } +} + +function saveToken(config: ServerConfig, token: string): void { + try { + sessionStorage.setItem(tokenKey(config), token); + } catch { + /* Storage can be unavailable. */ + } +} + +function clearToken(config: ServerConfig): void { + try { + sessionStorage.removeItem(tokenKey(config)); + } catch { + /* Storage can be unavailable. */ + } +} + +function isStaleTokenError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return ( + message.includes('Failed to verify token') || + message.includes('Unauthorized') + ); +} + +async function loadConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + return (await r.json()) as ServerConfig; +} + +function connectOnce( + config: ServerConfig, + token?: string +): Promise { + return new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(config.stdbUri) + .withDatabaseName(config.database); + if (token) builder = builder.withToken(token); + builder + .onConnect((c, identity, token) => { + conn = c; + me = identity.toHexString(); + if (token) saveToken(config, token); + resolve(c); + }) + .onDisconnect((_ctx, err) => { + showToast(err?.message ?? 'Disconnected.', 'error'); + }) + .onConnectError((_ctx, err) => reject(err)) + .build(); + }); +} + +async function connect(config: ServerConfig): Promise { + const token = loadToken(config); + try { + return await connectOnce(config, token); + } catch (err) { + if (!token || !isStaleTokenError(err)) throw err; + clearToken(config); + showToast('Session expired. Reconnecting.', 'error'); + return connectOnce(config); + } +} + +function currentProfile(): Pilot | undefined { + return rows(profileTable())[0]; +} + +function latestTicket(): LobbyTicket | undefined { + return selectLatestTicket(rows(ticketsTable())); +} + +function latestDuel(): Duel | undefined { + return selectLatestDuel(rows(duelsTable()), latestTicket()); +} + +function activeRoom(): LobbyRoom | undefined { + return selectActiveRoom(rows(roomsTable()), latestDuel(), latestTicket()); +} + +function selectedShipCatalog(): ShipCatalogRow | undefined { + return rows(shipCatalogTable()).find( + row => row.shipClass.tag === selectedShip + ); +} + +function statRows(ship: ShipCatalogRow): Array<[string, number, string]> { + return [ + ['Hull', pct(ship.hull, 160), String(ship.hull)], + ['Shields', pct(ship.shields, 80), String(ship.shields)], + ['Attack', pct(ship.attack, 38), String(ship.attack)], + ['Speed', pct(ship.speed, 8), String(ship.speed)], + ['Crit', pct(ship.critBps, 1400), `${Math.round(ship.critBps / 100)}%`], + ['Dodge', pct(ship.dodgeBps, 2200), `${Math.round(ship.dodgeBps / 100)}%`], + ]; +} + +function renderShipCarousel(): void { + const details = selectedShipCatalog(); + const card = document.querySelector('.launch-card'); + if (card instanceof HTMLElement) + card.style.setProperty('--ship', shipColors[selectedShip]); + setText('selectedShipRole', details?.role ?? 'Loading'); + setText('selectedShipName', selectedShip); + setText( + 'selectedShipDescription', + details?.description ?? 'Loading ship catalog from SpacetimeDB.' + ); + $('selectedShipVisual').className = + `ship-preview ${selectedShip.toLowerCase()}`; + $('shipRoster').innerHTML = shipClasses + .map( + cls => ` + + ` + ) + .join(''); + $('selectedShipStats').innerHTML = details + ? statRows(details) + .map( + ([label, pctValue, value]) => ` +
        +
        ${label}${value}
        +
        +
        + ` + ) + .join('') + : '
        Loading ship catalog.
        '; + + const moves = rows(maneuverCatalogTable()) + .filter(move => move.shipClass.tag === selectedShip) + .sort( + (a, b) => + maneuverSlots.indexOf(a.slot.tag) - maneuverSlots.indexOf(b.slot.tag) + ); + $('shipAbilities').innerHTML = + moves.length === 0 + ? '' + : ` + Maneuvers +
        + ${moves + .map(move => { + const meta = maneuverSlotMeta[move.slot.tag]; + return ` +
        +
        ${meta.icon}${meta.label}
        + ${escapeHtml(move.name)} +
        + `; + }) + .join('')} +
        + `; +} + +async function chooseShip(ship: ShipClass): Promise { + selectedShip = ship; + renderShipCarousel(); + requireConn().reducers.selectShip({ shipClass: { tag: ship } }); +} + +function renderProfile(): void { + const profile = currentProfile(); + selectedShip = profile?.shipClass.tag ?? selectedShip; + const nameInput = input('displayName'); + const nextName = profile?.displayName ?? defaultDisplayName(); + if (document.activeElement !== nameInput || !nameInput.value.trim()) { + nameInput.value = nextName; + } + renderShipCarousel(); +} + +function renderRanked(): void { + const rating = rows(ratingsTable()).find( + row => row.pool === 'spaceship_duel' + ); + setText('myRating', String(rating?.rating ?? 1000)); + setText( + 'myRecord', + rating + ? `${rating.wins}W ${rating.losses}L${rating.draws ? ` ${rating.draws}D` : ''}` + : '0W 0L' + ); + const leaderboard = rows(leaderboardTable()) + .filter( + row => row.pool === 'spaceship_duel' && !row.subject.startsWith('ai:') + ) + .sort( + (a, b) => + b.rating - a.rating || + b.wins - a.wins || + a.subject.localeCompare(b.subject) + ) + .slice(0, 10); + $('leaderboard').innerHTML = + leaderboard.length === 0 + ? '

        No ranked pilots yet. Win a duel to get on the board.

        ' + : leaderboard + .map( + (row, index) => ` +
        + ${index + 1} + ${escapeHtml(displayNameFor(row.subject))}${row.subject === me ? ' (you)' : ''} + ${row.rating} +
        + ` + ) + .join(''); +} + +function maybeAutoJoin(): void { + if (screenOverride === 'setup') return; + const room = activeRoom(); + if (!room) return; + const mySeat = rows(seatsTable()).find( + seat => seat.roomId === room.roomId && seat.subject === me + ); + if (!mySeat || mySeat.status.tag === 'Joined') return; + const key = room.roomId.toString(); + if (autoJoinedRoom === key) return; + autoJoinedRoom = key; + try { + requireConn().reducers.joinDuelRoom({ roomId: room.roomId }); + } catch (err) { + autoJoinedRoom = null; + if (!isBenignDuelError(err)) showToast(errorMessage(err), 'error'); + } +} + +function renderLobby(): void { + const room = activeRoom(); + $('forfeitDuel').toggleAttribute('disabled', !room); + maybeAutoJoin(); +} + +function closeForfeitDialog(): void { + const modal = dialog('forfeitDialog'); + if (modal.open) modal.close(); +} + +async function forfeitActiveDuel(): Promise { + const room = activeRoom(); + autoJoinedRoom = null; + screenOverride = 'setup'; + closeForfeitDialog(); + render(); + if (!room) return; + try { + requireConn().reducers.leaveDuel({ roomId: room.roomId }); + } catch (err) { + // Surface only actionable errors while leaving a room that may already be closed. + if (!isBenignDuelError(err)) showToast(errorMessage(err), 'error'); + } +} + +function desiredScreen(): 'setupScreen' | 'waitingScreen' | 'duelScreen' { + const duel = latestDuel(); + const ticket = latestTicket(); + return selectLobbyScreen({ + screenOverride, + ticket, + duel, + room: activeRoom(), + playedRoomId, + }); +} + +function combatantCardHtml(row: Combatant): string { + return ` +
        +
        +
        +
        + ${row.subject === me ? 'Your ship' : 'Opponent'} +

        ${escapeHtml(row.displayName)}

        +

        ${row.shipClass.tag}

        +
        +
        +
        +
        Hull
        +
        +
        Shields
        +
        +
        +
        + `; +} + +function highlightedManeuverFor( + duel: Duel | undefined, + row: Combatant +): DuelManeuver | undefined { + return selectHighlightedManeuver(duel, row, rows(maneuversTable())); +} + +function abilitiesHtml(duel: Duel | undefined, row: Combatant): string { + const abilities = rows(maneuverCatalogTable()) + .filter(move => move.shipClass.tag === row.shipClass.tag) + .sort( + (a, b) => + maneuverSlots.indexOf(a.slot.tag) - maneuverSlots.indexOf(b.slot.tag) + ); + if (abilities.length === 0) return ''; + const highlighted = highlightedManeuverFor(duel, row); + return ` + Abilities +
        + ${abilities + .map(move => { + const meta = maneuverSlotMeta[move.slot.tag]; + return ` +
        +
        ${meta.icon}${meta.label}
        + ${escapeHtml(move.name)} +
        + `; + }) + .join('')} +
        + `; +} + +function flashCard(card: HTMLElement, kind: string): void { + const cls = `flash-${kind}`; + card.classList.remove('flash-hit', 'flash-crit', 'flash-shield'); + void card.offsetWidth; + card.classList.add(cls); + setTimeout(() => card.classList.remove(cls), 420); +} + +function shakeCard(card: HTMLElement, hard: boolean): void { + const cls = hard ? 'shake-hard' : 'shake'; + card.classList.remove('shake', 'shake-hard'); + void card.offsetWidth; + card.classList.add(cls); + setTimeout(() => card.classList.remove(cls), 480); +} + +function lungeCard(card: HTMLElement): void { + card.classList.add('attacking'); + setTimeout(() => card.classList.remove('attacking'), 200); +} + +function popNumber(card: HTMLElement, text: string, kind: string): void { + const layer = card.querySelector('.pops'); + if (!layer) return; + const pop = document.createElement('div'); + pop.className = `pop ${kind}`; + pop.textContent = text; + pop.style.setProperty( + '--pop-x', + `${Math.round((Math.random() - 0.5) * 44)}px` + ); + layer.appendChild(pop); + setTimeout(() => pop.remove(), 900); +} + +function animateRound( + host: HTMLElement, + duel: Duel, + combatants: Combatant[], + round: number +): void { + const cards = new Map(); + for (const el of host.querySelectorAll('.combatant')) { + cards.set((el as HTMLElement).dataset.subject ?? '', el as HTMLElement); + } + const roundLogs = rows(logsTable()).filter( + row => row.roomId === duel.roomId && row.round === round + ); + const critRound = roundLogs.some(row => /critical/i.test(row.message)); + const hadEvade = roundLogs.some(row => /evade|miss/i.test(row.message)); + + const damage = combatants.map(c => { + const prev = prevVitals.get(c.subject); + const prevHull = prev?.hull ?? c.hull; + const prevShields = prev?.shields ?? c.shields; + const dmg = Math.max(0, prevHull + prevShields - (c.hull + c.shields)); + const shieldOnly = prevHull === c.hull && prevShields > c.shields; + return { subject: c.subject, dmg, shieldOnly }; + }); + const maxDmg = Math.max(0, ...damage.map(d => d.dmg)); + + for (const d of damage) { + const card = cards.get(d.subject); + if (!card) continue; + if (d.dmg > 0) { + const isCrit = critRound && !d.shieldOnly && d.dmg === maxDmg; + const kind = d.shieldOnly ? 'shield' : isCrit ? 'crit' : 'hit'; + popNumber(card, `-${d.dmg}`, kind); + flashCard(card, kind); + shakeCard(card, isCrit); + for (const other of cards) { + if (other[0] !== d.subject) lungeCard(other[1]); + } + } else if (hadEvade) { + popNumber(card, 'EVADE', 'evade'); + } + } +} + +function renderManeuvers( + duel: Duel | undefined, + combatants: Combatant[], + complete: boolean +): void { + const host = $('maneuverActions'); + if (!duel || complete || duel.status.tag !== 'Active') { + host.innerHTML = ''; + host.hidden = true; + return; + } + const mine = combatants.find(row => row.subject === me); + if (!mine) { + host.innerHTML = ''; + host.hidden = true; + return; + } + const round = duel.round + 1; + const chosen = rows(maneuversTable()).find( + row => + row.roomId === duel.roomId && row.round === round && row.subject === me + ); + const catalog = rows(maneuverCatalogTable()) + .filter(row => row.shipClass.tag === mine.shipClass.tag) + .sort( + (a, b) => + maneuverSlots.indexOf(a.slot.tag) - maneuverSlots.indexOf(b.slot.tag) + ); + host.hidden = false; + host.innerHTML = ` +
        + ${chosen ? '✓ Locked in | waiting for opponent' : `Choose your maneuver | Round ${round}`} +
        +
        + ${catalog + .map(row => { + const meta = maneuverSlotMeta[row.slot.tag]; + const isChosen = chosen?.slot.tag === row.slot.tag; + return ` + + `; + }) + .join('')} +
        + `; +} + +function renderArena(): void { + const duel = latestDuel(); + const combatants = rows(combatantsTable()).filter( + row => !duel || row.roomId === duel.roomId + ); + + const host = $('combatants'); + if (combatants.length === 0) { + host.innerHTML = + '
        Queue from two tabs to create the duel.
        '; + arenaSignature = ''; + animatedRound = -1; + prevVitals.clear(); + $('duelStatus').hidden = true; + renderManeuvers(undefined, [], false); + $('forfeitDuel').hidden = false; + $('newDuel').hidden = true; + $('home').hidden = true; + return; + } + + // Rebuild cards only when the combatant set changes, so HP bars can animate in place. + const round = duel?.round ?? 0; + const signature = `${duel?.roomId ?? ''}:${combatants.map(c => c.subject).join('|')}`; + if (signature !== arenaSignature) { + host.innerHTML = combatants.map(combatantCardHtml).join(''); + arenaSignature = signature; + animatedRound = round; + prevVitals.clear(); + } + + const cards = new Map(); + for (const el of host.querySelectorAll('.combatant')) { + cards.set((el as HTMLElement).dataset.subject ?? '', el as HTMLElement); + } + const complete = + !!duel && + (duel.status.tag === 'Complete' || duel.status.tag === 'Abandoned'); + + // Animate a freshly resolved round before applying the new bar values. + if (duel && round > animatedRound) { + animateRound(host, duel, combatants, round); + animatedRound = round; + } + + for (const row of combatants) { + const card = cards.get(row.subject); + if (!card) continue; + (card.querySelector('.hull-text') as HTMLElement).textContent = + `${row.hull}/${row.maxHull}`; + (card.querySelector('.shield-text') as HTMLElement).textContent = + `${row.shields}/${row.maxShields}`; + (card.querySelector('.hull-fill') as HTMLElement).style.width = + `${pct(row.hull, row.maxHull)}%`; + (card.querySelector('.shield-fill') as HTMLElement).style.width = + `${pct(row.shields, row.maxShields)}%`; + card.classList.toggle( + 'low', + row.hull > 0 && pct(row.hull, row.maxHull) <= 30 + ); + card.classList.toggle('dead', row.hull <= 0); + card.classList.toggle( + 'winner', + complete && !!duel!.winnerSubject && row.subject === duel!.winnerSubject + ); + card.classList.toggle( + 'loser', + complete && !!duel!.winnerSubject && row.subject !== duel!.winnerSubject + ); + const abilities = card.querySelector('.ship-abilities') as HTMLElement; + abilities.innerHTML = abilitiesHtml(duel, row); + } + + prevVitals.clear(); + for (const c of combatants) + prevVitals.set(c.subject, { hull: c.hull, shields: c.shields }); + + const statusEl = $('duelStatus'); + if (complete) { + const abandoned = duel!.status.tag === 'Abandoned'; + const youWon = duel!.winnerSubject === me; + statusEl.textContent = abandoned + ? 'Duel Abandoned' + : youWon + ? 'Victory' + : 'Defeat'; + statusEl.className = `${abandoned ? 'abandoned' : youWon ? 'win' : 'lose'} show`; + statusEl.hidden = false; + } else if (duel) { + statusEl.textContent = round >= 1 ? `Round ${round}` : 'Ready'; + statusEl.className = ''; + statusEl.hidden = false; + } else { + statusEl.hidden = true; + } + + renderManeuvers(duel, combatants, complete); + $('forfeitDuel').hidden = complete; + $('newDuel').hidden = !complete; + $('home').hidden = !complete; +} + +function render(): void { + if (!conn) return; + renderProfile(); + renderRanked(); + renderLobby(); + renderArena(); + const ticket = latestTicket(); + const duel = latestDuel(); + if ( + duel && + (duel.status.tag === 'Active' || duel.status.tag === 'Configuring') + ) { + playedRoomId = duel.roomId.toString(); + } + if (ticket?.status.tag !== 'Queued' || duel || activeRoom()) + clearFallbackTimer(); + setScreen(desiredScreen()); +} + +function wireTables(): void { + const rerender = () => render(); + const sources = [ + profileTable(), + playersTable(), + ticketsTable(), + roomsTable(), + seatsTable(), + summaryTable(), + ratingsTable(), + leaderboardTable(), + shipCatalogTable(), + maneuverCatalogTable(), + duelsTable(), + combatantsTable(), + logsTable(), + maneuversTable(), + ]; + for (const source of sources) { + source.onInsert(rerender); + source.onUpdate(rerender); + source.onDelete(rerender); + } +} + +function wireActions(): void { + const moveShip = async (direction: -1 | 1) => { + const index = shipClasses.indexOf(selectedShip); + const next = + shipClasses[ + (index + direction + shipClasses.length) % shipClasses.length + ]; + try { + await chooseShip(next); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }; + $('shipRoster').addEventListener('click', ev => { + const btn = + ev.target instanceof Element ? ev.target.closest('[data-ship]') : null; + if (!(btn instanceof HTMLElement) || !btn.dataset.ship) return; + const ship = btn.dataset.ship as ShipClass; + if (ship !== selectedShip) + void chooseShip(ship).catch(err => showToast(errorMessage(err), 'error')); + }); + document.addEventListener('keydown', ev => { + if (!$('setupScreen').classList.contains('active')) return; + if (document.activeElement instanceof HTMLInputElement) return; + if (ev.key === 'ArrowLeft') void moveShip(-1); + else if (ev.key === 'ArrowRight') void moveShip(1); + }); + const saveDisplayName = async () => { + const value = input('displayName').value.trim(); + if (!value || value === currentProfile()?.displayName) return; + try { + requireConn().reducers.setDisplayName({ displayName: value }); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }; + input('displayName').addEventListener('change', () => void saveDisplayName()); + input('displayName').addEventListener('keydown', ev => { + if (ev.key === 'Enter') { + ev.preventDefault(); + input('displayName').blur(); + } + }); + $('findDuel').addEventListener('click', async () => { + try { + screenOverride = null; + autoJoinedRoom = null; + requireConn().reducers.setDisplayName({ + displayName: input('displayName').value, + }); + requireConn().reducers.selectShip({ shipClass: { tag: selectedShip } }); + requireConn().reducers.findDuel({}); + showToast('Looking for match.'); + scheduleAiFallback(); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }); + $('maneuverActions').addEventListener('click', async ev => { + const btn = + ev.target instanceof Element ? ev.target.closest('[data-slot]') : null; + if (!(btn instanceof HTMLElement) || !btn.dataset.slot) return; + const duel = latestDuel(); + if (!duel) return; + try { + requireConn().reducers.chooseManeuver({ + roomId: duel.roomId, + slot: { tag: btn.dataset.slot as ManeuverSlot }, + }); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }); + $('forfeitDuel').addEventListener('click', () => { + if (!activeRoom()) return; + dialog('forfeitDialog').showModal(); + }); + $('cancelForfeit').addEventListener('click', closeForfeitDialog); + $('confirmForfeit').addEventListener('click', () => void forfeitActiveDuel()); + dialog('forfeitDialog').addEventListener('click', ev => { + if (ev.target === dialog('forfeitDialog')) closeForfeitDialog(); + }); + $('newDuel').addEventListener('click', async () => { + const duel = latestDuel(); + try { + screenOverride = null; + autoJoinedRoom = null; + requireConn().reducers.queueAgain({ roomId: duel?.roomId }); + showToast('Looking for match.'); + scheduleAiFallback(); + } catch (err) { + showToast(errorMessage(err), 'error'); + } + }); + $('home').addEventListener('click', () => { + screenOverride = 'setup'; + render(); + }); + $('cancelSearch').addEventListener('click', async () => { + clearFallbackTimer(); + screenOverride = 'setup'; + render(); + for (const ticket of rows(ticketsTable()).filter( + t => t.status.tag === 'Queued' + )) { + try { + requireConn().reducers['lobby.cancelTicket']({ + ticketId: ticket.ticketId, + }); + } catch (err) { + if (!isBenignDuelError(err)) showToast(errorMessage(err), 'error'); + } + } + }); +} + +async function run(): Promise { + const config = await loadConfig(); + const c = await connect(config); + c.subscriptionBuilder() + .onApplied(() => render()) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe([ + 'SELECT * FROM my_profile', + 'SELECT * FROM players', + 'SELECT * FROM my_lobby_tickets', + 'SELECT * FROM my_lobby_rooms', + 'SELECT * FROM my_lobby_room_seats', + 'SELECT * FROM lobby_queue_summary', + 'SELECT * FROM my_lobby_ratings', + 'SELECT * FROM lobby_ranked_leaderboard', + 'SELECT * FROM ship_catalog', + 'SELECT * FROM maneuver_catalog', + 'SELECT * FROM my_duels', + 'SELECT * FROM my_duel_combatants', + 'SELECT * FROM my_duel_round_logs', + 'SELECT * FROM my_duel_maneuvers', + ]); + wireTables(); + wireActions(); + setupTooltip(); + showToast('Connected.'); + render(); +} + +run().catch(err => { + showToast(errorMessage(err), 'error'); +}); diff --git a/spacetime-lobby-ts/example/src/model.ts b/spacetime-lobby-ts/example/src/model.ts new file mode 100644 index 00000000000..bab7c966d4d --- /dev/null +++ b/spacetime-lobby-ts/example/src/model.ts @@ -0,0 +1,300 @@ +export interface ServerConfig { + stdbUri: string; + database: string; +} + +export type TableEvents = { + iter(): Iterable; + onInsert(cb: (ctx: EventContext, row: T) => void): void; + onUpdate(cb: (ctx: EventContext, old: T, row: T) => void): void; + onDelete(cb: (ctx: EventContext, row: T) => void): void; +}; + +export type EnumTag = { tag: T }; +export type ShipClass = 'Bulwark' | 'Interceptor' | 'Phantom' | 'Artillery'; +export type ManeuverSlot = 'Primary' | 'Defensive' | 'Risky'; + +export type Pilot = { + subject: string; + displayName: string; + shipClass: EnumTag; +}; + +export type LobbyTicket = { + ticketId: string; + pool: string; + status: EnumTag; + roomId?: bigint; + createdAt: { microsSinceUnixEpoch: bigint }; +}; + +export type LobbyRoom = { + roomId: bigint; + pool: string; + status: EnumTag; + capacity: number; + createdAt: { microsSinceUnixEpoch: bigint }; +}; + +export type LobbySeat = { + seatId: bigint; + roomId: bigint; + subject: string; + seatIndex: number; + status: EnumTag; +}; + +export type Duel = { + roomId: bigint; + status: EnumTag; + round: number; + winnerSubject?: string; + updatedAt: { microsSinceUnixEpoch: bigint }; +}; + +export type Combatant = { + roomId: bigint; + subject: string; + displayName: string; + shipClass: EnumTag; + hull: number; + maxHull: number; + shields: number; + maxShields: number; + attack: number; + defense: number; + speed: number; + critBps: number; + dodgeBps: number; +}; + +export type ShipCatalogRow = { + shipId: string; + shipClass: EnumTag; + role: string; + description: string; + hull: number; + shields: number; + attack: number; + defense: number; + speed: number; + critBps: number; + dodgeBps: number; +}; + +export type ManeuverCatalogRow = { + maneuverId: string; + shipClass: EnumTag; + slot: EnumTag; + name: string; + description: string; + damageBps: number; + defenseBps: number; + shieldRestore: number; + selfShieldCost: number; + critBonusBps: number; + dodgeBonusBps: number; +}; + +export type DuelManeuver = { + choiceId: string; + roomId: bigint; + round: number; + subject: string; + slot: EnumTag; + maneuverId: string; +}; + +export type RoundLog = { + logId: bigint; + roomId: bigint; + round: number; + message: string; + createdAt: { microsSinceUnixEpoch: bigint }; +}; + +export type QueueSummary = { + pool: string; + queuedTickets: number; + readyRooms: number; + activeRooms: number; +}; + +export type RatingRow = { + pool: string; + subject: string; + rating: number; + wins: number; + losses: number; + draws: number; + matches: number; +}; + +export type LobbyScreen = 'setupScreen' | 'waitingScreen' | 'duelScreen'; + +export function selectLatestTicket( + tickets: readonly LobbyTicket[] +): LobbyTicket | undefined { + return [...tickets].sort((a, b) => { + const aCreated = a.createdAt.microsSinceUnixEpoch; + const bCreated = b.createdAt.microsSinceUnixEpoch; + return aCreated < bCreated ? 1 : aCreated > bCreated ? -1 : 0; + })[0]; +} + +export function selectLatestDuel( + duels: readonly Duel[], + ticket: LobbyTicket | undefined +): Duel | undefined { + const newestFirst = [...duels].sort((a, b) => { + const aUpdated = a.updatedAt.microsSinceUnixEpoch; + const bUpdated = b.updatedAt.microsSinceUnixEpoch; + return aUpdated < bUpdated ? 1 : aUpdated > bUpdated ? -1 : 0; + }); + if (ticket?.roomId !== undefined) { + const ticketDuel = newestFirst.find(duel => duel.roomId === ticket.roomId); + if (ticketDuel) return ticketDuel; + } + return newestFirst.find( + duel => duel.status.tag !== 'Complete' && duel.status.tag !== 'Abandoned' + ); +} + +export function selectActiveRoom( + rooms: readonly LobbyRoom[], + duel: Duel | undefined, + ticket: LobbyTicket | undefined +): LobbyRoom | undefined { + if ( + duel && + duel.status.tag !== 'Complete' && + duel.status.tag !== 'Abandoned' + ) { + return rooms.find(room => room.roomId === duel.roomId); + } + if (ticket?.roomId === undefined) return undefined; + return rooms.find(room => room.roomId === ticket.roomId); +} + +export function selectLobbyScreen(input: { + screenOverride: 'setup' | null; + ticket: LobbyTicket | undefined; + duel: Duel | undefined; + room: LobbyRoom | undefined; + playedRoomId: string | null; +}): LobbyScreen { + const { screenOverride, ticket, duel, room, playedRoomId } = input; + if (screenOverride === 'setup') return 'setupScreen'; + if (ticket?.status.tag === 'Queued') return 'waitingScreen'; + if ( + duel && + (duel.status.tag === 'Complete' || duel.status.tag === 'Abandoned') + ) { + return playedRoomId === duel.roomId.toString() + ? 'duelScreen' + : 'setupScreen'; + } + if (duel || room) return 'duelScreen'; + return 'setupScreen'; +} + +export function selectHighlightedManeuver( + duel: Duel | undefined, + combatant: Combatant, + choices: readonly DuelManeuver[] +): DuelManeuver | undefined { + if (!duel || duel.status.tag === 'Configuring') return undefined; + const newestFirst = choices + .filter( + choice => + choice.roomId === combatant.roomId && + choice.subject === combatant.subject + ) + .sort((a, b) => b.round - a.round); + if (duel.status.tag === 'Active') { + return ( + newestFirst.find(choice => choice.round === duel.round + 1) ?? + newestFirst.find(choice => choice.round === duel.round) + ); + } + return newestFirst[0]; +} + +export const TOKEN_KEY_PREFIX = 'lobby-duel:stdb-token'; +export const MATCH_FALLBACK_MS = 4500; +export const shipClasses: ShipClass[] = [ + 'Bulwark', + 'Interceptor', + 'Phantom', + 'Artillery', +]; +export const maneuverSlots: ManeuverSlot[] = ['Primary', 'Defensive', 'Risky']; + +export const shipColors: Record = { + Bulwark: 'var(--green)', + Interceptor: 'var(--yellow)', + Phantom: 'var(--violet)', + Artillery: 'var(--red)', +}; + +// Each maneuver slot reads as a distinct tactical stance: strike / guard / gamble. +export const maneuverSlotMeta: Record< + ManeuverSlot, + { color: string; label: string; icon: string } +> = { + Primary: { + color: 'var(--red)', + label: 'Primary', + icon: '', + }, + Defensive: { + color: 'var(--cyan)', + label: 'Defensive', + icon: '', + }, + Risky: { + color: 'var(--violet)', + label: 'Special', + icon: '', + }, +}; + +export function maneuverEffects( + move: ManeuverCatalogRow +): Array<{ text: string; cost: boolean }> { + const signPct = (bps: number) => + `${bps > 0 ? '+' : ''}${Math.round(bps / 100)}%`; + const fx: Array<{ text: string; cost: boolean }> = []; + if (move.damageBps) + fx.push({ + text: `${signPct(move.damageBps)} dmg`, + cost: move.damageBps < 0, + }); + if (move.defenseBps) + fx.push({ + text: `${signPct(move.defenseBps)} def`, + cost: move.defenseBps < 0, + }); + if (move.critBonusBps) + fx.push({ + text: `${signPct(move.critBonusBps)} crit`, + cost: move.critBonusBps < 0, + }); + if (move.dodgeBonusBps) + fx.push({ + text: `${signPct(move.dodgeBonusBps)} dodge`, + cost: move.dodgeBonusBps < 0, + }); + if (move.shieldRestore) + fx.push({ text: `+${move.shieldRestore} shield`, cost: false }); + if (move.selfShieldCost) + fx.push({ text: `-${move.selfShieldCost} shield`, cost: true }); + return fx; +} + +export function maneuverFx(move: ManeuverCatalogRow): string { + return maneuverEffects(move) + .map(c => `${c.text}`) + .join(''); +} +import type { EventContext } from './codegen'; diff --git a/spacetime-lobby-ts/example/tsconfig.json b/spacetime-lobby-ts/example/tsconfig.json new file mode 100644 index 00000000000..1e69312d24d --- /dev/null +++ b/spacetime-lobby-ts/example/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-lobby-ts/package.json b/spacetime-lobby-ts/package.json new file mode 100644 index 00000000000..5b0bc8f2231 --- /dev/null +++ b/spacetime-lobby-ts/package.json @@ -0,0 +1,65 @@ +{ + "name": "@spacetimedb/lobby", + "description": "Queueing, room assignment, ranking, and match result primitives for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-lobby-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-lobby-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "lobby", + "matchmaking", + "typescript" + ], + "scripts": { + "build": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts", + "generate-ts": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-lobby", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-lobby" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-lobby-ts/scripts/test.ts b/spacetime-lobby-ts/scripts/test.ts new file mode 100644 index 00000000000..f8f9f0681ca --- /dev/null +++ b/spacetime-lobby-ts/scripts/test.ts @@ -0,0 +1,36 @@ +import * as assert from 'node:assert/strict'; +import { + expectedScore, + rankedBand, + rankedSelection, + updatedRating, +} from '../src/matchmaking.ts'; +import { lobbyCompositeKey } from '../src/keys.ts'; + +assert.equal(expectedScore(1000, 1000), 0.5); +assert.ok(expectedScore(1200, 1000) > 0.75); +assert.equal(updatedRating(1000, 0.5, 1), 1016); +assert.equal(updatedRating(1000, 0.5, 0), 984); +assert.equal(updatedRating(100, 1, 0), 100); +assert.equal(updatedRating(5000, 0, 1), 5000); + +const at = (microsSinceUnixEpoch: bigint) => ({ microsSinceUnixEpoch }); +assert.equal(rankedBand({ createdAt: at(100_000_000n) }, 100_000_000n), 100); +assert.equal(rankedBand({ createdAt: at(0n) }, 20_000_000n), 200); +assert.equal(rankedBand({ createdAt: at(0n) }, 1_000_000_000n), 800); + +const queue = [ + { ticketId: 'a', rating: 1000, ratingPool: 'ranked', createdAt: at(0n) }, + { ticketId: 'b', rating: 1080, ratingPool: 'ranked', createdAt: at(1n) }, + { ticketId: 'c', rating: 1400, ratingPool: 'ranked', createdAt: at(2n) }, +]; +assert.deepEqual( + rankedSelection(queue, 2, 5_000_000n)?.map(ticket => ticket.ticketId), + ['a', 'b'] +); +assert.equal(rankedSelection(queue, 3, 5_000_000n), undefined); + +assert.notEqual(lobbyCompositeKey('a:b', 'c'), lobbyCompositeKey('a', 'b:c')); +assert.equal(lobbyCompositeKey('ranked', 'player-1'), '6:ranked8:player-1'); + +console.log('lobby tests passed'); diff --git a/spacetime-lobby-ts/src/index.ts b/spacetime-lobby-ts/src/index.ts new file mode 100644 index 00000000000..919cb7b7c2f --- /dev/null +++ b/spacetime-lobby-ts/src/index.ts @@ -0,0 +1,27 @@ +// Top-level entry. Only re-exports registered STDB exports plus standalone init. + +export { default, init } from './submodule/schema'; +export { + add_admin_identity, + cancel_ticket, + close_room, + expire_tickets, + get_lobby_status, + join_ranked_queue, + join_queue, + join_room, + leave_room, + lobbyAdminMatchResults, + lobbyAdminRoomSeats, + lobbyAdminRooms, + lobbyAdminTickets, + lobbyQueueSummary, + lobbyRankedLeaderboard, + myLobbyRatings, + myLobbyRoomSeats, + myLobbyRooms, + myLobbyTickets, + remove_admin_identity, + set_rating, + update_config, +} from './submodule/operations'; diff --git a/spacetime-lobby-ts/src/keys.ts b/spacetime-lobby-ts/src/keys.ts new file mode 100644 index 00000000000..53c4b347eea --- /dev/null +++ b/spacetime-lobby-ts/src/keys.ts @@ -0,0 +1,3 @@ +export function lobbyCompositeKey(...parts: string[]): string { + return parts.map(part => `${part.length}:${part}`).join(''); +} diff --git a/spacetime-lobby-ts/src/matchmaking.ts b/spacetime-lobby-ts/src/matchmaking.ts new file mode 100644 index 00000000000..57d32c858e9 --- /dev/null +++ b/spacetime-lobby-ts/src/matchmaking.ts @@ -0,0 +1,67 @@ +export const DEFAULT_RATING = 1000; +export const MIN_RATING = 100; +export const MAX_RATING = 5000; + +const ELO_K = 32; +const RANKED_INITIAL_BAND = 100; +const RANKED_BAND_STEP = 50; +const RANKED_BAND_STEP_SECONDS = 10n; +const RANKED_MAX_BAND = 800; + +type RankedTicket = { + rating?: number | undefined; + ratingPool?: string | undefined; + createdAt: { microsSinceUnixEpoch: bigint }; +}; + +export function rankedBand(ticket: RankedTicket, now: bigint): number { + const waitedSeconds = + ticket.createdAt.microsSinceUnixEpoch >= now + ? 0n + : (now - ticket.createdAt.microsSinceUnixEpoch) / 1_000_000n; + const extra = + Number(waitedSeconds / RANKED_BAND_STEP_SECONDS) * RANKED_BAND_STEP; + return Math.min(RANKED_MAX_BAND, RANKED_INITIAL_BAND + extra); +} + +export function rankedSelection( + queued: T[], + matchSize: number, + now: bigint +): T[] | undefined { + for (const anchor of queued) { + const anchorRating = anchor.rating ?? DEFAULT_RATING; + const band = rankedBand(anchor, now); + const candidates = queued + .filter( + ticket => + Math.abs((ticket.rating ?? DEFAULT_RATING) - anchorRating) <= band + ) + .filter(ticket => ticket.ratingPool === anchor.ratingPool) + .sort((a, b) => { + const ar = Math.abs((a.rating ?? DEFAULT_RATING) - anchorRating); + const br = Math.abs((b.rating ?? DEFAULT_RATING) - anchorRating); + if (ar !== br) return ar - br; + const av = a.createdAt.microsSinceUnixEpoch; + const bv = b.createdAt.microsSinceUnixEpoch; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + if (candidates.length >= matchSize) return candidates.slice(0, matchSize); + } + return undefined; +} + +export function expectedScore(rating: number, opponentRating: number): number { + return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400)); +} + +export function updatedRating( + rating: number, + expected: number, + score: number +): number { + return Math.max( + MIN_RATING, + Math.min(MAX_RATING, Math.round(rating + ELO_K * (score - expected))) + ); +} diff --git a/spacetime-lobby-ts/src/submodule.ts b/spacetime-lobby-ts/src/submodule.ts new file mode 100644 index 00000000000..bb433d54e9e --- /dev/null +++ b/spacetime-lobby-ts/src/submodule.ts @@ -0,0 +1,56 @@ +export { default } from './submodule/schema'; +export { + RoomStatus, + SeatStatus, + TicketStatus, + lobbyAdminIdentity, + lobbyConfig, + lobbyMatchResult, + lobbyQueueTicket, + lobbyRoom, + lobbyRoomSeat, + lobbySubjectRating, + roomStatus, + seatStatus, + t, + ticketStatus, +} from './submodule/schema'; +export { installLobby } from './submodule/install'; +export { + add_admin_identity, + cancelTicket, + cancel_ticket, + closeRoom, + close_room, + expire_tickets, + get_lobby_status, + joinRankedQueue, + joinQueue, + joinRoom, + join_ranked_queue, + join_queue, + join_room, + leaveRoom, + leave_room, + lobbyAdminMatchResults, + lobbyAdminRoomSeats, + lobbyAdminRooms, + lobbyAdminTickets, + lobbyQueueSummary, + lobbyRankedLeaderboard, + myLobbyRatings, + myLobbyRoomSeats, + myLobbyRooms, + myLobbyTickets, + remove_admin_identity, + reportMatchResult, + set_rating, + update_config, + type JoinQueueArgs, + type JoinRankedQueueArgs, + type JoinQueueResult, + type ReportMatchResultArgs, + type RoomSubjectArgs, + type SetRatingArgs, + type TicketSubjectArgs, +} from './submodule/operations'; diff --git a/spacetime-lobby-ts/src/submodule/install.ts b/spacetime-lobby-ts/src/submodule/install.ts new file mode 100644 index 00000000000..1cf53b55ca1 --- /dev/null +++ b/spacetime-lobby-ts/src/submodule/install.ts @@ -0,0 +1,22 @@ +import type { ReducerModuleCtx } from './schema'; + +const DEFAULT_TICKET_TTL_SECONDS = 60; +const DEFAULT_MAX_MATCH_SIZE = 16; + +export function installLobby(ctx: ReducerModuleCtx) { + if (ctx.db.lobbyConfig.singleton.find(true) == null) { + ctx.db.lobbyConfig.insert({ + singleton: true, + defaultTicketTtlSeconds: DEFAULT_TICKET_TTL_SECONDS, + maxMatchSize: DEFAULT_MAX_MATCH_SIZE, + updatedAt: ctx.timestamp, + }); + } + + if (ctx.db.lobbyAdminIdentity.identity.find(ctx.sender) == null) { + ctx.db.lobbyAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } +} diff --git a/spacetime-lobby-ts/src/submodule/operations.ts b/spacetime-lobby-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..77fff54d43c --- /dev/null +++ b/spacetime-lobby-ts/src/submodule/operations.ts @@ -0,0 +1,814 @@ +import { Range, type Infer } from 'spacetimedb/server'; +import { + RoomStatus, + SeatStatus, + SenderError, + TicketStatus, + lobbyQueueTicket, + lobbyRoom, + lobbySubjectRating, + spacetimedb, + t, + type ProcedureModuleCtx, + type ViewModuleCtx, + type WriteCtx, +} from './schema'; +import { + DEFAULT_RATING, + MAX_RATING, + MIN_RATING, + expectedScore, + rankedSelection, + updatedRating, +} from '../matchmaking'; +import { lobbyCompositeKey } from '../keys'; + +const MAX_POOL_LENGTH = 96; +const MAX_SUBJECT_LENGTH = 160; +const MAX_JSON_LENGTH = 4096; +const DEFAULT_EXPIRE_LIMIT = 100; +const MAX_EXPIRE_LIMIT = 1000; +const MAX_MATCH_CANDIDATES = 5000; + +type QueueTicketRow = Infer; +type RoomRow = Infer; +type SubjectRatingRow = Infer; + +export type JoinQueueArgs = { + pool: string; + subject: string; + matchSize: number; + attributesJson?: string | undefined; + ttlSeconds?: number | undefined; +}; + +export type JoinRankedQueueArgs = JoinQueueArgs & { + ratingPool?: string | undefined; +}; + +export type TicketSubjectArgs = { + ticketId: string; + subject: string; +}; + +export type RoomSubjectArgs = { + roomId: bigint; + subject: string; +}; + +export type ReportMatchResultArgs = { + roomId: bigint; + subject: string; + winnerSubject?: string | undefined; +}; + +export type SetRatingArgs = { + pool: string; + subject: string; + rating: number; +}; + +export type JoinQueueResult = { + ticketId: string; + roomId?: bigint | undefined; +}; + +function fail(message: string): never { + throw new SenderError(`lobby.${message}`); +} + +function subjectForSender( + ctx: WriteCtx | ProcedureModuleCtx | ViewModuleCtx +): string { + return ctx.sender.toHexString(); +} + +function normalizeName(value: string, field: string, max: number): string { + const out = value.trim(); + if (!out) fail(`invalid_${field}`); + if (out.length > max) fail(`${field}_too_long`); + return out; +} + +function validateJson( + value: string | undefined, + field: string +): string | undefined { + if (value === undefined) return undefined; + const out = value.trim(); + if (!out) return undefined; + if (out.length > MAX_JSON_LENGTH) fail(`${field}_too_long`); + try { + const parsed = JSON.parse(out) as unknown; + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) { + fail(`invalid_${field}_json`); + } + } catch { + fail(`invalid_${field}_json`); + } + return out; +} + +function nowMicros(ctx: WriteCtx): bigint { + return ctx.timestamp.microsSinceUnixEpoch; +} + +function ratingId(pool: string, subject: string): string { + return lobbyCompositeKey(pool, subject); +} + +function getRating( + ctx: WriteCtx | ViewModuleCtx, + pool: string, + subject: string +) { + return ctx.db.lobbySubjectRating.ratingId.find(ratingId(pool, subject)); +} + +function getOrCreateRating(ctx: WriteCtx, pool: string, subject: string) { + const existing = getRating(ctx, pool, subject); + if (existing) return existing; + const row = { + ratingId: ratingId(pool, subject), + pool, + subject, + rating: DEFAULT_RATING, + ratingOrder: BigInt(-DEFAULT_RATING), + wins: 0, + losses: 0, + draws: 0, + matches: 0, + updatedAt: ctx.timestamp, + }; + ctx.db.lobbySubjectRating.insert(row); + return row; +} + +function validateRating(value: number): number { + const rating = Math.trunc(value); + if (!Number.isInteger(rating) || rating < MIN_RATING || rating > MAX_RATING) { + fail('invalid_rating'); + } + return rating; +} + +function getConfig(ctx: WriteCtx) { + const existing = ctx.db.lobbyConfig.singleton.find(true); + if (existing) return existing; + const row = { + singleton: true, + defaultTicketTtlSeconds: 60, + maxMatchSize: 16, + updatedAt: ctx.timestamp, + }; + ctx.db.lobbyConfig.insert(row); + return row; +} + +function isAdmin(ctx: WriteCtx | ViewModuleCtx, sender = ctx.sender): boolean { + return ctx.db.lobbyAdminIdentity.identity.find(sender) != null; +} + +function requireAdmin(ctx: WriteCtx): void { + if (!isAdmin(ctx)) fail('not_authorized'); +} + +function isQueued(ticket: QueueTicketRow): boolean { + return ticket.status.tag === TicketStatus.Queued.tag; +} + +function isTerminalRoom(room: RoomRow): boolean { + return ( + room.status.tag === RoomStatus.Closed.tag || + room.status.tag === RoomStatus.Abandoned.tag + ); +} + +function take(rows: Iterable, limit: number): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +function countUpTo(rows: Iterable, limit: number): number { + let count = 0; + for (const _row of rows) { + if (count >= limit) break; + count++; + } + return count; +} + +function expireQueuedTickets(ctx: WriteCtx, limit: number): number { + const now = nowMicros(ctx); + let expired = 0; + for (const ticket of ctx.db.lobbyQueueTicket.byStatusExpiresAt.filter([ + TicketStatus.Queued, + new Range(undefined, { tag: 'included', value: now }), + ])) { + if (expired >= limit) break; + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Expired, + updatedAt: ctx.timestamp, + }); + expired++; + } + return expired; +} + +function activeTicketsForSubjectPool( + ctx: WriteCtx, + subject: string, + pool: string +) { + return take( + ctx.db.lobbyQueueTicket.bySubjectStatus.filter([ + subject, + TicketStatus.Queued, + ]), + 1000 + ).filter(ticket => ticket.pool === pool && isQueued(ticket)); +} + +function seatsForRoom(ctx: WriteCtx | ViewModuleCtx, roomId: bigint) { + return take(ctx.db.lobbyRoomSeat.byRoom.filter(roomId), 128); +} + +function findSeat(ctx: WriteCtx, roomId: bigint, subject: string) { + for (const seat of ctx.db.lobbyRoomSeat.byRoomSubject.filter([ + roomId, + subject, + ])) + return seat; + return undefined; +} + +function refreshRoomAfterJoin(ctx: WriteCtx, roomId: bigint): void { + const room = ctx.db.lobbyRoom.roomId.find(roomId); + if (!room || isTerminalRoom(room)) return; + const seats = seatsForRoom(ctx, roomId); + if (seats.length === 0) return; + const allJoined = seats.every( + seat => seat.status.tag === SeatStatus.Joined.tag + ); + if (allJoined && room.status.tag === RoomStatus.Ready.tag) { + ctx.db.lobbyRoom.roomId.update({ + ...room, + status: RoomStatus.Active, + updatedAt: ctx.timestamp, + }); + } +} + +function markRoomAbandoned(ctx: WriteCtx, roomId: bigint): void { + const room = ctx.db.lobbyRoom.roomId.find(roomId); + if (!room || isTerminalRoom(room)) return; + ctx.db.lobbyRoom.roomId.update({ + ...room, + status: RoomStatus.Abandoned, + updatedAt: ctx.timestamp, + closedAt: ctx.timestamp, + }); +} + +function attemptMatch( + ctx: WriteCtx, + pool: string, + matchSize: number, + ranked: boolean +): bigint | undefined { + const now = nowMicros(ctx); + const queued = take( + ctx.db.lobbyQueueTicket.byPoolStatusCreatedAt.filter([ + pool, + TicketStatus.Queued, + new Range(), + ]), + MAX_MATCH_CANDIDATES + ).filter( + ticket => + ticket.pool === pool && + ticket.ranked === ranked && + ticket.matchSize === matchSize && + ticket.expiresAtMicros > now + ); + if (queued.length < matchSize) return undefined; + + const selected = ranked + ? rankedSelection(queued, matchSize, now) + : queued.slice(0, matchSize); + if (!selected || selected.length < matchSize) return undefined; + + const room = ctx.db.lobbyRoom.insert({ + roomId: 0n, + pool, + status: RoomStatus.Ready, + capacity: matchSize, + metadataJson: ranked + ? JSON.stringify({ + ranked: true, + ratingPool: selected[0].ratingPool ?? pool, + }) + : undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + closedAt: undefined, + }); + + selected.forEach((ticket, index) => { + ctx.db.lobbyRoomSeat.insert({ + seatId: 0n, + roomId: room.roomId, + subject: ticket.subject, + ticketId: ticket.ticketId, + seatIndex: index, + status: SeatStatus.Reserved, + ready: false, + joinedAt: undefined, + leftAt: undefined, + updatedAt: ctx.timestamp, + }); + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Matched, + roomId: room.roomId, + updatedAt: ctx.timestamp, + }); + }); + + return room.roomId; +} + +export function joinQueue(ctx: WriteCtx, args: JoinQueueArgs): JoinQueueResult { + const config = getConfig(ctx); + const pool = normalizeName(args.pool, 'pool', MAX_POOL_LENGTH); + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const matchSize = Math.trunc(args.matchSize); + if ( + !Number.isInteger(matchSize) || + matchSize < 1 || + matchSize > config.maxMatchSize + ) { + fail('invalid_match_size'); + } + const ttlSeconds = + args.ttlSeconds === undefined + ? config.defaultTicketTtlSeconds + : Math.trunc(args.ttlSeconds); + if ( + !Number.isInteger(ttlSeconds) || + ttlSeconds < 1 || + ttlSeconds > 24 * 60 * 60 + ) { + fail('invalid_ttl_seconds'); + } + const attributesJson = validateJson(args.attributesJson, 'attributes'); + + expireQueuedTickets(ctx, DEFAULT_EXPIRE_LIMIT); + for (const ticket of activeTicketsForSubjectPool(ctx, subject, pool)) { + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Cancelled, + updatedAt: ctx.timestamp, + }); + } + + const ticketId = `ticket:${ctx.newUuidV7().toString()}`; + ctx.db.lobbyQueueTicket.insert({ + ticketId, + pool, + subject, + status: TicketStatus.Queued, + matchSize, + ranked: false, + rating: undefined, + ratingPool: undefined, + partyId: undefined, + attributesJson, + roomId: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + expiresAtMicros: nowMicros(ctx) + BigInt(ttlSeconds) * 1_000_000n, + }); + + const roomId = attemptMatch(ctx, pool, matchSize, false); + return { ticketId, roomId }; +} + +export function joinRankedQueue( + ctx: WriteCtx, + args: JoinRankedQueueArgs +): JoinQueueResult { + const config = getConfig(ctx); + const pool = normalizeName(args.pool, 'pool', MAX_POOL_LENGTH); + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const matchSize = Math.trunc(args.matchSize); + if ( + !Number.isInteger(matchSize) || + matchSize < 1 || + matchSize > config.maxMatchSize + ) { + fail('invalid_match_size'); + } + const ttlSeconds = + args.ttlSeconds === undefined + ? config.defaultTicketTtlSeconds + : Math.trunc(args.ttlSeconds); + if ( + !Number.isInteger(ttlSeconds) || + ttlSeconds < 1 || + ttlSeconds > 24 * 60 * 60 + ) { + fail('invalid_ttl_seconds'); + } + const attributesJson = validateJson(args.attributesJson, 'attributes'); + const ratingPool = + args.ratingPool === undefined + ? pool + : normalizeName(args.ratingPool, 'rating_pool', MAX_POOL_LENGTH); + const rating = getOrCreateRating(ctx, ratingPool, subject).rating; + + expireQueuedTickets(ctx, DEFAULT_EXPIRE_LIMIT); + for (const ticket of activeTicketsForSubjectPool(ctx, subject, pool)) { + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Cancelled, + updatedAt: ctx.timestamp, + }); + } + + const ticketId = `ticket:${ctx.newUuidV7().toString()}`; + ctx.db.lobbyQueueTicket.insert({ + ticketId, + pool, + subject, + status: TicketStatus.Queued, + matchSize, + ranked: true, + rating, + ratingPool, + partyId: undefined, + attributesJson, + roomId: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + expiresAtMicros: nowMicros(ctx) + BigInt(ttlSeconds) * 1_000_000n, + }); + + const roomId = attemptMatch(ctx, pool, matchSize, true); + return { ticketId, roomId }; +} + +function ratingPoolForRoom(room: RoomRow): string { + if (!room.metadataJson) return room.pool; + try { + const metadata = JSON.parse(room.metadataJson) as { ratingPool?: unknown }; + return typeof metadata.ratingPool === 'string' && metadata.ratingPool.trim() + ? metadata.ratingPool.trim() + : room.pool; + } catch { + return room.pool; + } +} + +function applyResultRow( + ctx: WriteCtx, + row: SubjectRatingRow, + score: number, + opponentRating: number +) { + const nextRating = updatedRating( + row.rating, + expectedScore(row.rating, opponentRating), + score + ); + const next = { + ...row, + rating: nextRating, + ratingOrder: BigInt(-nextRating), + wins: row.wins + (score === 1 ? 1 : 0), + losses: row.losses + (score === 0 ? 1 : 0), + draws: row.draws + (score === 0.5 ? 1 : 0), + matches: row.matches + 1, + updatedAt: ctx.timestamp, + }; + ctx.db.lobbySubjectRating.ratingId.update(next); + return next; +} + +export function reportMatchResult( + ctx: WriteCtx, + args: ReportMatchResultArgs +): void { + const reporter = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const room = ctx.db.lobbyRoom.roomId.find(args.roomId); + if (!room) fail('room_not_found'); + if (room.status.tag !== RoomStatus.Active.tag) fail('room_not_active'); + const seats = seatsForRoom(ctx, args.roomId).filter( + seat => seat.status.tag !== SeatStatus.Left.tag + ); + if (seats.length !== 2) fail('elo_requires_two_seats'); + if (!seats.some(seat => seat.subject === reporter) && !isAdmin(ctx)) + fail('not_room_participant'); + for (const _existing of ctx.db.lobbyMatchResult.byRoom.filter(args.roomId)) + return; + + const [seatA, seatB] = seats.sort((a, b) => a.seatIndex - b.seatIndex); + const winnerSubject = + args.winnerSubject === undefined + ? undefined + : normalizeName(args.winnerSubject, 'winner_subject', MAX_SUBJECT_LENGTH); + if ( + winnerSubject !== undefined && + winnerSubject !== seatA.subject && + winnerSubject !== seatB.subject + ) { + fail('winner_not_in_room'); + } + + const scoreA = + winnerSubject === undefined ? 0.5 : winnerSubject === seatA.subject ? 1 : 0; + const scoreB = 1 - scoreA; + const ratingPool = ratingPoolForRoom(room); + const ratingA = getOrCreateRating(ctx, ratingPool, seatA.subject); + const ratingB = getOrCreateRating(ctx, ratingPool, seatB.subject); + const nextA = applyResultRow(ctx, ratingA, scoreA, ratingB.rating); + const nextB = applyResultRow(ctx, ratingB, scoreB, ratingA.rating); + ctx.db.lobbyMatchResult.insert({ + resultId: 0n, + roomId: args.roomId, + pool: ratingPool, + winnerSubject, + loserSubject: + winnerSubject === undefined + ? undefined + : winnerSubject === seatA.subject + ? seatB.subject + : seatA.subject, + subjectA: seatA.subject, + subjectB: seatB.subject, + ratingABefore: ratingA.rating, + ratingAAfter: nextA.rating, + ratingBBefore: ratingB.rating, + ratingBAfter: nextB.rating, + reportedAt: ctx.timestamp, + }); +} + +export function cancelTicket(ctx: WriteCtx, args: TicketSubjectArgs): void { + const ticketId = normalizeName(args.ticketId, 'ticket_id', 200); + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const ticket = ctx.db.lobbyQueueTicket.ticketId.find(ticketId); + if (!ticket) fail('ticket_not_found'); + if (ticket.subject !== subject) fail('not_ticket_owner'); + if (!isQueued(ticket)) fail('ticket_not_queued'); + ctx.db.lobbyQueueTicket.ticketId.update({ + ...ticket, + status: TicketStatus.Cancelled, + updatedAt: ctx.timestamp, + }); +} + +export function joinRoom(ctx: WriteCtx, args: RoomSubjectArgs): void { + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const room = ctx.db.lobbyRoom.roomId.find(args.roomId); + if (!room) fail('room_not_found'); + if (isTerminalRoom(room)) fail('room_closed'); + const seat = findSeat(ctx, args.roomId, subject); + if (!seat) fail('seat_not_found'); + if (seat.status.tag === SeatStatus.Left.tag) fail('seat_left'); + ctx.db.lobbyRoomSeat.seatId.update({ + ...seat, + status: SeatStatus.Joined, + joinedAt: seat.joinedAt ?? ctx.timestamp, + updatedAt: ctx.timestamp, + }); + refreshRoomAfterJoin(ctx, args.roomId); +} + +export function leaveRoom(ctx: WriteCtx, args: RoomSubjectArgs): void { + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const room = ctx.db.lobbyRoom.roomId.find(args.roomId); + if (!room) fail('room_not_found'); + const seat = findSeat(ctx, args.roomId, subject); + if (!seat) fail('seat_not_found'); + if (seat.status.tag === SeatStatus.Left.tag) return; + ctx.db.lobbyRoomSeat.seatId.update({ + ...seat, + status: SeatStatus.Left, + ready: false, + leftAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + markRoomAbandoned(ctx, args.roomId); +} + +export function closeRoom(ctx: WriteCtx, args: RoomSubjectArgs): void { + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const room = ctx.db.lobbyRoom.roomId.find(args.roomId); + if (!room) fail('room_not_found'); + if (!findSeat(ctx, args.roomId, subject)) fail('seat_not_found'); + if (room.status.tag === RoomStatus.Closed.tag) return; + ctx.db.lobbyRoom.roomId.update({ + ...room, + status: RoomStatus.Closed, + updatedAt: ctx.timestamp, + closedAt: ctx.timestamp, + }); +} + +export const join_queue = spacetimedb.reducer( + { + pool: t.string(), + matchSize: t.u32(), + attributesJson: t.option(t.string()), + ttlSeconds: t.option(t.u32()), + }, + (ctx, args) => { + joinQueue(ctx, { + pool: args.pool, + subject: subjectForSender(ctx), + matchSize: args.matchSize, + attributesJson: args.attributesJson, + ttlSeconds: args.ttlSeconds, + }); + } +); + +export const join_ranked_queue = spacetimedb.reducer( + { + pool: t.string(), + matchSize: t.u32(), + attributesJson: t.option(t.string()), + ttlSeconds: t.option(t.u32()), + ratingPool: t.option(t.string()), + }, + (ctx, args) => { + joinRankedQueue(ctx, { + pool: args.pool, + subject: subjectForSender(ctx), + matchSize: args.matchSize, + attributesJson: args.attributesJson, + ttlSeconds: args.ttlSeconds, + ratingPool: args.ratingPool, + }); + } +); + +export const cancel_ticket = spacetimedb.reducer( + { ticketId: t.string() }, + (ctx, args) => { + cancelTicket(ctx, { + ticketId: args.ticketId, + subject: subjectForSender(ctx), + }); + } +); + +export const join_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + joinRoom(ctx, { roomId: args.roomId, subject: subjectForSender(ctx) }); + } +); + +export const leave_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + leaveRoom(ctx, { roomId: args.roomId, subject: subjectForSender(ctx) }); + } +); + +export const close_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, args) => { + closeRoom(ctx, { roomId: args.roomId, subject: subjectForSender(ctx) }); + } +); + +export const set_rating = spacetimedb.reducer( + { + pool: t.string(), + subject: t.string(), + rating: t.i32(), + }, + (ctx, args) => { + requireAdmin(ctx); + const pool = normalizeName(args.pool, 'pool', MAX_POOL_LENGTH); + const subject = normalizeName(args.subject, 'subject', MAX_SUBJECT_LENGTH); + const rating = validateRating(args.rating); + const existing = getOrCreateRating(ctx, pool, subject); + ctx.db.lobbySubjectRating.ratingId.update({ + ...existing, + rating, + ratingOrder: BigInt(-rating), + updatedAt: ctx.timestamp, + }); + } +); + +export const expire_tickets = spacetimedb.reducer( + { limit: t.option(t.u32()) }, + (ctx, args) => { + requireAdmin(ctx); + const limit = args.limit ?? DEFAULT_EXPIRE_LIMIT; + if (limit < 1 || limit > MAX_EXPIRE_LIMIT) fail('invalid_expire_limit'); + expireQueuedTickets(ctx, limit); + } +); + +export const update_config = spacetimedb.reducer( + { + defaultTicketTtlSeconds: t.u32(), + maxMatchSize: t.u32(), + }, + (ctx, args) => { + requireAdmin(ctx); + if ( + args.defaultTicketTtlSeconds < 1 || + args.defaultTicketTtlSeconds > 24 * 60 * 60 + ) { + fail('invalid_default_ttl_seconds'); + } + if (args.maxMatchSize < 1 || args.maxMatchSize > 128) + fail('invalid_max_match_size'); + const config = getConfig(ctx); + ctx.db.lobbyConfig.singleton.update({ + ...config, + defaultTicketTtlSeconds: args.defaultTicketTtlSeconds, + maxMatchSize: args.maxMatchSize, + updatedAt: ctx.timestamp, + }); + } +); + +export const add_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + if (ctx.db.lobbyAdminIdentity.identity.find(args.identity) != null) return; + ctx.db.lobbyAdminIdentity.insert({ + identity: args.identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } +); + +export const remove_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + const row = ctx.db.lobbyAdminIdentity.identity.find(args.identity); + if (!row) return; + if (ctx.db.lobbyAdminIdentity.count() <= 1n) + fail('cannot_remove_last_admin'); + ctx.db.lobbyAdminIdentity.delete(row); + } +); + +export const get_lobby_status = spacetimedb.procedure({}, t.string(), ctx => { + const status = ctx.withTx(tx => { + const queuedTickets = countUpTo( + tx.db.lobbyQueueTicket.byStatus.filter(TicketStatus.Queued), + MAX_MATCH_CANDIDATES + ); + const readyRooms = countUpTo( + tx.db.lobbyRoom.byStatus.filter(RoomStatus.Ready), + MAX_MATCH_CANDIDATES + ); + const activeRooms = countUpTo( + tx.db.lobbyRoom.byStatus.filter(RoomStatus.Active), + MAX_MATCH_CANDIDATES + ); + const config = getConfig(tx); + return { + defaultTicketTtlSeconds: config.defaultTicketTtlSeconds, + maxMatchSize: config.maxMatchSize, + queuedTickets, + readyRooms, + activeRooms, + }; + }); + return JSON.stringify(status); +}); + +export { + lobbyAdminMatchResults, + lobbyAdminRoomSeats, + lobbyAdminRooms, + lobbyAdminTickets, + lobbyQueueSummary, + lobbyRankedLeaderboard, + myLobbyRatings, + myLobbyRoomSeats, + myLobbyRooms, + myLobbyTickets, +} from './views'; diff --git a/spacetime-lobby-ts/src/submodule/schema.ts b/spacetime-lobby-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..6e09f8885be --- /dev/null +++ b/spacetime-lobby-ts/src/submodule/schema.ts @@ -0,0 +1,270 @@ +import { + SenderError, + schema, + table, + t, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { installLobby } from './install'; + +export const ticketStatus = t.enum('LobbyTicketStatus', [ + 'Queued', + 'Matched', + 'Cancelled', + 'Expired', +]); +export const TicketStatus = { + Queued: { tag: 'Queued' as const }, + Matched: { tag: 'Matched' as const }, + Cancelled: { tag: 'Cancelled' as const }, + Expired: { tag: 'Expired' as const }, +}; + +export const roomStatus = t.enum('LobbyRoomStatus', [ + 'Ready', + 'Active', + 'Closed', + 'Abandoned', +]); +export const RoomStatus = { + Ready: { tag: 'Ready' as const }, + Active: { tag: 'Active' as const }, + Closed: { tag: 'Closed' as const }, + Abandoned: { tag: 'Abandoned' as const }, +}; + +export const seatStatus = t.enum('LobbySeatStatus', [ + 'Reserved', + 'Joined', + 'Left', + 'Disconnected', +]); +export const SeatStatus = { + Reserved: { tag: 'Reserved' as const }, + Joined: { tag: 'Joined' as const }, + Left: { tag: 'Left' as const }, + Disconnected: { tag: 'Disconnected' as const }, +}; + +export const lobbyConfig = table( + { name: 'lobby_config', public: false }, + { + singleton: t.bool().primaryKey(), + defaultTicketTtlSeconds: t.u32(), + maxMatchSize: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const lobbyAdminIdentity = table( + { name: 'lobby_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +export const lobbyQueueTicket = table( + { + name: 'lobby_queue_ticket', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { accessor: 'byPool', algorithm: 'btree', columns: ['pool'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + { + accessor: 'byPoolStatusCreatedAt', + algorithm: 'btree', + columns: ['pool', 'status', 'createdAt'], + }, + { + accessor: 'byStatusExpiresAt', + algorithm: 'btree', + columns: ['status', 'expiresAtMicros'], + }, + { + accessor: 'bySubjectStatus', + algorithm: 'btree', + columns: ['subject', 'status'], + }, + ], + }, + { + ticketId: t.string().primaryKey(), + pool: t.string(), + subject: t.string(), + status: ticketStatus, + matchSize: t.u32(), + ranked: t.bool(), + rating: t.option(t.i32()), + ratingPool: t.option(t.string()), + partyId: t.option(t.string()), + attributesJson: t.option(t.string()), + roomId: t.option(t.u64()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + expiresAtMicros: t.i64(), + } +); + +export const lobbySubjectRating = table( + { + name: 'lobby_subject_rating', + public: false, + indexes: [ + { accessor: 'byPool', algorithm: 'btree', columns: ['pool'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + { accessor: 'byRating', algorithm: 'btree', columns: ['rating'] }, + { + accessor: 'byLeaderboardOrder', + algorithm: 'btree', + columns: ['pool', 'ratingOrder', 'subject'], + }, + { accessor: 'byUpdatedAt', algorithm: 'btree', columns: ['updatedAt'] }, + ], + }, + { + ratingId: t.string().primaryKey(), + pool: t.string(), + subject: t.string(), + rating: t.i32(), + ratingOrder: t.i64(), + wins: t.u32(), + losses: t.u32(), + draws: t.u32(), + matches: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const lobbyRoom = table( + { + name: 'lobby_room', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { accessor: 'byPool', algorithm: 'btree', columns: ['pool'] }, + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + roomId: t.u64().primaryKey().autoInc(), + pool: t.string(), + status: roomStatus, + capacity: t.u32(), + metadataJson: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + closedAt: t.option(t.timestamp()), + } +); + +export const lobbyMatchResult = table( + { + name: 'lobby_match_result', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'byPool', algorithm: 'btree', columns: ['pool'] }, + { accessor: 'byReportedAt', algorithm: 'btree', columns: ['reportedAt'] }, + ], + }, + { + resultId: t.u64().primaryKey().autoInc(), + roomId: t.u64(), + pool: t.string(), + winnerSubject: t.option(t.string()), + loserSubject: t.option(t.string()), + subjectA: t.string(), + subjectB: t.string(), + ratingABefore: t.i32(), + ratingAAfter: t.i32(), + ratingBBefore: t.i32(), + ratingBAfter: t.i32(), + reportedAt: t.timestamp(), + } +); + +export const lobbyRoomSeat = table( + { + name: 'lobby_room_seat', + public: false, + indexes: [ + { accessor: 'byRoom', algorithm: 'btree', columns: ['roomId'] }, + { accessor: 'bySubject', algorithm: 'btree', columns: ['subject'] }, + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { + accessor: 'byRoomSubject', + algorithm: 'btree', + columns: ['roomId', 'subject'], + }, + ], + }, + { + seatId: t.u64().primaryKey().autoInc(), + roomId: t.u64(), + subject: t.string(), + ticketId: t.option(t.string()), + seatIndex: t.u32(), + status: seatStatus, + ready: t.bool(), + joinedAt: t.option(t.timestamp()), + leftAt: t.option(t.timestamp()), + updatedAt: t.timestamp(), + } +); + +export const queueSummaryRow = t.object('LobbyQueueSummaryRow', { + pool: t.string(), + queuedTickets: t.u32(), + readyRooms: t.u32(), + activeRooms: t.u32(), +}); + +export const lobbyStatusRow = t.object('LobbyStatusRow', { + defaultTicketTtlSeconds: t.u32(), + maxMatchSize: t.u32(), + queuedTickets: t.u32(), + readyRooms: t.u32(), + activeRooms: t.u32(), +}); + +export const rankedRatingRow = t.object('LobbyRankedRatingRow', { + pool: t.string(), + subject: t.string(), + rating: t.i32(), + wins: t.u32(), + losses: t.u32(), + draws: t.u32(), + matches: t.u32(), +}); + +export const spacetimedb = schema({ + lobbyConfig, + lobbyAdminIdentity, + lobbyQueueTicket, + lobbySubjectRating, + lobbyRoom, + lobbyMatchResult, + lobbyRoomSeat, +}); + +export const init = spacetimedb.init(ctx => { + installLobby(ctx); +}); + +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; + +export { SenderError, t }; diff --git a/spacetime-lobby-ts/src/submodule/views.ts b/spacetime-lobby-ts/src/submodule/views.ts new file mode 100644 index 00000000000..f1675c9b65f --- /dev/null +++ b/spacetime-lobby-ts/src/submodule/views.ts @@ -0,0 +1,256 @@ +import { Range } from 'spacetimedb/server'; +import { + RoomStatus, + TicketStatus, + lobbyMatchResult, + lobbyQueueTicket, + lobbyRoom, + lobbyRoomSeat, + queueSummaryRow, + rankedRatingRow, + spacetimedb, + t, + type ViewModuleCtx, +} from './schema'; + +const MAX_MATCH_CANDIDATES = 5000; +const MAX_VIEW_ROWS = 500; + +function subjectForSender(ctx: ViewModuleCtx): string { + return ctx.sender.toHexString(); +} + +function isAdmin(ctx: ViewModuleCtx): boolean { + return ctx.db.lobbyAdminIdentity.identity.find(ctx.sender) != null; +} + +function newestFirst( + rows: T[] +): T[] { + return rows.sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch; + const bv = b.createdAt.microsSinceUnixEpoch; + return av < bv ? 1 : av > bv ? -1 : 0; + }); +} + +function selectTopRows( + rows: Iterable, + limit: number, + compare: (a: T, b: T) => number +): T[] { + const selected: T[] = []; + for (const row of rows) { + let low = 0; + let high = selected.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (compare(row, selected[mid]!) < 0) high = mid; + else low = mid + 1; + } + if (low >= limit) continue; + selected.splice(low, 0, row); + if (selected.length > limit) selected.pop(); + } + return selected; +} + +function newestRowsBy( + rows: Iterable, + limit: number, + timestamp: (row: T) => bigint +): T[] { + return selectTopRows(rows, limit, (a, b) => { + const av = timestamp(a); + const bv = timestamp(b); + return av < bv ? 1 : av > bv ? -1 : 0; + }); +} + +function newestRows( + rows: Iterable, + limit: number +): T[] { + return newestRowsBy(rows, limit, row => row.createdAt.microsSinceUnixEpoch); +} + +function take(rows: Iterable, limit: number): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +export const myLobbyTickets = spacetimedb.view( + { name: 'my_lobby_tickets', public: true }, + t.array(lobbyQueueTicket.rowType), + ctx => + newestRows( + ctx.db.lobbyQueueTicket.bySubject.filter(subjectForSender(ctx)), + MAX_VIEW_ROWS + ) +); + +export const myLobbyRatings = spacetimedb.view( + { name: 'my_lobby_ratings', public: true }, + t.array(rankedRatingRow), + ctx => + selectTopRows( + ctx.db.lobbySubjectRating.bySubject.filter(subjectForSender(ctx)), + MAX_VIEW_ROWS, + (a, b) => a.pool.localeCompare(b.pool) + ) + .map(row => ({ + pool: row.pool, + subject: row.subject, + rating: row.rating, + wins: row.wins, + losses: row.losses, + draws: row.draws, + matches: row.matches, + })) + .sort((a, b) => a.pool.localeCompare(b.pool)) +); + +export const myLobbyRoomSeats = spacetimedb.view( + { name: 'my_lobby_room_seats', public: true }, + t.array(lobbyRoomSeat.rowType), + ctx => + newestRowsBy( + ctx.db.lobbyRoomSeat.bySubject.filter(subjectForSender(ctx)), + MAX_VIEW_ROWS, + row => row.updatedAt.microsSinceUnixEpoch + ) +); + +export const myLobbyRooms = spacetimedb.view( + { name: 'my_lobby_rooms', public: true }, + t.array(lobbyRoom.rowType), + ctx => { + const subject = subjectForSender(ctx); + const seen = new Set(); + const rooms = []; + for (const seat of newestRowsBy( + ctx.db.lobbyRoomSeat.bySubject.filter(subject), + MAX_VIEW_ROWS, + row => row.updatedAt.microsSinceUnixEpoch + )) { + const key = seat.roomId.toString(); + if (seen.has(key)) continue; + seen.add(key); + const room = ctx.db.lobbyRoom.roomId.find(seat.roomId); + if (room) rooms.push(room); + } + return newestFirst(rooms); + } +); + +export const lobbyQueueSummary = spacetimedb.view( + { name: 'lobby_queue_summary', public: true }, + t.array(queueSummaryRow), + ctx => { + const summary = new Map< + string, + { + pool: string; + queuedTickets: number; + readyRooms: number; + activeRooms: number; + } + >(); + const ensure = (pool: string) => { + let row = summary.get(pool); + if (!row) { + row = { pool, queuedTickets: 0, readyRooms: 0, activeRooms: 0 }; + summary.set(pool, row); + } + return row; + }; + for (const ticket of take( + ctx.db.lobbyQueueTicket.byStatus.filter(TicketStatus.Queued), + MAX_MATCH_CANDIDATES + )) { + ensure(ticket.pool).queuedTickets++; + } + for (const room of take( + ctx.db.lobbyRoom.byStatus.filter(RoomStatus.Ready), + MAX_MATCH_CANDIDATES + )) { + ensure(room.pool).readyRooms++; + } + for (const room of take( + ctx.db.lobbyRoom.byStatus.filter(RoomStatus.Active), + MAX_MATCH_CANDIDATES + )) { + ensure(room.pool).activeRooms++; + } + return [...summary.values()].sort((a, b) => a.pool.localeCompare(b.pool)); + } +); + +export const lobbyRankedLeaderboard = spacetimedb.view( + { name: 'lobby_ranked_leaderboard', public: true }, + t.array(rankedRatingRow), + ctx => + take( + ctx.db.lobbySubjectRating.byLeaderboardOrder.filter(new Range()), + 500 + ).map(row => ({ + pool: row.pool, + subject: row.subject, + rating: row.rating, + wins: row.wins, + losses: row.losses, + draws: row.draws, + matches: row.matches, + })) +); + +export const lobbyAdminTickets = spacetimedb.view( + { name: 'lobby_admin_tickets', public: true }, + t.array(lobbyQueueTicket.rowType), + ctx => + isAdmin(ctx) + ? newestRows( + ctx.db.lobbyQueueTicket.byCreatedAt.filter(new Range()), + MAX_VIEW_ROWS + ) + : [] +); + +export const lobbyAdminRooms = spacetimedb.view( + { name: 'lobby_admin_rooms', public: true }, + t.array(lobbyRoom.rowType), + ctx => + isAdmin(ctx) + ? newestRows( + ctx.db.lobbyRoom.byCreatedAt.filter(new Range()), + MAX_VIEW_ROWS + ) + : [] +); + +export const lobbyAdminRoomSeats = spacetimedb.view( + { name: 'lobby_admin_room_seats', public: true }, + t.array(lobbyRoomSeat.rowType), + ctx => (isAdmin(ctx) ? take(ctx.db.lobbyRoomSeat.iter(), 1000) : []) +); + +export const lobbyAdminMatchResults = spacetimedb.view( + { name: 'lobby_admin_match_results', public: true }, + t.array(lobbyMatchResult.rowType), + ctx => + isAdmin(ctx) + ? selectTopRows( + ctx.db.lobbyMatchResult.byReportedAt.filter(new Range()), + 500, + (a, b) => { + const av = a.reportedAt.microsSinceUnixEpoch; + const bv = b.reportedAt.microsSinceUnixEpoch; + return av < bv ? 1 : av > bv ? -1 : 0; + } + ) + : [] +); diff --git a/spacetime-lobby-ts/tsconfig.json b/spacetime-lobby-ts/tsconfig.json new file mode 100644 index 00000000000..c659d97428a --- /dev/null +++ b/spacetime-lobby-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-posthog-ts/.gitignore b/spacetime-posthog-ts/.gitignore new file mode 100644 index 00000000000..0eec7566545 --- /dev/null +++ b/spacetime-posthog-ts/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +ts-codegen +*.tsbuildinfo +.DS_Store diff --git a/spacetime-posthog-ts/LICENSE.txt b/spacetime-posthog-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-posthog-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-posthog-ts/README.md b/spacetime-posthog-ts/README.md new file mode 100644 index 00000000000..d627ca771dc --- /dev/null +++ b/spacetime-posthog-ts/README.md @@ -0,0 +1,171 @@ +# @spacetimedb/posthog + +A SpacetimeDB submodule for server-side PostHog analytics: direct capture, +durable queued events, explicit batch flush, feature flag evaluation, and +admin-scoped delivery state. Procedures call PostHog through `ctx.http.fetch`. + +--- + +## Install + +```bash +npm install @spacetimedb/posthog spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +This submodule can be published directly as its own STDB module from the root entrypoint. + +## Usage + +### Integrate into an application + +Mount PostHog in the host schema. Configure its private credentials through an +administrator-only startup path, enqueue events from reducers, and perform +network delivery from procedures: + +```ts +import { schema, t } from 'spacetimedb/server'; +import * as posthog from '@spacetimedb/posthog/submodule'; + +const spacetimedb = schema({ posthog }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + posthog.installPostHog(ctx.as.posthog); +}); + +export const complete_order = spacetimedb.reducer( + { orderId: t.string(), totalCents: t.u64() }, + (ctx, args) => { + // Apply the application's order mutation in this reducer transaction. + posthog.enqueueEvent(ctx.as.posthog, { + distinctId: ctx.sender.toHexString(), + event: 'order_completed', + propertiesJson: JSON.stringify({ + orderId: args.orderId, + totalCents: args.totalCents.toString(), + }), + idempotencyKey: `order_completed:${args.orderId}`, + }); + } +); +``` + +The host must decide which events and delivery controls a caller may use. See +the +[Context Cafe host module](./example/spacetimedb/) +for reducer-safe queueing, procedure delivery, and admin-scoped observability. + +### Standalone configuration + +PostHog credentials live in a private `posthog_config` singleton. During +`init`, a fresh database seeds the owner into the private +`posthog_admin_identity` table. + +```bash +spacetime call --server http://127.0.0.1:3000 posthog-ts set_posthog_config \ + '"https://us.i.posthog.com"' \ + '"phc_..."' +``` + +Verify: + +```bash +spacetime call --server http://127.0.0.1:3000 posthog-ts get_posthog_config_status '{}' +``` + +The project token stays in private module state. + +## Public views + +The submodule stores operational state in private tables and exposes admin-gated subscribable views: + +| View | Notes | +| ---------------------------- | ---------------------------------------------------------- | +| `posthog_outbox_admin` | up to 500 queued events waiting for explicit delivery | +| `posthog_delivery_log_admin` | recent direct capture, flush, and flag evaluation attempts | + +## API + +**Setup** + +- `set_posthog_config({ host, projectApiKey })` +- `get_posthog_config_status()` +- `add_admin_identity(identity)` / `remove_admin_identity(identity)` + +**Analytics** + +- `capture_now({ distinctId, event, propertiesJson })` sends one event immediately + through PostHog `/batch` and returns a JSON result string. +- `flush_outbox({ limit })` sends queued events in one `/batch` request, updates + delivery state, and returns a JSON result string. +- `get_feature_flag({ key, distinctId, personPropertiesJson, groupsJson })` calls + PostHog `/flags?v=2` and returns a JSON result string with the requested flag + value when present. + +**Maintenance** + +- `clearAnalytics(ctx, maxRows)` removes a bounded set of outbox and delivery + rows for operator-controlled resets. +- `posthog_outbox_admin` and `posthog_delivery_log_admin` expose bounded, + administrator-scoped operational views. + +Mounted state exports include `posthogOutbox`, `posthogDeliveryLog`, +`posthogDeliveryStats`, and `OutboxStatus` for host-defined views and operator +workflows. + +These mounted operations are admin-only because they can spend provider quota. +Expose product-specific host operations that derive the distinct ID and event or +flag name from authorized application state. + +**Reducer-safe queueing** + +- `enqueue_event({ distinctId, event, propertiesJson, idempotencyKey })` writes a + durable event intent inside a reducer transaction. The mounted reducer is + admin-only; host reducers should call `enqueueEvent` after authorization. + +For mounted modules, import `@spacetimedb/posthog/submodule` and call `enqueueEvent(ctx.as.posthog, ...)` from reducers or `captureNow(ctx.as.posthog, ...)` / `flushOutbox(ctx.as.posthog, ...)` from procedures. + +The client calls the business operation. Analytics remain a server-side +concern: + +```ts +await conn.reducers.completeOrder({ orderId, totalCents }); +``` + +An operator-owned procedure or scheduled workflow should call +`posthog.flushOutbox(ctx.as.posthog, { limit })`. Keep provider credentials and +generic event names inside the module. + +Package entrypoints: + +- `@spacetimedb/posthog` can run as a standalone analytics database. +- `@spacetimedb/posthog/submodule` supplies mounted state, configuration, + delivery helpers, and admin views. + +## Architecture notes + +- **Synchronous HTTP API.** Module procedures call PostHog's HTTP endpoints + directly through `ctx.http.fetch`. +- **Direct plus outbox.** Immediate capture is useful for important events. The outbox is for reducer-safe transactional queueing and explicit flush. +- **Browser analytics.** Applications can add `posthog-js` in the frontend for + autocapture and session replay. + +## Testing + +```bash +pnpm test +pnpm exec tsc --noEmit +pnpm run build +npm pack --dry-run --json +``` + +The example app in `example/` mounts the submodule under the `posthog` namespace and subscribes to the admin views. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-posthog-ts/example/.env.example b/spacetime-posthog-ts/example/.env.example new file mode 100644 index 00000000000..9edb0dee52c --- /dev/null +++ b/spacetime-posthog-ts/example/.env.example @@ -0,0 +1,10 @@ +PORT=8796 +HOST=127.0.0.1 +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_DATABASE=spacetime-posthog-example +# Optional. When unset, the server creates a persistent local identity token in +# .stdb-server-token and the logged-in publishing identity authorizes it. +# STDB_SERVER_TOKEN= +POSTHOG_HOST=https://us.i.posthog.com +POSTHOG_PROJECT_API_KEY=phc_replace_me diff --git a/spacetime-posthog-ts/example/.gitignore b/spacetime-posthog-ts/example/.gitignore new file mode 100644 index 00000000000..1232c4118a7 --- /dev/null +++ b/spacetime-posthog-ts/example/.gitignore @@ -0,0 +1,7 @@ +node_modules +public/app.js +public/app.js.map +src/codegen +*.log +.env +.stdb-server-token diff --git a/spacetime-posthog-ts/example/README.md b/spacetime-posthog-ts/example/README.md new file mode 100644 index 00000000000..c480becd37c --- /dev/null +++ b/spacetime-posthog-ts/example/README.md @@ -0,0 +1,166 @@ +# Context Cafe + +Context Cafe is a small robot café simulator that demonstrates the mounted +`@spacetimedb/posthog/submodule`. SpacetimeDB owns the catalog, simulation, +per-browser café state, metrics, and analytics outbox. A dedicated local server +identity delivers queued events to PostHog; the browser never receives component +administrator privileges or the PostHog project key. + +## What this demonstrates + +- Mounting the PostHog component under the `posthog` namespace. +- Enqueuing analytics in deterministic reducers for delivery outside + transactions. +- Delivering the component outbox from an authorized server connection. +- Caller-scoped café state and safe public aggregate delivery metrics. +- Editing prices and availability while watching simulated conversion change. +- Synchronizing a TypeScript-authored catalog from `catalog/catalog.ts`. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server reachable as `local`. +- A logged-in CLI identity. The identity that publishes the fresh database becomes + its initial component administrator. +- Optional: a PostHog project API key for real event delivery. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +Confirm the local server before continuing: + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-posthog-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Set `POSTHOG_PROJECT_API_KEY` in `.env` before starting if you want events delivered +to PostHog. Open , press **Run**, and watch the café and the +`→ PostHog` counter update. + +`build:module:fresh` deletes and recreates only the local `spacetime-posthog-example` +database. Use `pnpm run build:module` when existing data must be preserved. + +## Use in your project + +This workspace tests the component source in this repository. Consumer +applications install the published release: + +```bash +npm install @spacetimedb/posthog spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +enqueue, delivery, and admin-observability boundaries; the cafe simulator and +its event catalog are demonstration code. + +## Configuration + +| Variable | Default | Purpose | +| ------------------------- | --------------------------- | ------------------------------------------------------------------------ | +| `POSTHOG_PROJECT_API_KEY` | empty | Enables real PostHog delivery. Kept outside the browser. | +| `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser and server WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | CLI administration endpoint. Must address the same server as `STDB_URI`. | +| `STDB_DATABASE` | `spacetime-posthog-example` | Published database name. | +| `STDB_SERVER_TOKEN` | generated locally | Optional pre-provisioned server identity token. | +| `HOST` | `127.0.0.1` | Static-server bind address. | +| `PORT` | `8796` | Static-server port. | + +When `STDB_SERVER_TOKEN` is unset, the server stores its generated identity token in +the ignored `.stdb-server-token` file. On startup, the logged-in CLI publisher calls +`posthog.add_admin_identity` for that identity. This keeps the browser unprivileged +and preserves the delivery identity across restarts. + +## Architecture + +```text +Browser + -> caller-scoped café reducers and views + -> analytics events queued in the mounted posthog namespace + +Authorized example server + -> subscribes to the admin-scoped outbox view + -> calls flush_analytics in bounded batches + -> PostHog ingestion API +``` + +The public `cafe_analytics_summary` view exposes counts only. The detailed outbox +and delivery-log views return rows only to registered PostHog administrators. + +The Node server exposes only: + +| Route | Purpose | +| ----------------- | ---------------------------------------------------------- | +| `GET /api/health` | Local health probe. | +| `GET /api/config` | Browser-safe database and PostHog dashboard configuration. | + +Component administrator grants are available only through module operations. + +## Security and deployment boundaries + +- `POSTHOG_PROJECT_API_KEY` is loaded by the server and written to the component's + private configuration table through the authenticated CLI owner. +- `.stdb-server-token`, `.env`, and logs are ignored and must not be committed. +- The development server binds to loopback by default. Setting `HOST` to another + address deliberately expands its network exposure. +- The example server is scoped to local development. Production deployments + should provision service identities and + lifecycle supervision explicitly. + +## Verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a complete local smoke test, fresh-publish the database, start the server, load +the UI, press **Run**, and confirm that ticks, queued activity, and the PostHog count +advance through the authorized server identity. + +## Troubleshooting + +- **Connection targets disagree:** `STDB_URI`, `STDB_HTTP`, and the server selected + by the publish script must refer to the same SpacetimeDB instance. +- **Server identity cannot be authorized:** publish with the currently logged-in + CLI identity, then restart. Remove `.stdb-server-token` only when deliberately + replacing the local server identity. +- **Events stay queued:** verify `POSTHOG_PROJECT_API_KEY`, inspect server output, + and confirm the PostHog host is reachable. +- **Stored browser identity is rejected after a reset:** reload once; the client + discards the rejected browser token and obtains a fresh caller identity automatically. + +## Important files + +- `spacetimedb/src/index.ts`: host schema, scoped views, reducers, and PostHog + delegation. +- `spacetimedb/src/economy.ts`: simulation tuning, capacity rules, pricing, and + deterministic purchase behavior. +- `catalog/catalog.ts`: product, recipe, and scenario source data. +- `scripts/test-economy.ts`: focused tests for the simulator's economy rules. +- `server.ts`: safe startup configuration, server identity, and outbox delivery. +- `src/app.ts`: browser connection and café UI behavior. +- `public/index.html`: café interface structure. +- `public/styles.css`: café presentation. diff --git a/spacetime-posthog-ts/example/catalog/catalog.ts b/spacetime-posthog-ts/example/catalog/catalog.ts new file mode 100644 index 00000000000..41ba9b6ee5e --- /dev/null +++ b/spacetime-posthog-ts/example/catalog/catalog.ts @@ -0,0 +1,240 @@ +// Seed catalog. The server passes these to the sync_catalog reducer at startup. + +export interface VariantSeed { + variantId: string; + productId: string; + name: string; + flavor: string; + contextTokens: number; + reasoning: number; + latency: number; + priceCents: number; + discountBps?: number; + active?: boolean; + featured?: boolean; +} + +export interface ProductSeed { + productId: string; + name: string; + category: string; + description: string; + baseAppeal: number; + active?: boolean; + variants: VariantSeed[]; +} + +export interface ScenarioSeed { + scenarioId: string; + name: string; + description: string; + trafficPerTick: number; + priceSensitivity: number; + rushBias: number; + researchBias: number; + visualBias: number; + memoryBias: number; + premiumBias: number; + volatility: number; +} + +export const PRODUCTS: ProductSeed[] = [ + { + productId: 'context_cooler', + name: 'Context Cooler', + category: 'context', + description: + 'A tall glass of extra working memory for agents with long prompts.', + baseAppeal: 68, + variants: [ + { + variantId: 'context_cooler_classic', + productId: 'context_cooler', + name: 'Classic Context', + flavor: 'vanilla', + contextTokens: 64000, + reasoning: 4, + latency: 5, + priceCents: 900, + }, + { + variantId: 'context_cooler_raspberry', + productId: 'context_cooler', + name: 'Raspberry Long Context', + flavor: 'raspberry', + contextTokens: 180000, + reasoning: 6, + latency: 4, + priceCents: 1900, + discountBps: 500, + featured: true, + }, + ], + }, + { + productId: 'reasoning_refresher', + name: 'Reasoning Refresher', + category: 'quality', + description: + 'Extra thinking syrup for bots that refuse to be wrong in public.', + baseAppeal: 72, + variants: [ + { + variantId: 'reasoning_refresher_smart', + productId: 'reasoning_refresher', + name: 'Smart Syrup', + flavor: 'blueberry', + contextTokens: 96000, + reasoning: 8, + latency: 3, + priceCents: 2400, + }, + { + variantId: 'reasoning_refresher_deep', + productId: 'reasoning_refresher', + name: 'Deep Thought Double', + flavor: 'espresso', + contextTokens: 220000, + reasoning: 10, + latency: 2, + priceCents: 3900, + }, + ], + }, + { + productId: 'speed_spritz', + name: 'Speed Spritz', + category: 'latency', + description: 'Cold, fizzy priority inference for bots in a hurry.', + baseAppeal: 66, + variants: [ + { + variantId: 'speed_spritz_priority', + productId: 'speed_spritz', + name: 'Priority Lime', + flavor: 'lime', + contextTokens: 48000, + reasoning: 4, + latency: 9, + priceCents: 1400, + }, + ], + }, + { + productId: 'vision_fizz', + name: 'Vision Fizz', + category: 'multimodal', + description: 'Sparkling image support for bots staring at screenshots.', + baseAppeal: 62, + variants: [ + { + variantId: 'vision_fizz_snapshot', + productId: 'vision_fizz', + name: 'Snapshot Soda', + flavor: 'grape', + contextTokens: 80000, + reasoning: 5, + latency: 5, + priceCents: 1700, + }, + ], + }, + { + productId: 'memory_mint', + name: 'Memory Mint', + category: 'memory', + description: + 'Persistent memory with a clean finish and fewer repeated questions.', + baseAppeal: 58, + variants: [ + { + variantId: 'memory_mint_sticky', + productId: 'memory_mint', + name: 'Sticky Mint', + flavor: 'mint', + contextTokens: 120000, + reasoning: 5, + latency: 4, + priceCents: 2100, + }, + ], + }, + { + productId: 'tool_tonic', + name: 'Tool Tonic', + category: 'tools', + description: 'Function-calling bubbles for agents with things to do.', + baseAppeal: 64, + variants: [ + { + variantId: 'tool_tonic_fizz', + productId: 'tool_tonic', + name: 'Tool Fizz', + flavor: 'ginger', + contextTokens: 90000, + reasoning: 6, + latency: 6, + priceCents: 1600, + }, + ], + }, +]; + +export const SCENARIOS: ScenarioSeed[] = [ + { + scenarioId: 'steady_shift', + name: 'Steady Shift', + description: + 'A normal cafe shift with mixed robot traffic and balanced preferences.', + trafficPerTick: 4, + priceSensitivity: 45, + rushBias: 35, + researchBias: 35, + visualBias: 22, + memoryBias: 24, + premiumBias: 24, + volatility: 18, + }, + { + scenarioId: 'launch_rush', + name: 'Launch Rush', + description: + 'A product launch sends impatient agents sprinting for priority inference.', + trafficPerTick: 8, + priceSensitivity: 28, + rushBias: 70, + researchBias: 34, + visualBias: 24, + memoryBias: 20, + premiumBias: 42, + volatility: 32, + }, + { + scenarioId: 'budget_bots', + name: 'Budget Bots', + description: + 'A coupon crowd wants lots of compute and hates sticker shock.', + trafficPerTick: 6, + priceSensitivity: 82, + rushBias: 26, + researchBias: 28, + visualBias: 18, + memoryBias: 22, + premiumBias: 10, + volatility: 24, + }, + { + scenarioId: 'research_lab', + name: 'Research Lab', + description: + 'Deep-work agents prefer long context, high reasoning, memory, and quality.', + trafficPerTick: 5, + priceSensitivity: 25, + rushBias: 18, + researchBias: 76, + visualBias: 28, + memoryBias: 62, + premiumBias: 58, + volatility: 16, + }, +]; diff --git a/spacetime-posthog-ts/example/package.json b/spacetime-posthog-ts/example/package.json new file mode 100644 index 00000000000..f893dc48662 --- /dev/null +++ b/spacetime-posthog-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-posthog-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts", + "test:unit": "tsx scripts/test-economy.ts" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-posthog-ts/example/public/assets/brand.svg b/spacetime-posthog-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-posthog-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-posthog-ts/example/public/index.html b/spacetime-posthog-ts/example/public/index.html new file mode 100644 index 00000000000..da01d87fa5c --- /dev/null +++ b/spacetime-posthog-ts/example/public/index.html @@ -0,0 +1,314 @@ + + + + + + + + Context Cafe + + + +
        +
        +
        + +
        +

        Context Cafe

        +
        +
        +
        + + View in PostHog + + +
        + + + + + + +
        +
        +
        + +
        +
        + Cash on hand + $0.00 +
        ▲ $0.00 profit
        +
        + $0.00 sales + $0.00 supplies +
        +
        + ★★★☆☆ + reputation 50 +
        +
        +
        +
        + Workers1/tick +
        + +
        +
        +
        + Machinesstandard +
        + +
        +
        +
        + Counterholds 6 +
        + +
        +
        +
        + Storeroomstandard +
        + +
        +
        +
        + +
        +
        +

        Today's menu

        + tap a drink to price it +
        + +
        + +
        +
        + 🔥 Rush hour: bots are pouring in +
        +
        +
        +
        +
        + Compute0/150 +
        +
        +
        +
        +
        + GPU-seconds +
        +
        +
        +
        + Context0/250 +
        +
        +
        +
        +
        + tokens +
        +
        +
        +
        + Memory0/100 +
        +
        +
        +
        +
        + GB +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        +
        + +
        + 0 ticks + + 0.0% conv + $0.00 avg order + 0 stockouts + 0 gave up + 0 → PostHog +
        +
        + +
        + Built on + SpacetimeDB +
        + +
        +
        + + +
        + + + + + diff --git a/spacetime-posthog-ts/example/public/styles.css b/spacetime-posthog-ts/example/public/styles.css new file mode 100644 index 00000000000..d7374b10bc4 --- /dev/null +++ b/spacetime-posthog-ts/example/public/styles.css @@ -0,0 +1,1159 @@ +:root { + color-scheme: dark; + + --bg: #0a1013; + --panel-top: #14201f; + --panel-bot: #0f1819; + --inset: #0c1517; + --line: #25393b; + --line-soft: #1a2829; + + --text: #edf4f1; + --muted: #97aab0; + --faint: #65797f; + + --amber: #f6b94e; + --amber-deep: #e29f2c; + --teal: #3ac9c4; + --green: #5fd08a; + --red: #f37e76; + + --radius: 13px; + --radius-sm: 9px; + --shadow: 0 18px 40px -28px rgba(0, 0, 0, 0.9); + --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient( + 1100px 480px at 78% -8%, + rgba(246, 185, 78, 0.08), + transparent 60% + ), + radial-gradient( + 900px 520px at 8% 0%, + rgba(58, 201, 196, 0.06), + transparent 55% + ), + var(--bg); + background-attachment: fixed; + color: var(--text); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + -webkit-font-smoothing: antialiased; + font-size: 14px; +} + +button, +input, +select { + font: inherit; + color: var(--text); +} + +button { + height: 36px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: linear-gradient(180deg, #18272a, #122023); + cursor: pointer; + font-weight: 600; + transition: + border-color 0.14s ease, + background 0.14s ease, + transform 0.08s ease; +} +button:hover { + border-color: #36545a; +} +button:active { + transform: translateY(1px); +} +button.primary { + background: linear-gradient(180deg, var(--amber), var(--amber-deep)); + color: #1d1503; + border-color: transparent; + font-weight: 700; +} +button.primary:hover { + box-shadow: 0 0 0 1px rgba(246, 185, 78, 0.4); +} +button.running { + background: linear-gradient(180deg, #1d3331, #16292a); + color: var(--text); + border-color: #2f5a52; +} +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--teal); + outline-offset: 1px; +} + +input, +select { + width: 100%; + height: 38px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--inset); + padding: 0 11px; +} + +h1, +h2, +h3, +p { + margin: 0; +} +h1 { + font-size: 19px; + letter-spacing: -0.01em; +} +h2 { + font-size: 14px; + font-weight: 650; +} +h3 { + font-size: 14px; +} +p, +small { + color: var(--muted); + font-size: 12.5px; + line-height: 1.5; +} + +label { + display: block; + color: var(--muted); + font: 600 10.5px/1.2 var(--mono); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.app { + max-width: 1320px; + margin: 0 auto; + padding: 18px 22px 30px; +} + +/* ---- header ---- */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 13px 16px; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + background: linear-gradient(180deg, var(--panel-top), var(--panel-bot)); + box-shadow: var(--shadow); +} +.brand { + display: flex; + align-items: center; + gap: 13px; + min-width: 0; +} +.brand-mark { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 12px; + font-size: 21px; + line-height: 1; + background: radial-gradient( + 120% 120% at 30% 20%, + rgba(246, 185, 78, 0.22), + rgba(58, 201, 196, 0.1) 70%, + transparent + ); + border: 1px solid var(--line); +} +.brand-text h1 { + margin: 0; + font-size: 18px; + display: block; + letter-spacing: -0.01em; +} + +.page-foot { + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--line-soft); +} +.page-foot .by { + font: 600 11px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.12em; +} +.page-foot img { + height: 28px; + width: auto; + opacity: 0.9; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} +.ph-link { + display: inline-flex; + align-items: center; + gap: 7px; + height: 36px; + padding: 0 14px; + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + color: var(--teal); + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: + border-color 0.14s ease, + background 0.14s ease; +} +.ph-link:hover { + border-color: var(--teal); + background: rgba(58, 201, 196, 0.08); +} +.transport { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 7px; + border: 1px solid var(--line-soft); + border-radius: 11px; + background: var(--inset); +} +.transport button { + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.run-btn { + min-width: 88px; + gap: 7px; +} +.run-btn .run-ico { + display: inline-flex; +} +.run-btn .i-pause { + display: none; +} +.run-btn.running .i-play { + display: none; +} +.run-btn.running .i-pause { + display: inline; +} +.t-div { + width: 1px; + height: 20px; + background: var(--line-soft); +} +.speed-field { + display: inline-flex; + align-items: center; + gap: 7px; + margin: 0; +} +.speed-field > span { + font: 600 9.5px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.speed-field select { + width: 92px; + height: 30px; + border-color: var(--line-soft); +} +.transport .ghost { + color: var(--muted); +} +.transport .ghost:hover { + border-color: var(--red); + color: var(--red); +} + +/* ---- the stand (scene) ---- */ +.stage { + position: relative; + margin-top: 16px; + display: grid; + grid-template-columns: 256px minmax(0, 1fr); + gap: 20px 24px; + align-items: start; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + background: + radial-gradient( + 820px 280px at 50% -30%, + rgba(246, 185, 78, 0.13), + transparent 70% + ), + radial-gradient( + 640px 260px at 10% 130%, + rgba(58, 201, 196, 0.08), + transparent 60% + ), + linear-gradient(180deg, #16231f 0%, #101a1b 54%, #0c1517 100%); + box-shadow: var(--shadow); + padding: 22px; +} + +/* wallet HUD */ +.wallet { + grid-column: 1; +} +.wallet-label { + font: 700 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.12em; +} +.wallet-cash { + display: block; + font-size: 42px; + font-weight: 800; + letter-spacing: -0.025em; + color: var(--green); + margin: 7px 0 5px; + line-height: 1; + font-variant-numeric: tabular-nums; +} +.wallet-profit { + font: 700 12px/1 var(--mono); + color: var(--muted); +} +.wallet-profit.up { + color: var(--green); +} +.wallet-profit.down { + color: var(--red); +} +.wallet-break { + display: flex; + gap: 14px; + margin-top: 9px; + font: 600 10.5px/1 var(--mono); + color: var(--faint); +} +.wallet-break b { + font-variant-numeric: tabular-nums; +} +.wallet-break .up b { + color: var(--green); +} +.wallet-break .down b { + color: var(--amber); +} +.rep { + display: flex; + align-items: baseline; + gap: 8px; + margin-top: 14px; +} +.rep-stars { + font-size: 15px; + letter-spacing: 1px; + color: var(--amber); +} +.rep-meta { + font: 600 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.rep-meta b { + color: var(--text); +} +.upgrades { + display: grid; + gap: 7px; + margin-top: 14px; +} +.upgrade { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + padding: 8px 10px; +} +.up-name { + display: block; + font: 700 9px/1 var(--mono); + color: var(--amber); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.up-state { + display: block; + font-size: 12px; + color: var(--muted); + margin-top: 3px; +} +.upgrade button { + height: 28px; + padding: 0 10px; + font: 700 11px/1 var(--mono); + flex: none; +} +.upgrade button:disabled { + opacity: 0.4; + cursor: not-allowed; + border-color: var(--line-soft); +} + +/* menu board */ +.board { + grid-column: 2; + min-width: 0; +} +.board-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 11px; +} +.board-head h2 { + font-size: 15px; +} +.board-hint { + font: 600 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* counter scene */ +.counter-scene { + grid-column: 1 / -1; +} +.rush-banner { + display: none; + margin-bottom: 8px; + padding: 7px 12px; + border: 1px solid var(--amber-deep); + border-radius: var(--radius-sm); + background: linear-gradient( + 90deg, + rgba(246, 185, 78, 0.18), + rgba(246, 185, 78, 0.05) + ); + color: var(--amber); + font: 700 11px/1 var(--mono); + text-transform: uppercase; + letter-spacing: 0.08em; + animation: rushPulse 1.6s ease-in-out infinite; +} +.rush-banner.on { + display: block; +} +@keyframes rushPulse { + 0%, + 100% { + opacity: 0.85; + } + 50% { + opacity: 1; + box-shadow: 0 0 0 1px rgba(246, 185, 78, 0.35); + } +} +.counter-top { + position: relative; + padding: 13px 14px; + border: 1px solid var(--line); + border-bottom: none; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + background: linear-gradient(180deg, #1d2d2d, #152323); +} +.counter-top::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 4px; + background: linear-gradient( + 180deg, + rgba(246, 185, 78, 0.4), + rgba(246, 185, 78, 0.04) + ); +} +.dispensers { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +.tank { + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + padding: 10px 12px; +} +.tank-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.tank-name { + font: 700 9.5px/1 var(--mono); + color: var(--amber); + text-transform: uppercase; + letter-spacing: 0.1em; +} +.tank-amt { + display: inline-flex; + align-items: baseline; + gap: 1px; +} +.tank-units { + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.tank-units.low { + color: var(--red); +} +.tank-cap { + font: 600 11px/1 var(--mono); + color: var(--faint); +} +.tank-track { + height: 7px; + margin: 9px 0; + border-radius: 999px; + background: #0a1416; + border: 1px solid var(--line-soft); + overflow: hidden; +} +.tank-fill { + height: 100%; + width: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--teal), var(--green)); + transition: width 0.35s ease; +} +.tank-fill.low { + background: linear-gradient(90deg, #b8554f, var(--red)); +} +.tank-row2 { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.tank-sub { + font-size: 10.5px; + color: var(--faint); +} +.tank button { + height: 28px; + padding: 0 10px; + font: 700 11px/1 var(--mono); + flex: none; +} +.tank button:disabled { + opacity: 0.4; + cursor: not-allowed; + border-color: var(--line-soft); +} + +.floor { + position: relative; + padding: 22px 14px 12px; + min-height: 124px; + border: 1px solid var(--line); + border-top: none; + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + background: + radial-gradient( + 130% 150% at 50% 130%, + rgba(58, 201, 196, 0.07), + transparent 60% + ), + linear-gradient(180deg, #0e1719, #0a1213); +} + +/* ticker */ +.ticker { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + margin-top: 2px; + padding-top: 14px; + border-top: 1px solid var(--line-soft); + font: 600 11px/1 var(--mono); + color: var(--faint); +} +.ticker > span { + display: inline-flex; + align-items: baseline; + gap: 5px; +} +.ticker b { + color: var(--text); + font-variant-numeric: tabular-nums; +} +.ticker .warn b { + color: var(--red); +} +.ticker .ph b { + color: var(--green); +} + +.funnel { + display: flex; + gap: 16px; + flex-wrap: wrap; +} +.f { + display: inline-flex; + align-items: baseline; + gap: 5px; + font: 600 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.f b { + font-size: 16px; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.f.buy b { + color: var(--green); +} +.f.off b { + color: var(--red); +} + +.block-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; +} +.block-head p { + margin-top: 3px; +} + +/* ---- the counter (gamified bot line) ---- */ +.counter { + position: relative; +} +.counter .bots { + display: flex; + gap: 6px; + align-items: flex-end; + min-height: 96px; + overflow: hidden; +} +.counter .pops { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} +.bot { + position: relative; + width: 74px; + display: grid; + justify-items: center; + gap: 7px; + padding: 6px 0; + cursor: default; +} +.bot::before { + content: ''; + position: absolute; + bottom: 26px; + width: 50px; + height: 50px; + border-radius: 50%; + background: radial-gradient(circle, var(--stage, #5f7882), transparent 68%); + opacity: 0.3; + z-index: 0; +} +.bot-emoji { + font-size: 30px; + line-height: 1; + z-index: 1; + animation: bob 3.4s ease-in-out infinite; +} +.bot-profile { + font: 600 10px/1 var(--mono); + color: var(--muted); + text-transform: capitalize; + z-index: 1; +} +.bot-wants { + font: 600 8.5px/1.1 var(--mono); + color: var(--faint); + text-align: center; + z-index: 1; + max-width: 72px; +} +.bot.waiting { + --stage: #6f8790; +} +.bot.thrifty { + --stage: var(--amber); +} +.bot.thrifty .bot-wants { + color: var(--amber); +} +.bot.viewed { + --stage: #6f8790; +} +.bot.cart { + --stage: var(--teal); +} +.bot.checkout { + --stage: var(--amber); +} +.bot.purchased { + --stage: var(--green); +} +.bot.purchased .bot-emoji { + animation: pop 0.5s ease; +} +.bot.abandoned { + --stage: var(--red); + opacity: 0.6; +} +.bot.abandoned .bot-emoji { + transform: rotate(-10deg); +} +.bot-tip { + position: absolute; + bottom: calc(100% - 4px); + left: 50%; + transform: translate(-50%, 4px); + width: max-content; + max-width: 200px; + background: #0a1417; + border: 1px solid var(--line); + border-radius: 8px; + padding: 7px 10px; + font: 600 11px/1.4 var(--mono); + color: var(--text); + text-align: center; + opacity: 0; + pointer-events: none; + transition: + opacity 0.12s ease, + transform 0.12s ease; + z-index: 5; + box-shadow: 0 12px 26px -14px rgba(0, 0, 0, 0.95); +} +.bot-tip b { + color: var(--text); +} +.bot:hover { + z-index: 6; +} +.bot:hover .bot-tip { + opacity: 1; + transform: translate(-50%, 0); +} +.pop { + position: absolute; + font: 800 15px/1 var(--mono); + text-shadow: 0 2px 6px rgba(0, 0, 0, 0.7); + animation: floatUp 1.3s ease forwards; + white-space: nowrap; +} +.pop.sale { + color: var(--green); +} +.pop.miss { + color: var(--red); + font-size: 12px; + font-weight: 700; +} +.bot.entering { + animation: walkIn 0.45s ease both; +} +.bot.leaving { + animation: walkOut 0.45s ease both; +} +@keyframes walkIn { + from { + opacity: 0; + transform: translateX(46px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +@keyframes walkOut { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(-34px); + } +} +@keyframes bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-3px); + } +} +@keyframes pop { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.35); + } + 100% { + transform: scale(1); + } +} +@keyframes floatUp { + 0% { + opacity: 0; + transform: translate(-50%, 8px) scale(0.9); + } + 18% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translate(-50%, -50px) scale(1.05); + } +} + +.menu-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(186px, 1fr)); + gap: 10px; +} +.menu-card { + display: grid; + gap: 4px; + text-align: left; + align-content: start; + height: auto; + min-height: 96px; + padding: 12px 13px; + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + cursor: pointer; +} +.menu-card:hover { + transform: translateY(-2px); + border-color: var(--teal); +} +.menu-card.active { + border-color: var(--amber); + box-shadow: 0 0 0 1px var(--amber); +} +.menu-card.muted { + opacity: 0.5; +} +.menu-card-cat { + font: 700 9.5px/1 var(--mono); + color: var(--amber); + text-transform: uppercase; + letter-spacing: 0.1em; +} +.menu-card-name { + font-size: 14.5px; + font-weight: 650; + color: var(--text); + line-height: 1.25; +} +.menu-card-flavor { + font-size: 12px; + color: var(--faint); + text-transform: capitalize; +} +.menu-card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 8px; +} +.menu-card-price { + font: 700 15px/1 var(--mono); + color: var(--text); +} +.menu-card-badges { + display: flex; + gap: 5px; + flex-wrap: wrap; + justify-content: flex-end; +} +.chip { + font: 700 9px/1 var(--mono); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 4px 7px; + border-radius: 999px; + border: 1px solid currentColor; +} +.chip.featured { + color: var(--amber); +} +.chip.discount { + color: var(--green); +} +.chip.off { + color: var(--faint); +} + +/* ---- drawer ---- */ +.scrim { + position: fixed; + inset: 0; + background: rgba(4, 8, 9, 0.55); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; + z-index: 40; +} +.scrim.open { + opacity: 1; + pointer-events: auto; +} +.drawer { + position: fixed; + top: 0; + right: 0; + height: 100%; + width: 392px; + max-width: 92vw; + transform: translateX(102%); + transition: transform 0.2s ease; + z-index: 50; + background: linear-gradient(180deg, var(--panel-top), var(--panel-bot)); + border-left: 1px solid var(--line); + box-shadow: -24px 0 50px -30px rgba(0, 0, 0, 0.9); + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 20px; +} +.drawer.open { + transform: translateX(0); +} +.drawer-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} +.drawer-close { + height: 30px; + width: 30px; + padding: 0; + border-radius: 8px; + flex: none; + display: grid; + place-items: center; + font-size: 16px; + color: var(--muted); +} +#drawerSub { + font: 600 11px/1.2 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 5px; +} +.drawer h3 { + font-size: 18px; +} + +.recipe-grid { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 9px 12px; + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + padding: 14px; + margin-top: 16px; + font-size: 12.5px; +} +.recipe-grid span { + color: var(--faint); + font: 600 10.5px/1.3 var(--mono); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.recipe-grid strong { + overflow-wrap: anywhere; + font-weight: 600; +} +.recipe-grid strong.margin.pos { + color: var(--green); +} +.recipe-grid strong.margin.neg { + color: var(--red); +} +.recipe-head { + margin: 16px 0 -8px; + font: 700 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.field { + display: grid; + gap: 7px; + margin-top: 15px; +} +.inline { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} +.drawer-actions { + display: grid; + gap: 8px; + margin-top: 18px; +} +.drawer-actions .row2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +#featureVariant.is-on { + background: linear-gradient(180deg, var(--amber), var(--amber-deep)); + color: #1d1503; + border-color: transparent; +} + +.empty { + border: 1px dashed var(--line-soft); + border-radius: var(--radius-sm); + color: var(--faint); + padding: 24px; + text-align: center; + font-size: 12.5px; +} + +.toast { + position: fixed; + left: 50%; + bottom: 22px; + z-index: 60; + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 9px 16px; + color: var(--muted); + background: rgba(12, 21, 23, 0.94); + font: 600 12px/1.3 var(--mono); + box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.95); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 14px); + transition: + opacity 0.2s ease, + transform 0.2s ease; +} +.toast.show { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, 0); +} +.toast::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex: none; +} +.toast.ok { + color: var(--green); +} +.toast.error { + border-color: #6e3331; + color: #ffb0ab; + background: rgba(32, 16, 16, 0.96); +} + +@media (max-width: 820px) { + .stage { + grid-template-columns: 1fr; + } + .wallet { + grid-column: 1; + } + .board { + grid-column: 1; + } +} +@media (max-width: 560px) { + .app { + padding: 12px; + } + .topbar { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + .transport { + justify-content: space-between; + flex-wrap: wrap; + } + .dispensers { + grid-template-columns: 1fr; + } + .menu-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/spacetime-posthog-ts/example/scripts/test-economy.ts b/spacetime-posthog-ts/example/scripts/test-economy.ts new file mode 100644 index 00000000000..f73a88b1b92 --- /dev/null +++ b/spacetime-posthog-ts/example/scripts/test-economy.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; + +import { + arrivalDemand, + maximumQueueLength, + seededRandom, + serviceCapacity, + storageCapacity, + upgradeCost, +} from '../spacetimedb/src/economy'; +import type { EconRow } from '../spacetimedb/src/schema'; + +const economy = { + workers: 2, + machineLevel: 1, + storageLevel: 2, + seats: 3, +} as EconRow; + +assert.equal(storageCapacity('context', 0), 250); +assert.equal(storageCapacity('context', 2), 500); +assert.equal(serviceCapacity(economy), 2); +assert.equal(maximumQueueLength(economy), 12); +assert.equal(arrivalDemand(10, 50), 10); +assert.equal(upgradeCost('worker', economy), 12_000n); +assert.equal(upgradeCost('machine', economy), 16_000n); +assert.equal(upgradeCost('storage', economy), 21_000n); +assert.equal(upgradeCost('counter', economy), 20_000n); + +const first = seededRandom('stable-seed'); +const second = seededRandom('stable-seed'); +for (let index = 0; index < 10; index++) { + const value = first(); + assert.equal(value, second()); + assert.ok(value >= 0 && value < 1); +} + +console.log('posthog economy tests passed'); diff --git a/spacetime-posthog-ts/example/server.ts b/spacetime-posthog-ts/example/server.ts new file mode 100644 index 00000000000..a820343fdd0 --- /dev/null +++ b/spacetime-posthog-ts/example/server.ts @@ -0,0 +1,277 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { DbConnection, type ErrorContext } from './src/codegen'; +import { PRODUCTS, SCENARIOS } from './catalog/catalog'; +import { + discardStoredServerToken, + grantServerIdentity, + loadServerToken, + saveServerToken, +} from '../../tools/example-server-identity'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8796', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const STDB_DB = process.env.STDB_DATABASE ?? 'spacetime-posthog-example'; +const POSTHOG_HOST = process.env.POSTHOG_HOST ?? 'https://us.i.posthog.com'; +const POSTHOG_PROJECT_API_KEY = process.env.POSTHOG_PROJECT_API_KEY ?? ''; +const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime'; +const SERVER_TOKEN_PATH = path.resolve(__dirname, '.stdb-server-token'); + +let stdb: DbConnection | null = null; +let flushTimer: ReturnType | undefined; +let flushDueAt = 0; +let flushing = false; + +type ConnectedServer = { + connection: DbConnection; + identity: string; +}; + +function connectAttempt(token: string | undefined): Promise { + return new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(STDB_URI) + .withDatabaseName(STDB_DB) + .onConnect((connection, identity, nextToken) => { + if (!process.env.STDB_SERVER_TOKEN?.trim()) { + saveServerToken(SERVER_TOKEN_PATH, nextToken); + } + resolve({ connection, identity: identity.toHexString() }); + }) + .onDisconnect((_ctx, err) => { + console.error( + `[stdb] disconnected: ${err?.message ?? 'unknown'} - exiting for supervisor restart` + ); + process.exit(1); + }) + .onConnectError((_ctx: ErrorContext, err) => reject(err)); + if (token) builder = builder.withToken(token); + builder.build(); + }); +} + +async function connectStdb(): Promise { + const stored = loadServerToken( + SERVER_TOKEN_PATH, + process.env.STDB_SERVER_TOKEN + ); + try { + return await connectAttempt(stored.token); + } catch (error) { + if (stored.source !== 'file') throw error; + discardStoredServerToken(SERVER_TOKEN_PATH); + console.warn( + '[stdb] stored server token was rejected; creating a new identity' + ); + return connectAttempt(undefined); + } +} + +function callSpacetime(procedureName: string, ...args: unknown[]): void { + const result = spawnSync( + SPACETIME_BIN, + [ + 'call', + '--server', + STDB_HTTP, + STDB_DB, + procedureName, + ...args.map(arg => JSON.stringify(arg)), + ], + { encoding: 'utf8', shell: false } + ); + if (result.status !== 0) { + throw new Error( + result.stderr.trim() || + result.stdout.trim() || + `spacetime exited ${result.status}` + ); + } +} + +function configurePostHogFromEnv(): void { + if (!POSTHOG_PROJECT_API_KEY) { + throw new Error("POSTHOG_PROJECT_API_KEY not set in this server's .env."); + } + callSpacetime( + 'posthog.set_posthog_config', + POSTHOG_HOST, + POSTHOG_PROJECT_API_KEY + ); +} + +function syncCatalog(): void { + callSpacetime( + 'sync_catalog', + JSON.stringify(PRODUCTS), + JSON.stringify(SCENARIOS) + ); +} + +function scheduleAnalyticsFlush(): void { + if (!stdb || flushing) return; + let nextAttemptMs = Number.POSITIVE_INFINITY; + for (const row of stdb.db.posthogOutboxAdmin.iter()) { + const value = + row.status.tag === 'Queued' + ? Number(row.nextAttemptAt.microsSinceUnixEpoch / 1000n) + : row.status.tag === 'Processing' + ? Number(row.claimExpiresAtMicros / 1000n) + : Number.POSITIVE_INFINITY; + if (value < nextAttemptMs) nextAttemptMs = value; + } + if (!Number.isFinite(nextAttemptMs)) return; + const delay = Math.max(0, nextAttemptMs - Date.now()); + const dueAt = Date.now() + delay; + if (flushTimer && dueAt >= flushDueAt - 5) return; + if (flushTimer) clearTimeout(flushTimer); + flushDueAt = dueAt; + flushTimer = setTimeout(() => { + flushTimer = undefined; + flushDueAt = 0; + void flushAnalytics(); + }, delay); +} + +async function flushAnalytics(): Promise { + if (!stdb || flushing) return; + flushing = true; + try { + await stdb.procedures.flushAnalytics({ limit: 50 }); + } catch (error) { + console.error( + `[posthog] delivery failed: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + flushing = false; + scheduleAnalyticsFlush(); + } +} + +function startAnalyticsDelivery(connection: DbConnection): void { + connection.db.posthogOutboxAdmin.onInsert(scheduleAnalyticsFlush); + connection.db.posthogOutboxAdmin.onUpdate(scheduleAnalyticsFlush); + connection + .subscriptionBuilder() + .onApplied(scheduleAnalyticsFlush) + .onError(ctx => + console.error(`[posthog] outbox subscription failed: ${ctx.event}`) + ) + .subscribe(['SELECT * FROM posthog_outbox_admin']); +} + +// Derive the PostHog app (dashboard) URL from the ingestion host, e.g. +// https://us.i.posthog.com -> https://us.posthog.com. Self-hosted hosts are +// already the app host, so they pass through unchanged. +function posthogAppUrl(): string { + try { + const u = new URL(POSTHOG_HOST); + const host = u.hostname.endsWith('.i.posthog.com') + ? u.hostname.replace('.i.posthog.com', '.posthog.com') + : u.hostname; + return `${u.protocol}//${host}${u.port ? `:${u.port}` : ''}`; + } catch { + return 'https://us.posthog.com'; + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, database: STDB_DB }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + stdbUri: STDB_URI, + database: STDB_DB, + posthogAppUrl: POSTHOG_PROJECT_API_KEY ? posthogAppUrl() : null, + }); +}); + +(async () => { + console.log(`[stdb] connecting to ${STDB_URI}/${STDB_DB} ...`); + try { + const connected = await connectStdb(); + stdb = connected.connection; + grantServerIdentity({ + spacetimeBin: SPACETIME_BIN, + server: STDB_HTTP, + database: STDB_DB, + procedure: 'posthog.add_admin_identity', + identity: connected.identity, + }); + console.log(`[stdb] connected as authorized server ${connected.identity}`); + } catch (err) { + console.error( + `[stdb] connection failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[stdb] is the SpacetimeDB host running and the module published?' + ); + process.exit(1); + } + + try { + syncCatalog(); + console.log('[catalog] Context Cafe catalog synced'); + } catch (err) { + console.warn( + `[catalog] sync failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + + if (POSTHOG_PROJECT_API_KEY) { + try { + configurePostHogFromEnv(); + console.log('[posthog] config loaded from .env'); + } catch (err) { + console.warn( + `[posthog] automatic config failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + startAnalyticsDelivery(stdb); + + app.listen(PORT, HOST, () => { + process.stdout.write( + `\nspacetime-posthog-example listening on http://${HOST}:${PORT}\n` + ); + if (!POSTHOG_PROJECT_API_KEY) { + process.stdout.write( + ' ! POSTHOG_PROJECT_API_KEY not set - configure PostHog in .env and restart\n' + ); + } + process.stdout.write(` spacetime: ${SPACETIME_BIN}\n`); + process.stdout.write(` database: ${STDB_URI}/${STDB_DB}\n\n`); + }); +})(); diff --git a/spacetime-posthog-ts/example/spacetimedb/package.json b/spacetime-posthog-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..f755927b02c --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-posthog-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-posthog-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-posthog-example" + }, + "dependencies": { + "@spacetimedb/posthog": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-posthog-ts/example/spacetimedb/src/catalog.ts b/spacetime-posthog-ts/example/spacetimedb/src/catalog.ts new file mode 100644 index 00000000000..dbfc42651dc --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/catalog.ts @@ -0,0 +1,149 @@ +import { t } from 'spacetimedb/server'; + +import { + MAX_SYNC_ROWS, + spacetimedb, + type ProductInput, + type ScenarioInput, + type VariantInput, +} from './schema'; +import { clampU32, fail, requireId } from './validation'; + +function parseArray(json: string, field: string): T[] { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + fail(`invalid_${field}_json`); + } + if (!Array.isArray(parsed)) fail(`invalid_${field}_json`); + if (parsed.length > MAX_SYNC_ROWS) fail(`${field}_too_large`); + return parsed as T[]; +} + +export const sync_catalog = spacetimedb.reducer( + { productsJson: t.string(), scenariosJson: t.string() }, + (ctx, args) => { + const products = parseArray( + args.productsJson, + 'products' + ); + const scenarios = parseArray( + args.scenariosJson, + 'scenarios' + ); + + const keepProducts = new Set(); + const keepVariants = new Set(); + for (const item of products) { + const productId = requireId(item.productId, 'product_id'); + keepProducts.add(productId); + const row = { + productId, + name: requireId(item.name, 'product_name'), + category: requireId(item.category, 'product_category'), + description: + typeof item.description === 'string' ? item.description : '', + baseAppeal: clampU32(item.baseAppeal, 'base_appeal', 1, 100), + active: item.active ?? true, + }; + const existing = ctx.db.productTemplate.productId.find(productId); + if (existing) { + ctx.db.productTemplate.productId.update({ ...existing, ...row }); + } else { + ctx.db.productTemplate.insert(row); + } + + for (const rawVariant of item.variants ?? []) { + const variantId = requireId(rawVariant.variantId, 'variant_id'); + keepVariants.add(variantId); + const variantRow = { + variantId, + productId, + name: requireId(rawVariant.name, 'variant_name'), + flavor: requireId(rawVariant.flavor, 'flavor'), + contextTokens: clampU32( + rawVariant.contextTokens, + 'context_tokens', + 1_000, + 2_000_000 + ), + reasoning: clampU32(rawVariant.reasoning, 'reasoning', 1, 10), + latency: clampU32(rawVariant.latency, 'latency', 1, 10), + priceCents: clampU32( + rawVariant.priceCents, + 'price_cents', + 0, + 250_000 + ), + discountBps: clampU32( + rawVariant.discountBps ?? 0, + 'discount_bps', + 0, + 9000 + ), + active: rawVariant.active ?? true, + featured: rawVariant.featured ?? false, + }; + const existingVariant = + ctx.db.variantTemplate.variantId.find(variantId); + if (existingVariant) { + ctx.db.variantTemplate.variantId.update({ + ...existingVariant, + ...variantRow, + }); + } else { + ctx.db.variantTemplate.insert(variantRow); + } + } + } + + for (const row of [...ctx.db.variantTemplate.iter()]) { + if (!keepVariants.has(row.variantId)) ctx.db.variantTemplate.delete(row); + } + for (const row of [...ctx.db.productTemplate.iter()]) { + if (!keepProducts.has(row.productId)) ctx.db.productTemplate.delete(row); + } + + for (const rawScenario of scenarios) { + const scenarioId = requireId(rawScenario.scenarioId, 'scenario_id'); + const row = { + scenarioId, + name: requireId(rawScenario.name, 'scenario_name'), + description: + typeof rawScenario.description === 'string' + ? rawScenario.description + : '', + trafficPerTick: clampU32( + rawScenario.trafficPerTick, + 'traffic_per_tick', + 1, + 30 + ), + priceSensitivity: clampU32( + rawScenario.priceSensitivity, + 'price_sensitivity', + 1, + 100 + ), + rushBias: clampU32(rawScenario.rushBias, 'rush_bias', 1, 100), + researchBias: clampU32( + rawScenario.researchBias, + 'research_bias', + 1, + 100 + ), + visualBias: clampU32(rawScenario.visualBias, 'visual_bias', 1, 100), + memoryBias: clampU32(rawScenario.memoryBias, 'memory_bias', 1, 100), + premiumBias: clampU32(rawScenario.premiumBias, 'premium_bias', 1, 100), + volatility: clampU32(rawScenario.volatility, 'volatility', 0, 100), + }; + const existing = ctx.db.scenario.scenarioId.find(scenarioId); + if (existing) { + ctx.db.scenario.scenarioId.update({ ...existing, ...row }); + } else { + ctx.db.scenario.insert(row); + } + } + } +); diff --git a/spacetime-posthog-ts/example/spacetimedb/src/economy.ts b/spacetime-posthog-ts/example/spacetimedb/src/economy.ts new file mode 100644 index 00000000000..2d0c80ee3f3 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/economy.ts @@ -0,0 +1,246 @@ +import type { + EconRow, + ProductRow, + ScenarioRow, + VariantRow, + WriteCtx, +} from './schema'; + +export const SUPPLY_PRICE = { + compute: 50, + context: 20, + memory: 40, +} as const; +export type SupplyKind = keyof typeof SUPPLY_PRICE; + +export const START_CASH = 20_000n; +export const START_INVENTORY = { + compute: 120, + context: 200, + memory: 60, +}; +export const START_REPUTATION = 50; +export const MAX_MACHINE_LEVEL = 5; +export const PATIENCE_TICKS = 3; +export const RUSH_CYCLE_TICKS = 45; +export const RUSH_LENGTH_TICKS = 12; +export const RUSH_MULTIPLIER = 2.3; +export const REPUTATION_ON_SALE = 1; +export const REPUTATION_ON_STOCKOUT = 2; +export const REPUTATION_ON_RENEGE = 3; + +type ServiceEconomy = Pick; +type QueueEconomy = Pick; +type UpgradeEconomy = Pick< + EconRow, + 'workers' | 'machineLevel' | 'storageLevel' | 'seats' +>; +type SupplyProduct = Pick; +type SupplyVariant = Pick; + +const COUNTER_BASE = 6; +const UPGRADE_PRICE = { + worker: 6_000, + machine: 8_000, + counter: 5_000, + storage: 7_000, +}; +const STORAGE_BASE = { compute: 150, context: 250, memory: 100 }; + +export function storageCapacity(kind: SupplyKind, level: number): number { + return Math.round(STORAGE_BASE[kind] * (1 + 0.5 * level)); +} + +export function clampReputation(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +export function serviceCapacity(economy: ServiceEconomy): number { + return Math.max(1, economy.workers); +} + +export function maximumQueueLength(economy: QueueEconomy): number { + return COUNTER_BASE + economy.seats * 2; +} + +export function arrivalDemand(baseTraffic: number, reputation: number): number { + return Math.max(1, Math.round(baseTraffic * (0.5 + reputation / 100))); +} + +export function upgradeCost(kind: string, economy: UpgradeEconomy): bigint { + if (kind === 'worker') return BigInt(UPGRADE_PRICE.worker * economy.workers); + if (kind === 'machine') + return BigInt(UPGRADE_PRICE.machine * (economy.machineLevel + 1)); + if (kind === 'storage') + return BigInt(UPGRADE_PRICE.storage * (economy.storageLevel + 1)); + return BigInt(UPGRADE_PRICE.counter * (economy.seats + 1)); +} + +export function ensureEconomy(ctx: WriteCtx, owner: string): EconRow { + const existing = ctx.db.econ.owner.find(owner); + if (existing) return existing; + const row = { + owner, + cashCents: START_CASH, + computeUnits: START_INVENTORY.compute, + contextUnits: START_INVENTORY.context, + memoryUnits: START_INVENTORY.memory, + suppliesSpentCents: 0n, + stockouts: 0, + reputation: START_REPUTATION, + workers: 1, + machineLevel: 0, + seats: 0, + storageLevel: 0, + reneged: 0, + updatedAt: ctx.timestamp, + }; + ctx.db.econ.insert(row); + return row; +} + +export function supplyCost( + product: SupplyProduct, + variant: SupplyVariant, + machineLevel = 0 +): { compute: number; context: number; memory: number } { + const efficiency = Math.max(0.6, 1 - 0.08 * machineLevel); + return { + context: Math.max( + 1, + Math.round(Math.ceil(variant.contextTokens / 20_000) * efficiency) + ), + compute: Math.max( + 1, + Math.round((1 + Math.ceil(variant.reasoning / 3)) * efficiency) + ), + memory: Math.max( + 1, + Math.round((product.category === 'memory' ? 6 : 1) * efficiency) + ), + }; +} + +function hashSeed(input: string): number { + let hash = 2_166_136_261; + for (let index = 0; index < input.length; index++) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return hash >>> 0; +} + +export function seededRandom(seed: string): () => number { + let value = hashSeed(seed) || 1; + return () => { + value ^= value << 13; + value ^= value >>> 17; + value ^= value << 5; + return ((value >>> 0) % 10_000) / 10_000; + }; +} + +export function chooseProfile( + random: () => number, + scenario: ScenarioRow +): string { + const weighted = [ + ['cheap', Math.max(10, scenario.priceSensitivity)], + ['rushed', Math.max(10, scenario.rushBias)], + ['research', Math.max(10, scenario.researchBias)], + ['visual', Math.max(10, scenario.visualBias)], + ['memory', Math.max(10, scenario.memoryBias)], + ['premium', Math.max(10, scenario.premiumBias)], + ] as const; + const total = weighted.reduce((sum, row) => sum + row[1], 0); + let selection = random() * total; + for (const [profile, weight] of weighted) { + selection -= weight; + if (selection <= 0) return profile; + } + return 'cheap'; +} + +export function variantScore( + product: ProductRow, + variant: VariantRow, + scenario: ScenarioRow, + profile: string +): number { + let score = product.baseAppeal; + score += variant.featured ? 24 : 0; + score += Math.floor(variant.discountBps / 250); + + if (profile === 'cheap') + score += Math.max(0, 55 - Math.floor(variant.priceCents / 120)); + if (profile === 'rushed') score += variant.latency * 12; + if (profile === 'research') + score += Math.floor(variant.contextTokens / 6_000) + variant.reasoning * 7; + if (profile === 'visual' && product.category === 'multimodal') score += 44; + if (profile === 'memory' && product.category === 'memory') score += 44; + if (profile === 'premium') + score += variant.reasoning * 8 + variant.latency * 5; + + const pricePenalty = Math.floor( + (variant.priceCents * scenario.priceSensitivity) / 16_000 + ); + return Math.max(1, score - pricePenalty); +} + +export function purchaseProbability( + selected: { productRow: ProductRow; variantRow: VariantRow; score: number }, + scenario: ScenarioRow, + profile: string +): number { + let probability = 18 + Math.floor(selected.score / 4); + if (selected.variantRow.featured) probability += 8; + if (selected.variantRow.discountBps > 0) + probability += Math.floor(selected.variantRow.discountBps / 200); + if (profile === 'cheap') + probability -= Math.floor(selected.variantRow.priceCents / 250); + if (profile === 'premium') probability += 10; + probability -= Math.floor(scenario.volatility / 5); + return Math.max(5, Math.min(92, probability)); +} + +export function pricePaid(variant: VariantRow): number { + return Math.max( + 0, + Math.floor((variant.priceCents * (10_000 - variant.discountBps)) / 10_000) + ); +} + +export function nonSaleReason( + product: ProductRow, + variant: VariantRow, + scenario: ScenarioRow, + profile: string, + inStock: boolean, + shortage: string +): string { + if (!inStock) return `short_${shortage}`; + + const price = + Math.floor((variant.priceCents * scenario.priceSensitivity) / 16_000) + + (profile === 'cheap' ? Math.floor(variant.priceCents / 250) : 0); + const speedWeight = + profile === 'rushed' ? 8 : scenario.rushBias >= 55 ? 4 : 0; + const speed = (10 - variant.latency) * speedWeight; + + let preference = ''; + if (profile === 'visual' && product.category !== 'multimodal') + preference = 'want_vision'; + else if (profile === 'memory' && product.category !== 'memory') + preference = 'want_memory'; + else if (profile === 'research' && variant.reasoning < 6) + preference = 'want_smart'; + else if (profile === 'premium' && variant.reasoning < 6) + preference = 'want_premium'; + + const preferenceWeight = preference ? 30 : 0; + const dominant = Math.max(price, speed, preferenceWeight); + if (dominant < 8) return 'meh'; + if (dominant === preferenceWeight) return preference; + if (dominant === speed) return 'slow'; + return 'price'; +} diff --git a/spacetime-posthog-ts/example/spacetimedb/src/index.ts b/spacetime-posthog-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..c5fbc321881 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,1006 @@ +import { t } from 'spacetimedb/server'; +import * as posthog from '@spacetimedb/posthog/submodule'; + +import { + MAX_TICKS_PER_CALL, + MAX_ACTIVITY_ROWS, + MAX_SESSION_ROWS, + MAX_PURCHASE_ROWS, + spacetimedb, + type WriteCtx, + type VariantRow, + type ProductRow, + type ScenarioRow, + type MetricRow, +} from './schema'; +import { + MAX_MACHINE_LEVEL, + PATIENCE_TICKS, + REPUTATION_ON_RENEGE, + REPUTATION_ON_SALE, + REPUTATION_ON_STOCKOUT, + RUSH_CYCLE_TICKS, + RUSH_LENGTH_TICKS, + RUSH_MULTIPLIER, + START_CASH, + START_INVENTORY, + START_REPUTATION, + SUPPLY_PRICE, + arrivalDemand, + chooseProfile, + clampReputation, + ensureEconomy, + maximumQueueLength, + nonSaleReason, + pricePaid, + purchaseProbability, + seededRandom, + serviceCapacity, + storageCapacity, + supplyCost, + upgradeCost, + variantScore, + type SupplyKind, +} from './economy'; +import { clampU32, fail, requireId } from './validation'; +export { default } from './schema'; +export * from './catalog'; + +function keyFor(owner: string, id: string): string { + return `${owner}|${id}`; +} + +function findProduct(ctx: WriteCtx, owner: string, productId: string) { + return ctx.db.product.key.find(keyFor(owner, productId)); +} + +function findVariant(ctx: WriteCtx, owner: string, variantId: string) { + return ctx.db.variant.key.find(keyFor(owner, variantId)); +} + +function ensureConfig(ctx: WriteCtx, owner: string) { + const existing = ctx.db.simConfig.owner.find(owner); + if (existing) return existing; + const firstScenario = [...ctx.db.scenario.iter()][0]; + const scenarioId = firstScenario?.scenarioId ?? 'steady'; + const row = { + owner, + scenarioId, + tick: 0n, + experimentKey: 'context-cafe-offer', + experimentVariant: undefined, + updatedAt: ctx.timestamp, + }; + ctx.db.simConfig.insert(row); + return row; +} + +function ensureMetrics(ctx: WriteCtx, owner: string): MetricRow { + const existing = ctx.db.metrics.owner.find(owner); + if (existing) return existing; + const row = { + owner, + tick: 0n, + views: 0n, + carts: 0n, + checkouts: 0n, + purchases: 0n, + abandons: 0n, + revenueCents: 0n, + updatedAt: ctx.timestamp, + }; + ctx.db.metrics.insert(row); + return row; +} + +function addActivity( + ctx: WriteCtx, + owner: string, + tick: bigint, + kind: string, + message: string, + detail: { + profile?: string; + productId?: string; + variantId?: string; + amountCents?: number; + } = {} +): void { + ctx.db.activity.insert({ + activityId: 0n, + owner, + tick, + kind, + message, + profile: detail.profile, + productId: detail.productId, + variantId: detail.variantId, + amountCents: detail.amountCents, + createdAt: ctx.timestamp, + }); +} + +function enqueueCafeEvent( + ctx: WriteCtx, + distinctId: string, + event: string, + props: Record +): void { + posthog.enqueueEvent(ctx.as.posthog, { + distinctId, + event, + propertiesJson: JSON.stringify({ + source: 'context_cafe', + ...props, + }), + idempotencyKey: undefined, + }); +} + +function selectVariant( + ctx: WriteCtx, + owner: string, + rand: () => number, + scenarioRow: ScenarioRow, + profile: string +) { + const candidates = [...ctx.db.variant.byOwner.filter(owner)] + .filter((variantRow: VariantRow) => variantRow.active) + .map((variantRow: VariantRow) => { + const productRow = findProduct(ctx, owner, variantRow.productId); + if (!productRow || !productRow.active) return undefined; + return { + productRow, + variantRow, + score: variantScore(productRow, variantRow, scenarioRow, profile), + }; + }) + .filter(Boolean) as Array<{ + productRow: ProductRow; + variantRow: VariantRow; + score: number; + }>; + + if (candidates.length === 0) return undefined; + const total = candidates.reduce((sum, row) => sum + row.score, 0); + let pick = rand() * total; + for (const candidate of candidates) { + pick -= candidate.score; + if (pick <= 0) return candidate; + } + return candidates[0]; +} + +import { newestFirst } from './recent'; + +function trimRecent(ctx: WriteCtx, owner: string): void { + for (const row of newestFirst([...ctx.db.activity.owner.filter(owner)]).slice( + MAX_ACTIVITY_ROWS + )) + ctx.db.activity.delete(row); + for (const row of newestFirst([ + ...ctx.db.botSession.owner.filter(owner), + ]).slice(MAX_SESSION_ROWS)) + ctx.db.botSession.delete(row); + for (const row of newestFirst([...ctx.db.purchase.owner.filter(owner)]).slice( + MAX_PURCHASE_ROWS + )) + ctx.db.purchase.delete(row); +} + +// Seed this caller's per-session catalog + config/metrics. Idempotent. +export const init_session = spacetimedb.reducer({}, ctx => { + const owner = ctx.sender.toHexString(); + const alreadySeeded = [...ctx.db.product.byOwner.filter(owner)].length > 0; + if (!alreadySeeded) { + for (const tpl of [...ctx.db.productTemplate.iter()]) { + ctx.db.product.insert({ + key: keyFor(owner, tpl.productId), + owner, + productId: tpl.productId, + name: tpl.name, + category: tpl.category, + description: tpl.description, + baseAppeal: tpl.baseAppeal, + active: tpl.active, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + for (const tpl of [...ctx.db.variantTemplate.iter()]) { + ctx.db.variant.insert({ + key: keyFor(owner, tpl.variantId), + owner, + variantId: tpl.variantId, + productId: tpl.productId, + name: tpl.name, + flavor: tpl.flavor, + contextTokens: tpl.contextTokens, + reasoning: tpl.reasoning, + latency: tpl.latency, + priceCents: tpl.priceCents, + baselinePriceCents: tpl.priceCents, + discountBps: tpl.discountBps, + active: tpl.active, + featured: tpl.featured, + updatedAt: ctx.timestamp, + }); + } + } + ensureConfig(ctx, owner); + ensureMetrics(ctx, owner); + ensureEconomy(ctx, owner); +}); + +export const reset_simulation = spacetimedb.reducer( + { scenarioId: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const scenarioId = requireId(args.scenarioId, 'scenario_id'); + if (!ctx.db.scenario.scenarioId.find(scenarioId)) fail('unknown_scenario'); + for (const row of [...ctx.db.botSession.owner.filter(owner)]) + ctx.db.botSession.delete(row); + for (const row of [...ctx.db.purchase.owner.filter(owner)]) + ctx.db.purchase.delete(row); + for (const row of [...ctx.db.activity.owner.filter(owner)]) + ctx.db.activity.delete(row); + for (const row of [...ctx.db.waitingBot.owner.filter(owner)]) + ctx.db.waitingBot.delete(row); + const config = ensureConfig(ctx, owner); + ctx.db.simConfig.owner.update({ + ...config, + scenarioId, + tick: 0n, + updatedAt: ctx.timestamp, + }); + const metric = ensureMetrics(ctx, owner); + ctx.db.metrics.owner.update({ + ...metric, + tick: 0n, + views: 0n, + carts: 0n, + checkouts: 0n, + purchases: 0n, + abandons: 0n, + revenueCents: 0n, + updatedAt: ctx.timestamp, + }); + const money = ensureEconomy(ctx, owner); + ctx.db.econ.owner.update({ + ...money, + cashCents: START_CASH, + computeUnits: START_INVENTORY.compute, + contextUnits: START_INVENTORY.context, + memoryUnits: START_INVENTORY.memory, + suppliesSpentCents: 0n, + stockouts: 0, + reputation: START_REPUTATION, + workers: 1, + machineLevel: 0, + seats: 0, + storageLevel: 0, + reneged: 0, + updatedAt: ctx.timestamp, + }); + addActivity(ctx, owner, 0n, 'reset', 'Simulation reset.'); + } +); + +export const select_scenario = spacetimedb.reducer( + { scenarioId: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const scenarioId = requireId(args.scenarioId, 'scenario_id'); + const scenarioRow = ctx.db.scenario.scenarioId.find(scenarioId); + if (!scenarioRow) fail('unknown_scenario'); + const config = ensureConfig(ctx, owner); + ctx.db.simConfig.owner.update({ + ...config, + scenarioId, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + config.tick, + 'scenario_selected', + `Scenario set to ${scenarioRow.name}.` + ); + } +); + +export const set_product_active = spacetimedb.reducer( + { productId: t.string(), active: t.bool() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const productId = requireId(args.productId, 'product_id'); + const row = findProduct(ctx, owner, productId); + if (!row) fail('unknown_product'); + ctx.db.product.key.update({ + ...row, + active: args.active, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + args.active ? 'product_enabled' : 'product_disabled', + `${row.name} ${args.active ? 'enabled' : 'disabled'}.`, + { productId } + ); + enqueueCafeEvent( + ctx, + owner, + args.active ? 'product_enabled' : 'product_disabled', + { product_id: productId, product_name: row.name } + ); + } +); + +export const set_variant_active = spacetimedb.reducer( + { variantId: t.string(), active: t.bool() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const variantId = requireId(args.variantId, 'variant_id'); + const row = findVariant(ctx, owner, variantId); + if (!row) fail('unknown_variant'); + ctx.db.variant.key.update({ + ...row, + active: args.active, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + args.active ? 'variant_enabled' : 'variant_disabled', + `${row.name} ${args.active ? 'enabled' : 'disabled'}.`, + { productId: row.productId, variantId } + ); + } +); + +export const set_variant_price = spacetimedb.reducer( + { variantId: t.string(), priceCents: t.u32() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const row = findVariant( + ctx, + owner, + requireId(args.variantId, 'variant_id') + ); + if (!row) fail('unknown_variant'); + const priceCents = clampU32(args.priceCents, 'price_cents', 0, 250_000); + ctx.db.variant.key.update({ ...row, priceCents, updatedAt: ctx.timestamp }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'price_changed', + `${row.name} price changed to $${(priceCents / 100).toFixed(2)}.`, + { + productId: row.productId, + variantId: row.variantId, + amountCents: priceCents, + } + ); + enqueueCafeEvent(ctx, owner, 'price_changed', { + variant_id: row.variantId, + product_id: row.productId, + price_cents: priceCents, + old_price_cents: row.priceCents, + }); + } +); + +export const set_variant_discount = spacetimedb.reducer( + { variantId: t.string(), discountBps: t.u32() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const row = findVariant( + ctx, + owner, + requireId(args.variantId, 'variant_id') + ); + if (!row) fail('unknown_variant'); + const discountBps = clampU32(args.discountBps, 'discount_bps', 0, 9000); + ctx.db.variant.key.update({ + ...row, + discountBps, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'discount_changed', + `${row.name} discount set to ${discountBps / 100}%.`, + { productId: row.productId, variantId: row.variantId } + ); + enqueueCafeEvent(ctx, owner, 'discount_changed', { + variant_id: row.variantId, + product_id: row.productId, + discount_bps: discountBps, + }); + } +); + +export const set_featured_variant = spacetimedb.reducer( + { variantId: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const variantId = requireId(args.variantId, 'variant_id'); + const selected = findVariant(ctx, owner, variantId); + if (!selected) fail('unknown_variant'); + for (const row of [...ctx.db.variant.byOwner.filter(owner)]) { + ctx.db.variant.key.update({ + ...row, + featured: row.variantId === variantId, + updatedAt: ctx.timestamp, + }); + } + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'featured_variant_changed', + `${selected.name} is now the featured recipe.`, + { productId: selected.productId, variantId } + ); + enqueueCafeEvent(ctx, owner, 'featured_variant_changed', { + variant_id: variantId, + product_id: selected.productId, + }); + } +); + +export const set_experiment_variant = spacetimedb.reducer( + { key: t.string(), variant: t.option(t.string()) }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const config = ensureConfig(ctx, owner); + ctx.db.simConfig.owner.update({ + ...config, + experimentKey: args.key.trim() || config.experimentKey, + experimentVariant: args.variant, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + config.tick, + 'experiment_variant_checked', + `Experiment ${args.key || config.experimentKey}: ${args.variant ?? 'control'}.` + ); + } +); + +export const buy_supply = spacetimedb.reducer( + { kind: t.string(), units: t.u32() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const kind = requireId(args.kind, 'supply_kind'); + if (kind !== 'compute' && kind !== 'context' && kind !== 'memory') + fail('invalid_supply_kind'); + const money = ensureEconomy(ctx, owner); + const field = + kind === 'compute' + ? 'computeUnits' + : kind === 'context' + ? 'contextUnits' + : 'memoryUnits'; + // Cap the purchase at the storeroom's available capacity. + const headroom = + storageCapacity(kind as SupplyKind, money.storageLevel) - money[field]; + if (headroom <= 0) fail('storage_full'); + const units = Math.min(clampU32(args.units, 'units', 1, 1000), headroom); + const cost = BigInt(units * SUPPLY_PRICE[kind as SupplyKind]); + if (money.cashCents < cost) fail('insufficient_cash'); + ctx.db.econ.owner.update({ + ...money, + cashCents: money.cashCents - cost, + [field]: money[field] + units, + suppliesSpentCents: money.suppliesSpentCents + cost, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'supply_purchased', + `Bought ${units} ${kind} ($${(Number(cost) / 100).toFixed(2)}).`, + { amountCents: Number(cost) } + ); + enqueueCafeEvent(ctx, owner, 'supply_purchased', { + kind, + units, + cost_cents: Number(cost), + }); + } +); + +export const buy_upgrade = spacetimedb.reducer( + { kind: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const kind = requireId(args.kind, 'upgrade_kind'); + if ( + kind !== 'worker' && + kind !== 'machine' && + kind !== 'counter' && + kind !== 'storage' + ) + fail('invalid_upgrade_kind'); + const money = ensureEconomy(ctx, owner); + if (kind === 'machine' && money.machineLevel >= MAX_MACHINE_LEVEL) + fail('machine_maxed'); + const cost = upgradeCost(kind, money); + if (money.cashCents < cost) fail('insufficient_cash'); + const next = { + ...money, + cashCents: money.cashCents - cost, + workers: money.workers + (kind === 'worker' ? 1 : 0), + machineLevel: money.machineLevel + (kind === 'machine' ? 1 : 0), + seats: money.seats + (kind === 'counter' ? 1 : 0), + storageLevel: money.storageLevel + (kind === 'storage' ? 1 : 0), + updatedAt: ctx.timestamp, + }; + ctx.db.econ.owner.update(next); + const label = + kind === 'worker' + ? `Hired a worker (now ${next.workers} serving/tick)` + : kind === 'machine' + ? `Upgraded machines (level ${next.machineLevel})` + : kind === 'storage' + ? `Expanded storeroom (holds ${storageCapacity('context', next.storageLevel)} context)` + : `Added counter space (holds ${maximumQueueLength(next)})`; + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'upgrade_purchased', + `${label}: $${(Number(cost) / 100).toFixed(2)}.`, + { amountCents: Number(cost) } + ); + enqueueCafeEvent(ctx, owner, 'upgrade_purchased', { + kind, + cost_cents: Number(cost), + workers: next.workers, + machine_level: next.machineLevel, + seats: next.seats, + storage_level: next.storageLevel, + }); + } +); + +export const simulate_tick = spacetimedb.reducer( + { ticks: t.u32(), seed: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const tickCount = clampU32(args.ticks, 'ticks', 1, MAX_TICKS_PER_CALL); + const config = ensureConfig(ctx, owner); + const scenarioRow = ctx.db.scenario.scenarioId.find(config.scenarioId); + if (!scenarioRow) fail('unknown_scenario'); + let metric = ensureMetrics(ctx, owner); + const money = { ...ensureEconomy(ctx, owner) }; + let finalTick = config.tick; + + const WANTS: Record = { + cheap: 'a deal', + rushed: 'speed', + research: 'long context', + visual: 'vision', + memory: 'memory', + premium: 'top quality', + }; + + for (let i = 0; i < tickCount; i++) { + finalTick += 1n; + const rand = seededRandom( + `${args.seed}:${finalTick.toString()}:${config.scenarioId}` + ); + const baseTraffic = scenarioRow.trafficPerTick; + let tickViews = 0n; + let tickCarts = 0n; + let tickCheckouts = 0n; + let tickPurchases = 0n; + let tickAbandons = 0n; + let tickRevenue = 0n; + + // Serve up to capacity bots from the front; each leaves the queue either way. + const queue = [...ctx.db.waitingBot.owner.filter(owner)].sort((a, b) => + a.queueId < b.queueId ? -1 : a.queueId > b.queueId ? 1 : 0 + ); + const capacity = serviceCapacity(money); + let served = 0; + for (const front of queue.slice(0, capacity)) { + served++; + ctx.db.waitingBot.delete(front); + const variantRow = findVariant(ctx, owner, front.variantId); + const productRow = variantRow + ? findProduct(ctx, owner, variantRow.productId) + : undefined; + + if ( + !variantRow || + !variantRow.active || + !productRow || + !productRow.active + ) { + // The selected item is unavailable when the customer reaches the counter. + tickAbandons++; + ctx.db.botSession.insert({ + sessionId: 0n, + owner, + botId: front.botId, + tick: finalTick, + profile: front.profile, + scenarioId: scenarioRow.scenarioId, + productId: front.productId, + variantId: front.variantId, + stage: 'abandoned', + revenueCents: 0, + reason: 'unavailable', + createdAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + finalTick, + 'checkout_abandoned', + `${front.profile} bot left because its selection was unavailable.`, + { + profile: front.profile, + productId: front.productId, + variantId: front.variantId, + } + ); + enqueueCafeEvent(ctx, front.botId, 'checkout_abandoned', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: front.productId, + variant_id: front.variantId, + price_cents: 0, + reason: 'unavailable', + }); + } else { + const selected = { + productRow, + variantRow, + score: variantScore( + productRow, + variantRow, + scenarioRow, + front.profile + ), + }; + const paid = pricePaid(variantRow); + const cartChance = Math.min(94, 35 + Math.floor(selected.score / 3)); + const didCart = rand() * 100 < cartChance; + const didCheckout = didCart && rand() * 100 < 82; + const wouldBuy = + didCheckout && + rand() * 100 < + purchaseProbability(selected, scenarioRow, front.profile); + // Complete a sale only when the required supplies are available. + const cost = supplyCost(productRow, variantRow, money.machineLevel); + const inStock = + money.computeUnits >= cost.compute && + money.contextUnits >= cost.context && + money.memoryUnits >= cost.memory; + const didPurchase = wouldBuy && inStock; + const stockedOut = wouldBuy && !inStock; + let stage = 'viewed'; + if (didCart) { + tickCarts++; + stage = 'cart'; + enqueueCafeEvent(ctx, front.botId, 'product_added_to_cart', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + variant_id: variantRow.variantId, + price_cents: variantRow.priceCents, + discounted_price_cents: paid, + }); + } + if (didCheckout) { + tickCheckouts++; + stage = 'checkout'; + enqueueCafeEvent(ctx, front.botId, 'checkout_started', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + variant_id: variantRow.variantId, + price_cents: paid, + }); + } + if (didPurchase) { + tickPurchases++; + tickRevenue += BigInt(paid); + stage = 'purchased'; + // Consume supplies + bank the cash + a happy customer lifts reputation. + money.computeUnits -= cost.compute; + money.contextUnits -= cost.context; + money.memoryUnits -= cost.memory; + money.cashCents += BigInt(paid); + money.reputation = clampReputation( + money.reputation + REPUTATION_ON_SALE + ); + } else if (didCheckout || didCart) { + tickAbandons++; + stage = 'abandoned'; + } + if (stockedOut) { + money.stockouts += 1; + money.reputation = clampReputation( + money.reputation - REPUTATION_ON_STOCKOUT + ); + } + + // Show the purchase decision at the counter. + const short = + money.contextUnits < cost.context + ? 'context' + : money.computeUnits < cost.compute + ? 'compute' + : 'memory'; + const reason = didPurchase + ? '' + : nonSaleReason( + productRow, + variantRow, + scenarioRow, + front.profile, + inStock, + short + ); + + ctx.db.botSession.insert({ + sessionId: 0n, + owner, + botId: front.botId, + tick: finalTick, + profile: front.profile, + scenarioId: scenarioRow.scenarioId, + productId: productRow.productId, + variantId: variantRow.variantId, + stage, + revenueCents: didPurchase ? paid : 0, + reason, + createdAt: ctx.timestamp, + }); + + if (didPurchase) { + ctx.db.purchase.insert({ + purchaseId: 0n, + owner, + sessionId: 0n, + tick: finalTick, + botId: front.botId, + profile: front.profile, + productId: productRow.productId, + variantId: variantRow.variantId, + pricePaidCents: paid, + createdAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + finalTick, + 'purchase_completed', + `${front.profile} bot bought ${variantRow.name}.`, + { + profile: front.profile, + productId: productRow.productId, + variantId: variantRow.variantId, + amountCents: paid, + } + ); + enqueueCafeEvent(ctx, front.botId, 'purchase_completed', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + product_name: productRow.name, + variant_id: variantRow.variantId, + variant_name: variantRow.name, + price_cents: paid, + discount_bps: variantRow.discountBps, + }); + } else if (stockedOut) { + addActivity( + ctx, + owner, + finalTick, + 'stockout', + `${front.profile} bot left because ${short} was out of stock.`, + { + profile: front.profile, + productId: productRow.productId, + variantId: variantRow.variantId, + } + ); + enqueueCafeEvent(ctx, front.botId, 'stockout', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + variant_id: variantRow.variantId, + short_supply: short, + price_cents: paid, + }); + } else if (stage === 'abandoned') { + addActivity( + ctx, + owner, + finalTick, + 'checkout_abandoned', + `${front.profile} bot bailed on ${variantRow.name}.`, + { + profile: front.profile, + productId: productRow.productId, + variantId: variantRow.variantId, + } + ); + enqueueCafeEvent(ctx, front.botId, 'checkout_abandoned', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + variant_id: variantRow.variantId, + price_cents: paid, + reason, + }); + } + } + } + + // Customers who exceed their patience limit leave and reduce reputation. + for (const waiting of [...ctx.db.waitingBot.owner.filter(owner)]) { + if (finalTick - waiting.arrivedTick <= BigInt(PATIENCE_TICKS)) continue; + ctx.db.waitingBot.delete(waiting); + money.reneged += 1; + money.reputation = clampReputation( + money.reputation - REPUTATION_ON_RENEGE + ); + tickAbandons++; + ctx.db.botSession.insert({ + sessionId: 0n, + owner, + botId: waiting.botId, + tick: finalTick, + profile: waiting.profile, + scenarioId: scenarioRow.scenarioId, + productId: waiting.productId, + variantId: waiting.variantId, + stage: 'abandoned', + revenueCents: 0, + reason: 'waited', + createdAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + finalTick, + 'reneged', + `${waiting.profile} bot gave up waiting.`, + { + profile: waiting.profile, + productId: waiting.productId, + variantId: waiting.variantId, + } + ); + enqueueCafeEvent(ctx, waiting.botId, 'reneged', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: waiting.profile, + waited_ticks: Number(finalTick - waiting.arrivedTick), + reason: 'waited', + }); + } + + // New arrivals scale with reputation (and surge during a rush), but the counter only holds so many. + const rushing = + finalTick % BigInt(RUSH_CYCLE_TICKS) < BigInt(RUSH_LENGTH_TICKS); + const effTraffic = rushing ? baseTraffic * RUSH_MULTIPLIER : baseTraffic; + const lineCap = maximumQueueLength(money); + const demand = Math.min( + arrivalDemand(effTraffic, money.reputation), + lineCap + ); + let admit = 0; + while ( + [...ctx.db.waitingBot.owner.filter(owner)].length < demand && + admit < lineCap + ) { + admit++; + const profile = chooseProfile(rand, scenarioRow); + const botId = `bot-${finalTick.toString()}-${admit}`; + const selected = selectVariant(ctx, owner, rand, scenarioRow, profile); + if (!selected) { + addActivity( + ctx, + owner, + finalTick, + 'no_inventory', + 'A bot found no active recipes.' + ); + break; + } + const { productRow, variantRow } = selected; + tickViews++; + enqueueCafeEvent(ctx, botId, 'product_viewed', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: profile, + product_id: productRow.productId, + product_name: productRow.name, + variant_id: variantRow.variantId, + variant_name: variantRow.name, + price_cents: variantRow.priceCents, + discount_bps: variantRow.discountBps, + featured: variantRow.featured, + context_tokens: variantRow.contextTokens, + reasoning: variantRow.reasoning, + latency: variantRow.latency, + }); + ctx.db.waitingBot.insert({ + queueId: 0n, + owner, + botId, + profile, + scenarioId: scenarioRow.scenarioId, + productId: productRow.productId, + variantId: variantRow.variantId, + wants: WANTS[profile] ?? 'a good drink', + thrifty: profile === 'cheap' || scenarioRow.priceSensitivity >= 60, + arrivedTick: finalTick, + createdAt: ctx.timestamp, + }); + } + + metric = { + ...metric, + tick: finalTick, + views: metric.views + tickViews, + carts: metric.carts + tickCarts, + checkouts: metric.checkouts + tickCheckouts, + purchases: metric.purchases + tickPurchases, + abandons: metric.abandons + tickAbandons, + revenueCents: metric.revenueCents + tickRevenue, + updatedAt: ctx.timestamp, + }; + ctx.db.metrics.owner.update(metric); + ctx.db.econ.owner.update({ ...money, updatedAt: ctx.timestamp }); + enqueueCafeEvent(ctx, `sim:${owner}`, 'serve_tick_summary', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + served, + capacity, + rush: rushing, + reputation: money.reputation, + queue_length: [...ctx.db.waitingBot.owner.filter(owner)].length, + purchases: tickPurchases.toString(), + abandons: tickAbandons.toString(), + revenue_cents: tickRevenue.toString(), + }); + } + + ctx.db.simConfig.owner.update({ + ...config, + tick: finalTick, + updatedAt: ctx.timestamp, + }); + trimRecent(ctx, owner); + } +); + +export * from './views'; + +export const init = spacetimedb.init(ctx => { + posthog.installPostHog(ctx.as.posthog); +}); diff --git a/spacetime-posthog-ts/example/spacetimedb/src/recent.ts b/spacetime-posthog-ts/example/spacetimedb/src/recent.ts new file mode 100644 index 00000000000..6d2cef4a94a --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/recent.ts @@ -0,0 +1,9 @@ +export function newestFirst< + T extends { createdAt: { microsSinceUnixEpoch: bigint } }, +>(rows: T[]): T[] { + return rows.sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? 1 : av > bv ? -1 : 0; + }); +} diff --git a/spacetime-posthog-ts/example/spacetimedb/src/schema.ts b/spacetime-posthog-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..23c88eb6384 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,331 @@ +import { + schema, + table, + t, + type Infer, + type InferSchema, + type ReducerCtx, +} from 'spacetimedb/server'; +import * as posthog from '@spacetimedb/posthog/submodule'; + +export const MAX_SYNC_ROWS = 100; +export const MAX_TICKS_PER_CALL = 25; +export const MAX_ACTIVITY_ROWS = 120; +export const MAX_SESSION_ROWS = 250; +export const MAX_PURCHASE_ROWS = 120; + +// Template catalog. init_session copies these into per-session rows. +export const productTemplate = table( + { name: 'product_template', public: false }, + { + productId: t.string().primaryKey(), + name: t.string(), + category: t.string(), + description: t.string(), + baseAppeal: t.u32(), + active: t.bool(), + } +); + +export const variantTemplate = table( + { name: 'variant_template', public: false }, + { + variantId: t.string().primaryKey(), + productId: t.string().index(), + name: t.string(), + flavor: t.string(), + contextTokens: t.u32(), + reasoning: t.u32(), + latency: t.u32(), + priceCents: t.u32(), + discountBps: t.u32(), + active: t.bool(), + featured: t.bool(), + } +); + +// Per-session catalog; key = `${owner}|${id}` so ids can repeat across sessions. +export const product = table( + { + name: 'product', + public: false, + indexes: [{ accessor: 'byOwner', algorithm: 'btree', columns: ['owner'] }], + }, + { + key: t.string().primaryKey(), + owner: t.string(), + productId: t.string().index(), + name: t.string(), + category: t.string(), + description: t.string(), + baseAppeal: t.u32(), + active: t.bool(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +export const variant = table( + { + name: 'variant', + public: false, + indexes: [{ accessor: 'byOwner', algorithm: 'btree', columns: ['owner'] }], + }, + { + key: t.string().primaryKey(), + owner: t.string(), + variantId: t.string().index(), + productId: t.string().index(), + name: t.string(), + flavor: t.string(), + contextTokens: t.u32(), + reasoning: t.u32(), + latency: t.u32(), + priceCents: t.u32(), + baselinePriceCents: t.u32(), + discountBps: t.u32(), + active: t.bool(), + featured: t.bool(), + updatedAt: t.timestamp(), + } +); + +export const scenario = table( + { name: 'scenario', public: false }, + { + scenarioId: t.string().primaryKey(), + name: t.string().index(), + description: t.string(), + trafficPerTick: t.u32(), + priceSensitivity: t.u32(), + rushBias: t.u32(), + researchBias: t.u32(), + visualBias: t.u32(), + memoryBias: t.u32(), + premiumBias: t.u32(), + volatility: t.u32(), + } +); + +export const simConfig = table( + { name: 'sim_config', public: false }, + { + owner: t.string().primaryKey(), + scenarioId: t.string(), + tick: t.u64(), + experimentKey: t.string(), + experimentVariant: t.option(t.string()), + updatedAt: t.timestamp(), + } +); + +export const metrics = table( + { name: 'metrics', public: false }, + { + owner: t.string().primaryKey(), + tick: t.u64(), + views: t.u64(), + carts: t.u64(), + checkouts: t.u64(), + purchases: t.u64(), + abandons: t.u64(), + revenueCents: t.u64(), + updatedAt: t.timestamp(), + } +); + +// Per-session cash, supply inventory, reputation, and capacity upgrades. +export const econ = table( + { name: 'econ', public: false }, + { + owner: t.string().primaryKey(), + cashCents: t.u64(), + computeUnits: t.u32(), + contextUnits: t.u32(), + memoryUnits: t.u32(), + suppliesSpentCents: t.u64(), + stockouts: t.u32(), + reputation: t.u32(), + workers: t.u32(), + machineLevel: t.u32(), + seats: t.u32(), + storageLevel: t.u32(), + reneged: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const botSession = table( + { + name: 'bot_session', + public: false, + indexes: [ + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + sessionId: t.u64().primaryKey().autoInc(), + owner: t.string().index(), + botId: t.string().index(), + tick: t.u64().index(), + profile: t.string().index(), + scenarioId: t.string().index(), + productId: t.option(t.string()), + variantId: t.option(t.string()), + stage: t.string().index(), + revenueCents: t.u32(), + reason: t.string(), + createdAt: t.timestamp(), + } +); + +export const purchase = table( + { + name: 'purchase', + public: false, + indexes: [ + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + purchaseId: t.u64().primaryKey().autoInc(), + owner: t.string().index(), + sessionId: t.u64().index(), + tick: t.u64().index(), + botId: t.string().index(), + profile: t.string().index(), + productId: t.string().index(), + variantId: t.string().index(), + pricePaidCents: t.u32(), + createdAt: t.timestamp(), + } +); + +export const activity = table( + { + name: 'activity', + public: false, + indexes: [ + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + activityId: t.u64().primaryKey().autoInc(), + owner: t.string().index(), + tick: t.u64().index(), + kind: t.string().index(), + message: t.string(), + profile: t.option(t.string()), + productId: t.option(t.string()), + variantId: t.option(t.string()), + amountCents: t.option(t.u32()), + createdAt: t.timestamp(), + } +); + +// Waiting queue; lowest queueId is served first. +export const waitingBot = table( + { + name: 'waiting_bot', + public: false, + }, + { + queueId: t.u64().primaryKey().autoInc(), + owner: t.string().index(), + botId: t.string(), + profile: t.string().index(), + scenarioId: t.string().index(), + productId: t.string(), + variantId: t.string(), + wants: t.string(), + thrifty: t.bool(), + arrivedTick: t.u64(), + createdAt: t.timestamp(), + } +); + +export const cafeDeliveryLogViewRow = posthog.t.object( + 'ContextCafeDeliveryLogRow', + { + deliveryId: posthog.t.string(), + source: posthog.t.string(), + distinctId: posthog.t.string(), + event: posthog.t.string(), + ok: posthog.t.bool(), + statusCode: posthog.t.u16(), + responseBody: posthog.t.string(), + attemptedAt: posthog.t.timestamp(), + } +); + +export const cafeAnalyticsSummaryRow = posthog.t.object( + 'ContextCafeAnalyticsSummaryRow', + { + queued: posthog.t.u64(), + delivered: posthog.t.u64(), + failed: posthog.t.u64(), + } +); + +export const spacetimedb = schema({ + posthog, + productTemplate, + variantTemplate, + product, + variant, + scenario, + simConfig, + metrics, + econ, + botSession, + purchase, + activity, + waitingBot, +}); + +export type ProductInput = { + productId: string; + name: string; + category: string; + description: string; + baseAppeal: number; + active?: boolean; +}; + +export type VariantInput = { + variantId: string; + productId: string; + name: string; + flavor: string; + contextTokens: number; + reasoning: number; + latency: number; + priceCents: number; + discountBps?: number; + active?: boolean; + featured?: boolean; +}; + +export type ScenarioInput = { + scenarioId: string; + name: string; + description: string; + trafficPerTick: number; + priceSensitivity: number; + rushBias: number; + researchBias: number; + visualBias: number; + memoryBias: number; + premiumBias: number; + volatility: number; +}; + +export type Schema = InferSchema; +export type WriteCtx = ReducerCtx; +export type VariantRow = Infer; +export type ProductRow = Infer; +export type ScenarioRow = Infer; +export type MetricRow = Infer; +export type EconRow = Infer; + +export default spacetimedb; diff --git a/spacetime-posthog-ts/example/spacetimedb/src/validation.ts b/spacetime-posthog-ts/example/spacetimedb/src/validation.ts new file mode 100644 index 00000000000..11463dab319 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/validation.ts @@ -0,0 +1,26 @@ +import { SenderError } from 'spacetimedb/server'; + +export function fail(message: string): never { + throw new SenderError(`context_cafe.${message}`); +} + +export function requireId(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim() === '') { + fail(`invalid_${field}`); + } + return value.trim(); +} + +export function clampU32( + value: unknown, + field: string, + min: number, + max: number +): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`invalid_${field}`); + } + const result = Math.round(value); + if (result < min || result > max) fail(`invalid_${field}`); + return result; +} diff --git a/spacetime-posthog-ts/example/spacetimedb/src/views.ts b/spacetime-posthog-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..0c033ea381f --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,169 @@ +import { Range, t } from 'spacetimedb/server'; +import * as posthog from '@spacetimedb/posthog/submodule'; + +import { + product, + variant, + scenario, + simConfig, + metrics, + econ, + botSession, + purchase, + activity, + waitingBot, + cafeDeliveryLogViewRow, + cafeAnalyticsSummaryRow, + spacetimedb, +} from './schema'; +export { default } from './schema'; + +import { newestFirst } from './recent'; + +export const flush_analytics = spacetimedb.procedure( + { limit: t.u32() }, + t.string(), + (ctx, args) => + JSON.stringify(posthog.flushOutbox(ctx.as.posthog, { limit: args.limit })) +); + +export const cafeProducts = spacetimedb.view( + { name: 'cafe_products', public: true }, + t.array(product.rowType), + ctx => [...ctx.db.product.byOwner.filter(ctx.sender.toHexString())] +); + +export const cafeVariants = spacetimedb.view( + { name: 'cafe_variants', public: true }, + t.array(variant.rowType), + ctx => [...ctx.db.variant.byOwner.filter(ctx.sender.toHexString())] +); + +export const cafeScenarios = spacetimedb.view( + { name: 'cafe_scenarios', public: true }, + t.array(scenario.rowType), + ctx => [...ctx.db.scenario.iter()] +); + +export const cafeConfig = spacetimedb.view( + { name: 'cafe_config', public: true }, + t.array(simConfig.rowType), + ctx => { + const row = ctx.db.simConfig.owner.find(ctx.sender.toHexString()); + return row ? [row] : []; + } +); + +export const cafeMetrics = spacetimedb.view( + { name: 'cafe_metrics', public: true }, + t.array(metrics.rowType), + ctx => { + const row = ctx.db.metrics.owner.find(ctx.sender.toHexString()); + return row ? [row] : []; + } +); + +export const cafeEcon = spacetimedb.view( + { name: 'cafe_econ', public: true }, + t.array(econ.rowType), + ctx => { + const row = ctx.db.econ.owner.find(ctx.sender.toHexString()); + return row ? [row] : []; + } +); + +export const cafeQueue = spacetimedb.view( + { name: 'cafe_queue', public: true }, + t.array(waitingBot.rowType), + ctx => { + const rows = [...ctx.db.waitingBot.owner.filter(ctx.sender.toHexString())]; + rows.sort((a, b) => + a.queueId < b.queueId ? -1 : a.queueId > b.queueId ? 1 : 0 + ); + return rows; + } +); + +export const cafeRecentSessions = spacetimedb.view( + { name: 'cafe_recent_sessions', public: true }, + t.array(botSession.rowType), + ctx => + newestFirst([ + ...ctx.db.botSession.owner.filter(ctx.sender.toHexString()), + ]).slice(0, 80) +); + +export const cafeRecentPurchases = spacetimedb.view( + { name: 'cafe_recent_purchases', public: true }, + t.array(purchase.rowType), + ctx => + newestFirst([ + ...ctx.db.purchase.owner.filter(ctx.sender.toHexString()), + ]).slice(0, 50) +); + +export const cafeRecentActivity = spacetimedb.view( + { name: 'cafe_recent_activity', public: true }, + t.array(activity.rowType), + ctx => + newestFirst([ + ...ctx.db.activity.owner.filter(ctx.sender.toHexString()), + ]).slice(0, 80) +); + +export const posthogOutboxAdmin = spacetimedb.view( + { name: 'posthog_outbox_admin', public: true }, + posthog.t.array(posthog.posthogOutbox.rowType), + ctx => { + const admin = ctx.db.posthog.posthogAdminIdentity.identity.find(ctx.sender); + return admin + ? [ + ...ctx.db.posthog.posthogOutbox.byStatus.filter( + posthog.OutboxStatus.Queued + ), + ] + : []; + } +); + +export const posthogDeliveryLogAdmin = spacetimedb.view( + { name: 'posthog_delivery_log_admin', public: true }, + posthog.t.array(cafeDeliveryLogViewRow), + ctx => { + const admin = ctx.db.posthog.posthogAdminIdentity.identity.find(ctx.sender); + if (!admin) return []; + const rows = [ + ...ctx.db.posthog.posthogDeliveryLog.byAttemptedAt.filter(new Range()), + ]; + rows.sort((a, b) => { + const av = a.attemptedAt.microsSinceUnixEpoch; + const bv = b.attemptedAt.microsSinceUnixEpoch; + return av < bv ? 1 : av > bv ? -1 : 0; + }); + return rows.slice(0, 50).map(row => ({ + deliveryId: row.deliveryId.toString(), + source: row.source.tag, + distinctId: row.distinctId, + event: row.event, + ok: row.ok, + statusCode: row.statusCode, + responseBody: row.responseBody, + attemptedAt: row.attemptedAt, + })); + } +); + +export const cafeAnalyticsSummary = spacetimedb.anonymousView( + { name: 'cafe_analytics_summary', public: true }, + posthog.t.array(cafeAnalyticsSummaryRow), + ctx => { + const stats = ctx.db.posthog.posthogDeliveryStats.singleton.find(true); + return [ + { + queued: stats?.pending ?? 0n, + delivered: stats?.delivered ?? 0n, + failed: stats?.failed ?? 0n, + }, + ]; + } +); diff --git a/spacetime-posthog-ts/example/spacetimedb/tsconfig.json b/spacetime-posthog-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..4b599551afe --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-posthog-ts/example/src/app.ts b/spacetime-posthog-ts/example/src/app.ts new file mode 100644 index 00000000000..d1bb834d7c0 --- /dev/null +++ b/spacetime-posthog-ts/example/src/app.ts @@ -0,0 +1,1007 @@ +import { DbConnection, type ErrorContext, type EventContext } from './codegen'; +import { + MAX_MACHINE_LEVEL, + RUSH_CYCLE_TICKS, + RUSH_LENGTH_TICKS, + SUPPLY_PRICE, + maximumQueueLength, + storageCapacity, + supplyCost, + upgradeCost, +} from '../spacetimedb/src/economy'; + +interface ServerConfig { + stdbUri: string; + database: string; + posthogAppUrl?: string | null; +} + +type TableEvents = { + iter(): Iterable; + onInsert(cb: (ctx: EventContext, row: T) => void): void; + onUpdate(cb: (ctx: EventContext, old: T, row: T) => void): void; + onDelete(cb: (ctx: EventContext, row: T) => void): void; +}; + +type ProductRow = { + productId: string; + name: string; + category: string; + description: string; + baseAppeal: number; + active: boolean; +}; + +type VariantRow = { + variantId: string; + productId: string; + name: string; + flavor: string; + contextTokens: number; + reasoning: number; + latency: number; + priceCents: number; + baselinePriceCents: number; + discountBps: number; + active: boolean; + featured: boolean; +}; + +type ScenarioRow = { + scenarioId: string; + name: string; + description: string; + trafficPerTick: number; +}; + +type ConfigRow = { + scenarioId: string; + tick: bigint; + experimentKey: string; + experimentVariant?: string; +}; + +type MetricsRow = { + tick: bigint; + views: bigint; + carts: bigint; + checkouts: bigint; + purchases: bigint; + abandons: bigint; + revenueCents: bigint; +}; + +type EconRow = { + cashCents: bigint; + computeUnits: number; + contextUnits: number; + memoryUnits: number; + suppliesSpentCents: bigint; + stockouts: number; + reputation: number; + workers: number; + machineLevel: number; + seats: number; + storageLevel: number; + reneged: number; +}; + +const BUY_UNITS = 50; + +type SessionRow = { + sessionId: bigint; + botId: string; + tick: bigint; + profile: string; + variantId?: string; + stage: string; + revenueCents: number; + reason: string; +}; + +type AnalyticsSummaryRow = { + queued: bigint; + delivered: bigint; + failed: bigint; +}; + +let conn: DbConnection | null = null; +let simTimer: ReturnType | null = null; +let running = false; +let selectedVariantId = ''; +let drawerOpen = false; +let speedMs = 900; + +let toastTimer: ReturnType | null = null; + +// Track served bots that appeared on the counter and animate each outcome once. +const seenSessionIds = new Set(); +let counterReady = false; + +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el; +} + +function input(id: string): HTMLInputElement { + return $(id) as HTMLInputElement; +} + +function select(id: string): HTMLSelectElement { + return $(id) as HTMLSelectElement; +} + +function setText(id: string, value: string): void { + $(id).textContent = value; +} + +// Ephemeral status line: slides in on a message, auto-dismisses success +// messages and holds errors until the next action clears them. +function showToast(message: string, kind: 'ok' | 'error' = 'ok'): void { + const el = $('toast'); + el.textContent = message; + el.className = `toast ${kind} show`; + if (toastTimer) { + clearTimeout(toastTimer); + toastTimer = null; + } + if (kind === 'ok') { + toastTimer = setTimeout(() => el.classList.remove('show'), 3000); + } +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function money(cents: number | bigint): string { + const n = typeof cents === 'bigint' ? Number(cents) : cents; + return `$${(n / 100).toFixed(2)}`; +} + +function pct(num: bigint, den: bigint): string { + if (den === 0n) return '0.0%'; + return `${(Number((num * 1000n) / den) / 10).toFixed(1)}%`; +} + +function requireConn(): DbConnection { + if (!conn) throw new Error('stdb.disconnected'); + return conn; +} + +function productsTable(): TableEvents { + return requireConn().db.cafeProducts; +} +function variantsTable(): TableEvents { + return requireConn().db.cafeVariants; +} +function scenariosTable(): TableEvents { + return requireConn().db.cafeScenarios; +} +function configTable(): TableEvents { + return requireConn().db.cafeConfig; +} +function metricsTable(): TableEvents { + return requireConn().db.cafeMetrics; +} +function sessionsTable(): TableEvents { + return requireConn().db.cafeRecentSessions; +} +function queueTable(): TableEvents { + return requireConn().db.cafeQueue; +} +function econTable(): TableEvents { + return requireConn().db.cafeEcon; +} + +function analyticsSummaryTable(): TableEvents { + return requireConn().db.cafeAnalyticsSummary; +} + +function rows(source: TableEvents): T[] { + return [...source.iter()]; +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + return (await r.json()) as ServerConfig; +} + +const TOKEN_KEY = 'context-cafe.stdb-token'; + +function connect(config: ServerConfig): Promise { + const attempt = (token: string | null): Promise => + new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(config.stdbUri) + .withDatabaseName(config.database) + // Persist the token so this browser keeps its identity across reloads. + .onConnect((c, _identity, tok) => { + try { + localStorage.setItem(TOKEN_KEY, tok); + } catch { + /* ignore */ + } + resolve(c); + }) + .onDisconnect((_ctx, err) => { + stopSimulation(); + showToast(err?.message ?? 'Disconnected.', 'error'); + }) + .onConnectError((_ctx, err) => reject(err)); + if (token) builder = builder.withToken(token); + builder.build(); + }); + + let saved: string | null = null; + try { + saved = localStorage.getItem(TOKEN_KEY); + } catch { + /* ignore */ + } + if (!saved) return attempt(null); + // A token rejected after a --delete-data republish + // is rejected with a 401. Drop it and reconnect with a fresh identity. + return attempt(saved).catch(err => { + console.warn( + 'Stored identity token rejected. Clearing it and reconnecting.', + err + ); + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + /* ignore */ + } + return attempt(null); + }); +} + +function effectivePriceCents(row: VariantRow): number { + return Math.round(row.priceCents * (1 - row.discountBps / 10000)); +} + +function variantById(id: string): VariantRow | undefined { + return rows(variantsTable()).find(v => v.variantId === id); +} + +function productById(id: string): ProductRow | undefined { + return rows(productsTable()).find(p => p.productId === id); +} + +function currentConfig(): ConfigRow | undefined { + return rows(configTable())[0]; +} + +function currentMetrics(): MetricsRow { + return ( + rows(metricsTable())[0] ?? { + tick: 0n, + views: 0n, + carts: 0n, + checkouts: 0n, + purchases: 0n, + abandons: 0n, + revenueCents: 0n, + } + ); +} + +// Rendering + +function renderKpis(): void { + const m = currentMetrics(); + const e = currentEcon(); + + // Wallet cash is set in renderEcon. Profit equals sales minus supply spend. + setText('walletRevenue', money(m.revenueCents)); + setText('walletSpent', money(e.suppliesSpentCents)); + const profit = m.revenueCents - e.suppliesSpentCents; + const profitEl = document.getElementById('walletProfit'); + if (profitEl) { + const mag = money(profit < 0n ? -profit : profit); + profitEl.textContent = `${profit < 0n ? '▼' : '▲'} ${mag} profit`; + profitEl.classList.toggle('up', profit > 0n); + profitEl.classList.toggle('down', profit < 0n); + } + + // Ticker. + setText('kpiConversion', pct(m.purchases, m.views)); + setText( + 'kpiAov', + m.purchases === 0n ? '$0.00' : money(m.revenueCents / m.purchases) + ); + setText('kpiTicks', m.tick.toString()); + setText( + 'kpiSent', + (rows(analyticsSummaryTable())[0]?.delivered ?? 0n).toString() + ); + setText('kpiReneged', String(e.reneged)); + const rushing = + Number(m.tick % BigInt(RUSH_CYCLE_TICKS)) < RUSH_LENGTH_TICKS && + m.tick > 0n; + document.getElementById('rushBadge')?.classList.toggle('on', rushing); + + const funnel: Array<[string, bigint, string]> = [ + ['views', m.views, ''], + ['carts', m.carts, ''], + ['bought', m.purchases, 'buy'], + ['walked', m.abandons, 'off'], + ]; + $('flowSummary').innerHTML = funnel + .map( + ([label, val, cls]) => + `${val.toString()} ${escapeHtml(label)}` + ) + .join(''); +} + +function currentEcon(): EconRow { + return ( + rows(econTable())[0] ?? { + cashCents: 0n, + computeUnits: 0, + contextUnits: 0, + memoryUnits: 0, + suppliesSpentCents: 0n, + stockouts: 0, + reputation: 0, + workers: 1, + machineLevel: 0, + seats: 0, + storageLevel: 0, + reneged: 0, + } + ); +} + +const SUPPLIES: Array<{ + kind: 'compute' | 'context' | 'memory'; + unitsKey: 'computeUnits' | 'contextUnits' | 'memoryUnits'; + id: string; + fillId: string; + capId: string; +}> = [ + { + kind: 'compute', + unitsKey: 'computeUnits', + id: 'bankCompute', + fillId: 'fillCompute', + capId: 'capCompute', + }, + { + kind: 'context', + unitsKey: 'contextUnits', + id: 'bankContext', + fillId: 'fillContext', + capId: 'capContext', + }, + { + kind: 'memory', + unitsKey: 'memoryUnits', + id: 'bankMemory', + fillId: 'fillMemory', + capId: 'capMemory', + }, +]; + +function renderEcon(): void { + const e = currentEcon(); + setText('econCash', money(e.cashCents)); + setText('econStockouts', String(e.stockouts)); + for (const s of SUPPLIES) { + const units = e[s.unitsKey]; + const cap = storageCapacity(s.kind, e.storageLevel); + const low = units < 20; + const el = document.getElementById(s.id); + if (el) { + el.textContent = String(units); + el.classList.toggle('low', low); + } + const capEl = document.getElementById(s.capId); + if (capEl) capEl.textContent = `/${cap}`; + const fill = document.getElementById(s.fillId); + if (fill) { + fill.style.width = `${Math.max(0, Math.min(100, Math.round((units / cap) * 100)))}%`; + fill.classList.toggle('low', low); + } + const btn = document.querySelector( + `[data-supply="${s.kind}"]` + ) as HTMLButtonElement | null; + if (btn) { + const headroom = cap - units; + const full = headroom <= 0; + btn.disabled = + full || + e.cashCents < + BigInt(Math.min(BUY_UNITS, headroom) * SUPPLY_PRICE[s.kind]); + btn.textContent = full + ? 'Full' + : `+50 · ${money(BUY_UNITS * SUPPLY_PRICE[s.kind])}`; + } + } + renderReputation(e); + renderUpgrades(e); +} + +function renderReputation(e: EconRow): void { + const filled = Math.round(e.reputation / 20); + setText('repStars', '★'.repeat(filled) + '☆'.repeat(5 - filled)); + setText('repValue', String(e.reputation)); +} + +function renderUpgrades(e: EconRow): void { + setText('upWorkers', `${e.workers}/tick`); + setText( + 'upMachine', + e.machineLevel > 0 ? `−${e.machineLevel * 8}% supplies` : 'standard' + ); + setText('upCounter', `holds ${maximumQueueLength(e)}`); + setText( + 'upStorage', + e.storageLevel > 0 ? `+${e.storageLevel * 50}% space` : 'standard' + ); + + const maxed = e.machineLevel >= MAX_MACHINE_LEVEL; + setBuy('worker', upgradeCost('worker', e), e.cashCents); + setBuy( + 'machine', + upgradeCost('machine', e), + e.cashCents, + maxed, + maxed ? 'Maxed' : undefined + ); + setBuy('counter', upgradeCost('counter', e), e.cashCents); + setBuy('storage', upgradeCost('storage', e), e.cashCents); +} + +function setBuy( + kind: string, + costCents: number | bigint, + cash: bigint, + force = false, + label?: string +): void { + const btn = document.querySelector( + `[data-upgrade="${kind}"]` + ) as HTMLButtonElement | null; + if (!btn) return; + btn.textContent = label ?? money(costCents); + btn.disabled = force || cash < BigInt(costCents); +} + +function activeScenarioId(): string { + return ( + currentConfig()?.scenarioId || rows(scenariosTable())[0]?.scenarioId || '' + ); +} + +function renderMenu(): void { + const products = new Map(rows(productsTable()).map(p => [p.productId, p])); + const variants = rows(variantsTable()).sort((a, b) => { + const pa = products.get(a.productId)?.name ?? ''; + const pb = products.get(b.productId)?.name ?? ''; + return pa.localeCompare(pb) || a.name.localeCompare(b.name); + }); + + if (variants.length === 0) { + $('menuGrid').innerHTML = + '
        Waiting for catalog sync.
        '; + return; + } + + $('menuGrid').innerHTML = variants + .map(v => { + const product = products.get(v.productId); + const off = !v.active || (product ? !product.active : false); + const discounted = v.discountBps > 0; + const badges = [ + v.featured ? 'Featured' : '', + discounted + ? `-${v.discountBps / 100}%` + : '', + off ? 'Off' : '', + ].join(''); + return ` + + `; + }) + .join(''); +} + +function renderDrawer(): void { + if (!drawerOpen) return; + const variant = variantById(selectedVariantId); + if (!variant) { + closeDrawer(); + return; + } + const product = productById(variant.productId); + setText('drawerTitle', variant.name); + setText('drawerSub', `${product?.name ?? ''} · ${product?.category ?? ''}`); + + input('priceInput').value = (variant.priceCents / 100).toFixed(2); + input('discountInput').value = String(variant.discountBps / 100); + + const cost = supplyCost( + product ?? { category: '' }, + variant, + currentEcon().machineLevel + ); + const costCents = + cost.compute * SUPPLY_PRICE.compute + + cost.context * SUPPLY_PRICE.context + + cost.memory * SUPPLY_PRICE.memory; + const margin = effectivePriceCents(variant) - costCents; + const units = (n: number) => `${n} unit${n === 1 ? '' : 's'}`; + + $('variantDetails').innerHTML = ` +
        + Context window${variant.contextTokens.toLocaleString()} tokens + Reasoning${variant.reasoning}/10 + Latency${variant.latency}/10 + Price${money(effectivePriceCents(variant))}${variant.discountBps > 0 ? ` (was ${money(variant.priceCents)})` : ''} +
        +
        Uses per cup
        +
        + Compute${units(cost.compute)} + Context${units(cost.context)} + Memory${units(cost.memory)} + Cost to make${money(costCents)} + Margin / cup${margin < 0 ? '−' : '+'}${money(Math.abs(margin))} +
        + `; + + setText('featureVariant', variant.featured ? 'Featured ✓' : 'Feature'); + $('featureVariant').classList.toggle('is-on', variant.featured); + setText('toggleVariant', variant.active ? 'Disable recipe' : 'Enable recipe'); + setText( + 'toggleProduct', + product?.active + ? `Disable ${product?.name ?? 'product'}` + : `Enable ${product?.name ?? 'product'}` + ); +} + +function openDrawer(variantId: string): void { + selectedVariantId = variantId; + drawerOpen = true; + $('drawer').classList.add('open'); + $('drawerScrim').classList.add('open'); + renderDrawer(); + renderMenu(); +} + +function closeDrawer(): void { + drawerOpen = false; + $('drawer').classList.remove('open'); + $('drawerScrim').classList.remove('open'); + renderMenu(); +} + +function cap(value: string): string { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : value; +} + +type QueueRow = { + queueId: bigint; + botId: string; + profile: string; + scenarioId: string; + productId: string; + variantId: string; + wants: string; + thrifty: boolean; + arrivedTick: bigint; +}; + +function botInner(row: QueueRow, drink: string): string { + const wants = `wants ${escapeHtml(row.wants)}${row.thrifty ? ' · price-sensitive' : ''}`; + return ` +
        ${escapeHtml(cap(row.profile))} bot
        ${wants}
        eyeing ${escapeHtml(drink)}
        +
        🤖
        +
        ${escapeHtml(row.profile)}
        +
        ${escapeHtml(row.wants)}${row.thrifty ? ' 💸' : ''}
        `; +} + +// Render the waiting line, diffing against the live DOM so only changes animate. +function renderCounter(): void { + const bots = document.getElementById('counterBots'); + if (!bots) return; + const queue = [...rows(queueTable())].sort((a, b) => + a.queueId < b.queueId ? -1 : a.queueId > b.queueId ? 1 : 0 + ); + + if (queue.length === 0) { + if (!bots.querySelector('.empty')) + bots.innerHTML = '
        Press Run to open the queue.
        '; + } else { + const placeholder = bots.querySelector('.empty'); + if (placeholder) bots.innerHTML = ''; + + const variants = new Map( + rows(variantsTable()).map(row => [row.variantId, row]) + ); + const desired = queue.slice(0, 12); // front (lowest queueId) is served next, shown leftmost + const desiredIds = new Set(desired.map(row => row.queueId.toString())); + + // Bots that were served leave the line. + for (const el of Array.from(bots.children) as HTMLElement[]) { + const id = el.getAttribute('data-queue-id'); + if (id && !desiredIds.has(id) && !el.classList.contains('leaving')) { + el.classList.add('leaving'); + window.setTimeout(() => el.remove(), 480); + } + } + + const existing = new Set(); + for (const el of Array.from(bots.children) as HTMLElement[]) { + const id = el.getAttribute('data-queue-id'); + if (id) existing.add(id); + } + + let added = 0; + for (const row of desired) { + const id = row.queueId.toString(); + if (existing.has(id)) continue; + const drink = variants.get(row.variantId)?.name ?? 'a drink'; + const node = document.createElement('div'); + node.className = `bot waiting${row.thrifty ? ' thrifty' : ''} entering`; + node.setAttribute('data-queue-id', id); + node.style.animationDelay = `${added * 60}ms`; + node.innerHTML = botInner(row, drink); + bots.appendChild(node); + const delay = added * 60; + window.setTimeout(() => { + node.classList.remove('entering'); + node.style.animationDelay = ''; + }, 480 + delay); + added++; + } + } + + detectOutcomes(); +} + +// Pop each served bot: green "+$" on a sale, red "no sale" otherwise. Seed the +// first batch silently to prevent replaying past outcomes after a reload. +function detectOutcomes(): void { + const sessions = rows(sessionsTable()); + if (sessions.length === 0) { + seenSessionIds.clear(); + counterReady = false; + return; + } + if (!counterReady) { + for (const row of sessions) seenSessionIds.add(row.sessionId.toString()); + counterReady = true; + return; + } + // Oldest-first so a burst pops in the order it happened. + const fresh = sessions + .filter(row => !seenSessionIds.has(row.sessionId.toString())) + .sort((a, b) => + a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0 + ); + // Fan a batch out so simultaneous serves don't stack on the same spot. + fresh.forEach((row, i) => { + seenSessionIds.add(row.sessionId.toString()); + if (row.stage === 'purchased') + spawnPop(`+${money(row.revenueCents)}`, 'sale', i); + else spawnPop(missReason(row.reason), 'miss', i); + }); +} + +// Friendly counter caption for why a bot left without buying. +function missReason(reason: string): string { + switch (reason) { + case 'price': + return 'too pricey'; + case 'slow': + return 'too slow'; + case 'want_vision': + return 'wanted image support'; + case 'want_memory': + return 'wanted memory'; + case 'want_smart': + return 'wanted more reasoning'; + case 'want_premium': + return 'wanted top quality'; + case 'meh': + return 'changed its mind'; + case 'waited': + return 'gave up waiting'; + case 'short_context': + return 'out of context'; + case 'short_compute': + return 'out of compute'; + case 'short_memory': + return 'out of memory'; + case 'unavailable': + return 'off the menu'; + default: + return 'no sale'; + } +} + +function spawnPop(text: string, variant: 'sale' | 'miss', index = 0): void { + const pops = document.getElementById('counterPops'); + const bots = document.getElementById('counterBots'); + if (!pops) return; + // Anchor near the counter, then fan a batch rightward + stagger so they don't overlap. + const anchor = bots?.querySelector('.bot') as HTMLElement | null; + const baseLeft = anchor ? anchor.offsetLeft + anchor.offsetWidth / 2 : 30; + const baseTop = anchor ? anchor.offsetTop + 4 : 14; + const pop = document.createElement('div'); + pop.className = `pop ${variant}`; + pop.textContent = text; + pop.style.left = `${baseLeft + (index % 5) * 62}px`; + pop.style.top = `${baseTop - (index % 2) * 12}px`; + pop.style.animationDelay = `${index * 90}ms`; + pops.appendChild(pop); + pop.addEventListener('animationend', () => pop.remove()); +} + +function renderAll(): void { + if (!conn) return; + renderKpis(); + renderEcon(); + renderMenu(); + renderDrawer(); + renderCounter(); + syncRunButton(); +} + +function syncRunButton(): void { + const btn = document.getElementById('runToggle'); + if (!btn) return; + btn.classList.toggle('running', running); + const label = btn.querySelector('.run-label'); + if (label) label.textContent = running ? 'Pause' : 'Run'; +} + +// Simulation controls + +function restartTimer(): void { + if (simTimer) clearInterval(simTimer); + simTimer = null; + if (!running) return; + simTimer = setInterval(() => { + void tickSimulation().catch(err => { + stopSimulation(); + showToast(err instanceof Error ? err.message : String(err), 'error'); + }); + }, speedMs); +} + +function stopSimulation(): void { + running = false; + if (simTimer) clearInterval(simTimer); + simTimer = null; + syncRunButton(); +} + +async function tickSimulation(): Promise { + requireConn().reducers.simulateTick({ + ticks: 1, + seed: `${Date.now()}:${Math.random()}`, + }); +} + +// UI wiring + +function wireUi(): void { + $('menuGrid').addEventListener('click', event => { + const card = (event.target as HTMLElement).closest( + '[data-variant-id]' + ) as HTMLElement | null; + if (card?.dataset.variantId) openDrawer(card.dataset.variantId); + }); + $('drawerClose').addEventListener('click', () => closeDrawer()); + $('drawerScrim').addEventListener('click', () => closeDrawer()); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && drawerOpen) closeDrawer(); + }); +} + +function guard(fn: () => Promise): () => Promise { + return async () => { + try { + await fn(); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), 'error'); + } + }; +} + +function wireActions(): void { + $('runToggle').addEventListener('click', () => { + running = !running; + restartTimer(); + renderAll(); + }); + $('tickOnce').addEventListener( + 'click', + guard(() => tickSimulation()) + ); + select('speedSelect').addEventListener('change', () => { + speedMs = Number(select('speedSelect').value); + restartTimer(); + }); + $('resetSim').addEventListener( + 'click', + guard(async () => { + stopSimulation(); + requireConn().reducers.resetSimulation({ + scenarioId: activeScenarioId(), + }); + showToast('Simulation reset.'); + }) + ); + $('savePrice').addEventListener( + 'click', + guard(async () => { + const cents = Math.max( + 0, + Math.round(Number(input('priceInput').value) * 100) + ); + requireConn().reducers.setVariantPrice({ + variantId: selectedVariantId, + priceCents: cents, + }); + }) + ); + $('saveDiscount').addEventListener( + 'click', + guard(async () => { + const bps = Math.max( + 0, + Math.min(9000, Math.round(Number(input('discountInput').value) * 100)) + ); + requireConn().reducers.setVariantDiscount({ + variantId: selectedVariantId, + discountBps: bps, + }); + }) + ); + $('featureVariant').addEventListener( + 'click', + guard(async () => { + requireConn().reducers.setFeaturedVariant({ + variantId: selectedVariantId, + }); + }) + ); + $('toggleVariant').addEventListener( + 'click', + guard(async () => { + const row = variantById(selectedVariantId); + if (!row) throw new Error('No recipe selected.'); + requireConn().reducers.setVariantActive({ + variantId: selectedVariantId, + active: !row.active, + }); + }) + ); + $('toggleProduct').addEventListener( + 'click', + guard(async () => { + const variant = variantById(selectedVariantId); + const product = variant ? productById(variant.productId) : undefined; + if (!product) throw new Error('No product selected.'); + requireConn().reducers.setProductActive({ + productId: product.productId, + active: !product.active, + }); + }) + ); + $('bankGrid').addEventListener('click', event => { + const btn = (event.target as HTMLElement).closest( + '[data-supply]' + ) as HTMLElement | null; + const kind = btn?.dataset.supply; + if (!kind) return; + void guard(async () => { + requireConn().reducers.buySupply({ kind, units: BUY_UNITS }); + })(); + }); + $('upgradeGrid').addEventListener('click', event => { + const btn = (event.target as HTMLElement).closest( + '[data-upgrade]' + ) as HTMLElement | null; + const kind = btn?.dataset.upgrade; + if (!kind) return; + void guard(async () => { + requireConn().reducers.buyUpgrade({ kind }); + })(); + }); +} + +function wireTableEvents(): void { + const render = () => renderAll(); + const sources = [ + productsTable(), + variantsTable(), + scenariosTable(), + configTable(), + metricsTable(), + sessionsTable(), + queueTable(), + econTable(), + analyticsSummaryTable(), + ]; + for (const source of sources) { + source.onInsert(render); + source.onUpdate(render); + source.onDelete(render); + } +} + +async function run(): Promise { + // Wire the chrome first so the menu and drawer are interactive immediately. + wireUi(); + wireActions(); + + const config = await loadServerConfig(); + + // The link ships with a working default href in the HTML; upgrade it to the + // configured PostHog host when the server provides one. + const link = document.getElementById( + 'posthogLink' + ) as HTMLAnchorElement | null; + if (link && config.posthogAppUrl) { + link.href = config.posthogAppUrl; + } + + conn = await connect(config); + + // Seed this browser's café (idempotent); rows stream in via the subscriptions. + try { + requireConn().reducers.initSession({}); + } catch (err) { + console.error('init_session failed', err); + } + + conn + .subscriptionBuilder() + .onApplied(() => { + renderAll(); + showToast('Context Cafe ready.'); + }) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe([ + 'SELECT * FROM cafe_products', + 'SELECT * FROM cafe_variants', + 'SELECT * FROM cafe_scenarios', + 'SELECT * FROM cafe_config', + 'SELECT * FROM cafe_metrics', + 'SELECT * FROM cafe_econ', + 'SELECT * FROM cafe_queue', + 'SELECT * FROM cafe_recent_sessions', + 'SELECT * FROM cafe_analytics_summary', + ]); + + wireTableEvents(); + + renderAll(); +} + +run().catch(err => { + showToast(err instanceof Error ? err.message : String(err), 'error'); +}); diff --git a/spacetime-posthog-ts/example/tsconfig.json b/spacetime-posthog-ts/example/tsconfig.json new file mode 100644 index 00000000000..3f3a247c57c --- /dev/null +++ b/spacetime-posthog-ts/example/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "public/app.js", "public/app.js.map"] +} diff --git a/spacetime-posthog-ts/package.json b/spacetime-posthog-ts/package.json new file mode 100644 index 00000000000..d3c73c1f9fd --- /dev/null +++ b/spacetime-posthog-ts/package.json @@ -0,0 +1,65 @@ +{ + "name": "@spacetimedb/posthog", + "description": "PostHog capture, durable outbox, delivery logs, and feature flags for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-posthog-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-posthog-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "posthog", + "analytics", + "typescript" + ], + "scripts": { + "build": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts", + "generate-ts": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-posthog", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-posthog" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-posthog-ts/scripts/test.ts b/spacetime-posthog-ts/scripts/test.ts new file mode 100644 index 00000000000..22a82476327 --- /dev/null +++ b/spacetime-posthog-ts/scripts/test.ts @@ -0,0 +1,92 @@ +import * as assert from 'node:assert/strict'; +import { + isOkStatus, + toStatusCode, + truncateForLog, +} from '../src/submodule/value-utils.ts'; +import { + MAX_DELIVERY_ATTEMPTS, + claimHasExpired, + claimOutboxRow, + releaseExpiredClaim, + retryDelayMicros, + settleOutboxClaim, +} from '../src/submodule/outbox-state.ts'; + +assert.equal(isOkStatus(200), true); +assert.equal(isOkStatus(299), true); +assert.equal(isOkStatus(300), false); +assert.equal(toStatusCode(65535), 65535); +assert.equal(toStatusCode(65536), 0); +assert.equal(truncateForLog('x'.repeat(3000)).length, 2051); + +const timestamp = { microsSinceUnixEpoch: 10_000_000n }; +const queued = { + outboxId: 'event-1', + status: { tag: 'Queued' }, + attempts: 0, + claimId: undefined, + claimExpiresAtMicros: 0n, + nextAttemptAt: timestamp, + lastStatusCode: undefined, + lastError: undefined, + updatedAt: timestamp, + deliveredAt: undefined, +}; + +const claimed = claimOutboxRow(queued, 'claim-1', 15_000_000n, timestamp); +assert.equal(claimed.status.tag, 'Processing'); +assert.equal(claimed.claimId, 'claim-1'); +assert.equal(claimHasExpired(claimed, 14_999_999n), false); +assert.equal(claimHasExpired(claimed, 15_000_000n), true); + +const released = releaseExpiredClaim(claimed, timestamp); +assert.equal(released.status.tag, 'Queued'); +assert.equal(released.claimId, undefined); +assert.equal(released.claimExpiresAtMicros, 0n); +assert.equal(retryDelayMicros(1), 1_000_000n); +assert.equal(retryDelayMicros(2), 2_000_000n); +assert.equal(retryDelayMicros(20), 300_000_000n); + +let retrying = claimed; +for (let attempt = 1; attempt < MAX_DELIVERY_ATTEMPTS; attempt++) { + const settled = settleOutboxClaim( + retrying, + { ok: false, statusCode: 503, responseBody: 'unavailable' }, + timestamp, + { microsSinceUnixEpoch: 11_000_000n } + ); + assert.equal(settled.row.attempts, attempt); + assert.equal(settled.terminal, false); + assert.equal(settled.row.status.tag, 'Queued'); + retrying = claimOutboxRow( + settled.row, + `claim-${attempt + 1}`, + 15_000_000n, + timestamp + ); +} + +const exhausted = settleOutboxClaim( + retrying, + { ok: false, statusCode: 503, responseBody: 'unavailable' }, + timestamp, + { microsSinceUnixEpoch: 11_000_000n } +); +assert.equal(exhausted.row.attempts, MAX_DELIVERY_ATTEMPTS); +assert.equal(exhausted.terminal, true); +assert.equal(exhausted.row.status.tag, 'Failed'); +assert.equal(exhausted.row.lastError, 'unavailable'); + +const delivered = settleOutboxClaim( + claimed, + { ok: true, statusCode: 200, responseBody: 'ok' }, + timestamp, + timestamp +); +assert.equal(delivered.terminal, true); +assert.equal(delivered.row.status.tag, 'Delivered'); +assert.equal(delivered.row.deliveredAt, timestamp); +assert.equal(delivered.row.lastError, undefined); + +console.log('posthog tests passed'); diff --git a/spacetime-posthog-ts/src/index.ts b/spacetime-posthog-ts/src/index.ts new file mode 100644 index 00000000000..75a6e6b4034 --- /dev/null +++ b/spacetime-posthog-ts/src/index.ts @@ -0,0 +1,16 @@ +// Top-level entry. Only re-exports registered STDB exports. + +export { default, init } from './submodule/schema'; +export { + set_posthog_config, + get_posthog_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; +export { + capture_now, + enqueue_event, + flush_outbox, + get_feature_flag, + posthogDeliveryLogAdmin, + posthogOutboxAdmin, +} from './submodule/operations'; diff --git a/spacetime-posthog-ts/src/submodule.ts b/spacetime-posthog-ts/src/submodule.ts new file mode 100644 index 00000000000..ccd9adbffa5 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule.ts @@ -0,0 +1,26 @@ +export { default } from './submodule/schema'; +export { + OutboxStatus, + posthogDeliveryLog, + posthogDeliveryStats, + posthogOutbox, + t, +} from './submodule/schema'; +export { installPostHog } from './submodule/install'; +export { + set_posthog_config, + get_posthog_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; +export { + captureNow, + clearAnalytics, + enqueueEvent, + flushOutbox, + capture_now, + enqueue_event, + flush_outbox, + get_feature_flag, + posthogDeliveryLogAdmin, + posthogOutboxAdmin, +} from './submodule/operations'; diff --git a/spacetime-posthog-ts/src/submodule/auth.ts b/spacetime-posthog-ts/src/submodule/auth.ts new file mode 100644 index 00000000000..d8508c7b41b --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/auth.ts @@ -0,0 +1,58 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { throwSenderError } from './utils'; + +type Sender = WriteCtx['sender']; +type AdminReadableCtx = { + db: { + posthogAdminIdentity: { + identity: { find(identity: Sender): unknown }; + }; + }; +}; + +export function isAdmin(ctx: AdminReadableCtx, sender: Sender): boolean { + return ctx.db.posthogAdminIdentity.identity.find(sender) != null; +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError('posthog.not_authorized'); +} + +export const add_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + if (tx.db.posthogAdminIdentity.identity.find(identity) == null) { + tx.db.posthogAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + }); + return {}; + } +); + +export const remove_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + const existing = tx.db.posthogAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.posthogAdminIdentity.count() <= 1n) { + throwSenderError('posthog.cannot_remove_last_admin'); + } + tx.db.posthogAdminIdentity.delete(existing); + }); + return {}; + } +); diff --git a/spacetime-posthog-ts/src/submodule/config.ts b/spacetime-posthog-ts/src/submodule/config.ts new file mode 100644 index 00000000000..9036b815cb4 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/config.ts @@ -0,0 +1,80 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { requireAdmin } from './auth'; +import { normalizeHost, throwSenderError } from './utils'; + +export type PostHogConfig = { + host: string; + projectApiKey: string; +}; + +export function loadConfigOrThrow(ctx: WriteCtx): PostHogConfig { + const row = ctx.db.posthogConfig.singleton.find(true); + if (!row) { + throwSenderError('posthog.config_missing'); + } + return { + host: row.host, + projectApiKey: row.projectApiKey, + }; +} + +export function loadConfigOrThrowFromProcedure( + ctx: ProcedureModuleCtx +): PostHogConfig { + return ctx.withTx(tx => loadConfigOrThrow(tx)); +} + +export const set_posthog_config = spacetimedb.procedure( + { + host: t.string(), + projectApiKey: t.string(), + }, + t.unit(), + (ctx, args) => { + const host = normalizeHost(args.host); + const projectApiKey = args.projectApiKey.trim(); + if (!projectApiKey) throwSenderError('posthog.invalid_project_api_key'); + ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + const existing = tx.db.posthogConfig.singleton.find(true); + const row = { + singleton: true, + host, + projectApiKey, + updatedAt: ctx.timestamp, + }; + if (!existing) { + tx.db.posthogConfig.insert(row); + } else { + tx.db.posthogConfig.singleton.update(row); + } + }); + return {}; + } +); + +export const get_posthog_config_status = spacetimedb.procedure( + {}, + t.string(), + ctx => + ctx.withTx(tx => { + const row = tx.db.posthogConfig.singleton.find(true); + if (!row) { + return JSON.stringify({ + isConfigured: false, + host: undefined, + projectApiKeyLength: 0, + }); + } + return JSON.stringify({ + isConfigured: true, + host: row.host, + projectApiKeyLength: row.projectApiKey.length, + }); + }) +); diff --git a/spacetime-posthog-ts/src/submodule/http.ts b/spacetime-posthog-ts/src/submodule/http.ts new file mode 100644 index 00000000000..4b787cc97f2 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/http.ts @@ -0,0 +1,29 @@ +import type { ProcedureModuleCtx } from './schema'; +import type { PostHogConfig } from './config'; +import { isOkStatus, toStatusCode, truncateForLog } from './utils'; + +export type PostHogHttpResult = { + ok: boolean; + statusCode: number; + responseBody: string; +}; + +export function posthogFetch( + ctx: ProcedureModuleCtx, + cfg: PostHogConfig, + path: string, + body: unknown +): PostHogHttpResult { + const response = ctx.http.fetch(`${cfg.host}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const statusCode = toStatusCode(response.status); + const responseBody = truncateForLog(response.text()); + return { + ok: isOkStatus(statusCode), + statusCode, + responseBody, + }; +} diff --git a/spacetime-posthog-ts/src/submodule/install.ts b/spacetime-posthog-ts/src/submodule/install.ts new file mode 100644 index 00000000000..5201e9cf5c0 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/install.ts @@ -0,0 +1,9 @@ +import type { ReducerModuleCtx } from './schema'; + +export function installPostHog(ctx: ReducerModuleCtx) { + if (ctx.db.posthogAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.posthogAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-posthog-ts/src/submodule/operations.ts b/spacetime-posthog-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..e44b6dd6d96 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/operations.ts @@ -0,0 +1,511 @@ +import { Range } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import { + DeliverySource, + OutboxStatus, + deliverySource, + posthogDeliveryLogRow, + posthogOutbox, + spacetimedb, + t, + type ProcedureModuleCtx, + type ViewModuleCtx, + type WriteCtx, +} from './schema'; +import { loadConfigOrThrowFromProcedure } from './config'; +import { posthogFetch, type PostHogHttpResult } from './http'; +import { isAdmin, requireAdmin } from './auth'; +import { isOkStatus, parseJsonObject, throwSenderError } from './utils'; +import { + claimHasExpired, + claimOutboxRow, + releaseExpiredClaim, + retryDelayMicros, + settleOutboxClaim, +} from './outbox-state'; + +const DEFAULT_FLUSH_LIMIT = 25; +const MAX_FLUSH_LIMIT = 100; +const CLAIM_TTL_MICROS = 5n * 60n * 1_000_000n; +const MAX_EXPIRED_CLAIMS_PER_FLUSH = 1_000; +const MAX_DISTINCT_ID_LENGTH = 256; +const MAX_EVENT_NAME_LENGTH = 200; +const MAX_PROPERTIES_JSON_LENGTH = 64 * 1024; +const MAX_IDEMPOTENCY_KEY_LENGTH = 256; +const HISTORY_RETENTION_MICROS = 30n * 24n * 60n * 60n * 1_000_000n; +const MAX_RETENTION_ROWS_PER_CALL = 100; + +function takeRows(rows: Iterable, limit: number): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +function updateDeliveryStats( + ctx: WriteCtx, + delta: { pending?: bigint; delivered?: bigint; failed?: bigint } +): void { + const existing = ctx.db.posthogDeliveryStats.singleton.find(true); + const current = existing ?? { + singleton: true, + pending: 0n, + delivered: 0n, + failed: 0n, + updatedAt: ctx.timestamp, + }; + const adjust = (value: bigint, change = 0n) => { + const next = value + change; + return next < 0n ? 0n : next; + }; + const row = { + ...current, + pending: adjust(current.pending, delta.pending), + delivered: adjust(current.delivered, delta.delivered), + failed: adjust(current.failed, delta.failed), + updatedAt: ctx.timestamp, + }; + if (existing) ctx.db.posthogDeliveryStats.singleton.update(row); + else ctx.db.posthogDeliveryStats.insert(row); +} + +export type EnqueueEventArgs = { + distinctId: string; + event: string; + propertiesJson?: string | undefined; + idempotencyKey?: string | undefined; +}; + +export type CaptureEventArgs = { + distinctId: string; + event: string; + propertiesJson?: string | undefined; +}; + +function validateEventInput(args: CaptureEventArgs): void { + const distinctId = args.distinctId.trim(); + const event = args.event.trim(); + if (!distinctId || distinctId.length > MAX_DISTINCT_ID_LENGTH) { + throwSenderError('posthog.invalid_distinct_id'); + } + if (!event || event.length > MAX_EVENT_NAME_LENGTH) { + throwSenderError('posthog.invalid_event'); + } + if ((args.propertiesJson?.length ?? 0) > MAX_PROPERTIES_JSON_LENGTH) { + throwSenderError('posthog.properties_too_large'); + } + parseJsonObject(args.propertiesJson, 'properties'); +} + +function buildBatchBody(projectApiKey: string, events: CaptureEventArgs[]) { + return { + api_key: projectApiKey, + batch: events.map(event => ({ + distinct_id: event.distinctId, + event: event.event, + properties: parseJsonObject(event.propertiesJson, 'properties') ?? {}, + })), + }; +} + +function outboxIdFor( + ctx: WriteCtx, + idempotencyKey: string | undefined +): string { + if (idempotencyKey !== undefined && idempotencyKey.trim()) { + return `idem:${idempotencyKey.trim()}`; + } + return `evt:${ctx.newUuidV7().toString()}`; +} + +export function enqueueEvent(ctx: WriteCtx, args: EnqueueEventArgs) { + validateEventInput(args); + if ((args.idempotencyKey?.length ?? 0) > MAX_IDEMPOTENCY_KEY_LENGTH) { + throwSenderError('posthog.idempotency_key_too_long'); + } + const outboxId = outboxIdFor(ctx, args.idempotencyKey); + const existing = ctx.db.posthogOutbox.outboxId.find(outboxId); + if (existing) { + return { outboxId, inserted: false }; + } + ctx.db.posthogOutbox.insert({ + outboxId, + idempotencyKey: args.idempotencyKey, + distinctId: args.distinctId, + event: args.event, + propertiesJson: args.propertiesJson, + status: OutboxStatus.Queued, + attempts: 0, + claimId: undefined, + claimExpiresAtMicros: 0n, + nextAttemptAt: ctx.timestamp, + lastStatusCode: undefined, + lastError: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + deliveredAt: undefined, + }); + updateDeliveryStats(ctx, { pending: 1n }); + return { outboxId, inserted: true }; +} + +function pruneDeliveryHistory( + ctx: WriteCtx, + maxRows = MAX_RETENTION_ROWS_PER_CALL +): number { + const cutoff = new Timestamp( + ctx.timestamp.microsSinceUnixEpoch - HISTORY_RETENTION_MICROS + ); + let removed = 0; + for (const status of [OutboxStatus.Delivered, OutboxStatus.Failed]) { + for (const row of ctx.db.posthogOutbox.byStatusUpdatedAt.filter([ + status, + new Range(undefined, { tag: 'included', value: cutoff }), + ])) { + if (removed >= maxRows) return removed; + ctx.db.posthogOutbox.delete(row); + removed++; + } + } + for (const row of ctx.db.posthogDeliveryLog.byAttemptedAt.filter( + new Range(undefined, { tag: 'included', value: cutoff }) + )) { + if (removed >= maxRows) break; + ctx.db.posthogDeliveryLog.delete(row); + removed++; + } + return removed; +} + +// Remove queued and delivered outbox entries plus the delivery log for bounded +// demo and test resets. Events received by PostHog remain at the provider. +export function clearAnalytics( + ctx: WriteCtx, + maxRows = 1000 +): { outbox: number; deliveries: number } { + if (!Number.isInteger(maxRows) || maxRows <= 0 || maxRows > 10_000) { + throwSenderError('posthog.invalid_clear_batch'); + } + let outbox = 0; + let deliveries = 0; + let pendingRemoved = 0n; + let deliveredRemoved = 0n; + let failedRemoved = 0n; + for (const row of ctx.db.posthogOutbox.iter()) { + if (outbox + deliveries >= maxRows) break; + if (row.status.tag === 'Queued' || row.status.tag === 'Processing') + pendingRemoved += 1n; + ctx.db.posthogOutbox.delete(row); + outbox++; + } + for (const row of ctx.db.posthogDeliveryLog.iter()) { + if (outbox + deliveries >= maxRows) break; + if (row.ok) deliveredRemoved += 1n; + else failedRemoved += 1n; + ctx.db.posthogDeliveryLog.delete(row); + deliveries++; + } + updateDeliveryStats(ctx, { + pending: -pendingRemoved, + delivered: -deliveredRemoved, + failed: -failedRemoved, + }); + return { outbox, deliveries }; +} + +function logDelivery( + ctx: WriteCtx, + source: (typeof DeliverySource)[keyof typeof DeliverySource], + outboxId: string | undefined, + event: CaptureEventArgs, + result: PostHogHttpResult +) { + ctx.db.posthogDeliveryLog.insert({ + deliveryId: 0n, + source, + outboxId, + distinctId: event.distinctId, + event: event.event, + ok: result.ok, + statusCode: result.statusCode, + responseBody: result.responseBody, + errorMessage: result.ok ? undefined : result.responseBody, + attemptedAt: ctx.timestamp, + attemptedAtOrder: -ctx.timestamp.microsSinceUnixEpoch, + }); + updateDeliveryStats(ctx, result.ok ? { delivered: 1n } : { failed: 1n }); +} + +export function captureNow( + ctx: ProcedureModuleCtx, + args: CaptureEventArgs +): PostHogHttpResult { + validateEventInput(args); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const result = posthogFetch( + ctx, + cfg, + '/batch', + buildBatchBody(cfg.projectApiKey, [args]) + ); + ctx.withTx(tx => { + logDelivery(tx, DeliverySource.Direct, undefined, args, result); + pruneDeliveryHistory(tx); + }); + return result; +} + +function claimQueuedRows(ctx: WriteCtx, limit: number) { + const nowMicros = ctx.timestamp.microsSinceUnixEpoch; + let inspected = 0; + for (const row of ctx.db.posthogOutbox.byStatusClaimExpiresAtMicros.filter([ + OutboxStatus.Processing, + new Range(undefined, { tag: 'included', value: nowMicros }), + ])) { + if (inspected >= MAX_EXPIRED_CLAIMS_PER_FLUSH) break; + inspected++; + if (!claimHasExpired(row, nowMicros)) continue; + ctx.db.posthogOutbox.outboxId.update( + releaseExpiredClaim(row, ctx.timestamp) + ); + } + + const rows = takeRows( + ctx.db.posthogOutbox.byStatusNextAttemptAt.filter([ + OutboxStatus.Queued, + new Range(undefined, { tag: 'included', value: ctx.timestamp }), + ]), + limit + ); + const claimId = ctx.newUuidV7().toString(); + const claimed = rows.map(row => + claimOutboxRow(row, claimId, nowMicros + CLAIM_TTL_MICROS, ctx.timestamp) + ); + for (const row of claimed) ctx.db.posthogOutbox.outboxId.update(row); + return { claimId, rows: claimed }; +} + +export function flushOutbox( + ctx: ProcedureModuleCtx, + args: { limit?: number | undefined } +) { + const rawLimit = args.limit ?? DEFAULT_FLUSH_LIMIT; + if ( + !Number.isInteger(rawLimit) || + rawLimit <= 0 || + rawLimit > MAX_FLUSH_LIMIT + ) { + throwSenderError('posthog.invalid_flush_limit'); + } + const cfg = loadConfigOrThrowFromProcedure(ctx); + const claim = ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + return claimQueuedRows(tx, rawLimit); + }); + const rows = claim.rows; + if (rows.length === 0) { + return { attempted: 0, delivered: 0, failed: 0 }; + } + + const events = rows.map(row => ({ + distinctId: row.distinctId, + event: row.event, + propertiesJson: row.propertiesJson, + })); + const result = posthogFetch( + ctx, + cfg, + '/batch', + buildBatchBody(cfg.projectApiKey, events) + ); + + return ctx.withTx(tx => { + let delivered = 0; + let failed = 0; + for (const row of rows) { + const current = tx.db.posthogOutbox.outboxId.find(row.outboxId); + if ( + !current || + current.status.tag !== 'Processing' || + current.claimId !== claim.claimId + ) + continue; + logDelivery(tx, DeliverySource.Flush, row.outboxId, row, result); + const retryAt = new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + retryDelayMicros(current.attempts + 1) + ); + const settled = settleOutboxClaim( + current, + result, + ctx.timestamp, + retryAt + ); + tx.db.posthogOutbox.outboxId.update(settled.row); + if (settled.terminal) updateDeliveryStats(tx, { pending: -1n }); + if (result.ok) delivered++; + else failed++; + } + pruneDeliveryHistory(tx); + return { attempted: rows.length, delivered, failed }; + }); +} + +export const enqueue_event = spacetimedb.reducer( + { + distinctId: t.string(), + event: t.string(), + propertiesJson: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + (ctx, args) => { + requireAdmin(ctx, ctx.sender); + enqueueEvent(ctx, args); + } +); + +export const capture_now = spacetimedb.procedure( + { + distinctId: t.string(), + event: t.string(), + propertiesJson: t.option(t.string()), + }, + t.string(), + (ctx, args) => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + return JSON.stringify(captureNow(ctx, args)); + } +); + +export const flush_outbox = spacetimedb.procedure( + { limit: t.u32() }, + t.string(), + (ctx, args) => JSON.stringify(flushOutbox(ctx, { limit: args.limit })) +); + +export const get_feature_flag = spacetimedb.procedure( + { + key: t.string(), + distinctId: t.string(), + personPropertiesJson: t.option(t.string()), + groupsJson: t.option(t.string()), + }, + t.string(), + (ctx, args) => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + if (!args.key.trim() || args.key.length > MAX_EVENT_NAME_LENGTH) { + throwSenderError('posthog.invalid_flag_key'); + } + if ( + !args.distinctId.trim() || + args.distinctId.length > MAX_DISTINCT_ID_LENGTH + ) { + throwSenderError('posthog.invalid_distinct_id'); + } + if ((args.personPropertiesJson?.length ?? 0) > MAX_PROPERTIES_JSON_LENGTH) { + throwSenderError('posthog.person_properties_too_large'); + } + if ((args.groupsJson?.length ?? 0) > MAX_PROPERTIES_JSON_LENGTH) { + throwSenderError('posthog.groups_too_large'); + } + const personProperties = parseJsonObject( + args.personPropertiesJson, + 'person_properties' + ); + const groups = parseJsonObject(args.groupsJson, 'groups'); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const body: Record = { + api_key: cfg.projectApiKey, + distinct_id: args.distinctId, + }; + if (personProperties !== undefined) + body.person_properties = personProperties; + if (groups !== undefined) body.groups = groups; + const result = posthogFetch(ctx, cfg, '/flags?v=2', body); + let valueJson: string | undefined; + if (isOkStatus(result.statusCode)) { + try { + const parsed = JSON.parse(result.responseBody) as Record< + string, + unknown + >; + const flags = parsed.featureFlags; + if (flags && typeof flags === 'object' && args.key in flags) { + valueJson = JSON.stringify( + (flags as Record)[args.key] + ); + } + } catch { + valueJson = undefined; + } + } + ctx.withTx(tx => { + logDelivery( + tx, + DeliverySource.FeatureFlag, + undefined, + { distinctId: args.distinctId, event: `$feature_flag:${args.key}` }, + result + ); + pruneDeliveryHistory(tx); + }); + return JSON.stringify({ + ok: result.ok, + statusCode: result.statusCode, + responseBody: result.responseBody, + valueJson, + }); + } +); + +function viewIsAdmin(ctx: ViewModuleCtx): boolean { + return isAdmin(ctx, ctx.sender); +} + +export const posthogOutboxAdmin = spacetimedb.view( + { name: 'posthog_outbox_admin', public: true }, + t.array(posthogOutbox.rowType), + ctx => { + if (!viewIsAdmin(ctx)) return []; + const rows = takeRows( + ctx.db.posthogOutbox.byStatus.filter(OutboxStatus.Processing), + 500 + ); + if (rows.length < 500) { + rows.push( + ...takeRows( + ctx.db.posthogOutbox.byStatus.filter(OutboxStatus.Queued), + 500 - rows.length + ) + ); + } + return rows; + } +); + +export const posthogDeliveryLogAdmin = spacetimedb.view( + { name: 'posthog_delivery_log_admin', public: true }, + t.array(posthogDeliveryLogRow), + ctx => { + if (!viewIsAdmin(ctx)) return []; + return takeRows( + ctx.db.posthogDeliveryLog.byAttemptedAtOrder.filter(new Range()), + 50 + ).map(row => ({ + deliveryId: row.deliveryId, + source: row.source, + outboxId: row.outboxId, + distinctId: row.distinctId, + event: row.event, + ok: row.ok, + statusCode: row.statusCode, + responseBody: row.responseBody, + errorMessage: row.errorMessage, + attemptedAt: row.attemptedAt, + })); + } +); + +export { deliverySource }; diff --git a/spacetime-posthog-ts/src/submodule/outbox-state.ts b/spacetime-posthog-ts/src/submodule/outbox-state.ts new file mode 100644 index 00000000000..7330fc5e3c2 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/outbox-state.ts @@ -0,0 +1,86 @@ +export const MAX_DELIVERY_ATTEMPTS = 5; +const INITIAL_RETRY_DELAY_MICROS = 1_000_000n; +const MAX_RETRY_DELAY_MICROS = 5n * 60n * 1_000_000n; + +type OutboxRow = { + status: { tag: string }; + attempts: number; + claimId?: string | undefined; + claimExpiresAtMicros: bigint; + nextAttemptAt: unknown; + lastStatusCode?: number | undefined; + lastError?: string | undefined; + updatedAt: unknown; + deliveredAt?: unknown; +}; + +export function claimHasExpired( + row: Pick, + nowMicros: bigint +): boolean { + return row.claimExpiresAtMicros <= nowMicros; +} + +export function retryDelayMicros(attempt: number): bigint { + const exponent = Math.max(0, Math.min(30, Math.trunc(attempt) - 1)); + const delay = INITIAL_RETRY_DELAY_MICROS * (1n << BigInt(exponent)); + return delay > MAX_RETRY_DELAY_MICROS ? MAX_RETRY_DELAY_MICROS : delay; +} + +export function releaseExpiredClaim( + row: T, + timestamp: T['updatedAt'] +): T { + return { + ...row, + status: { tag: 'Queued' }, + claimId: undefined, + claimExpiresAtMicros: 0n, + nextAttemptAt: timestamp, + updatedAt: timestamp, + }; +} + +export function claimOutboxRow( + row: T, + claimId: string, + expiresAtMicros: bigint, + timestamp: T['updatedAt'] +): T { + return { + ...row, + status: { tag: 'Processing' }, + claimId, + claimExpiresAtMicros: expiresAtMicros, + updatedAt: timestamp, + }; +} + +export function settleOutboxClaim( + row: T, + result: { ok: boolean; statusCode: number; responseBody: string }, + timestamp: T['updatedAt'], + retryAt: T['nextAttemptAt'] +): { row: T; terminal: boolean } { + const attempts = row.attempts + 1; + const terminal = result.ok || attempts >= MAX_DELIVERY_ATTEMPTS; + return { + terminal, + row: { + ...row, + status: result.ok + ? { tag: 'Delivered' } + : terminal + ? { tag: 'Failed' } + : { tag: 'Queued' }, + attempts, + claimId: undefined, + claimExpiresAtMicros: 0n, + nextAttemptAt: terminal ? timestamp : retryAt, + lastStatusCode: result.statusCode, + lastError: result.ok ? undefined : result.responseBody, + updatedAt: timestamp, + deliveredAt: result.ok ? timestamp : undefined, + }, + }; +} diff --git a/spacetime-posthog-ts/src/submodule/schema.ts b/spacetime-posthog-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..cada58018ca --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/schema.ts @@ -0,0 +1,182 @@ +import { + SenderError, + schema, + table, + t, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { installPostHog } from './install'; + +export const outboxStatus = t.enum('PostHogOutboxStatus', [ + 'Queued', + 'Processing', + 'Delivered', + 'Failed', +]); +export const OutboxStatus = { + Queued: { tag: 'Queued' as const }, + Processing: { tag: 'Processing' as const }, + Delivered: { tag: 'Delivered' as const }, + Failed: { tag: 'Failed' as const }, +}; + +export const deliverySource = t.enum('PostHogDeliverySource', [ + 'Direct', + 'Flush', + 'FeatureFlag', +]); +export const DeliverySource = { + Direct: { tag: 'Direct' as const }, + Flush: { tag: 'Flush' as const }, + FeatureFlag: { tag: 'FeatureFlag' as const }, +}; + +export const posthogConfig = table( + { name: 'posthog_config', public: false }, + { + singleton: t.bool().primaryKey(), + host: t.string(), + projectApiKey: t.string(), + updatedAt: t.timestamp(), + } +); + +export const posthogAdminIdentity = table( + { name: 'posthog_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +export const posthogOutbox = table( + { + name: 'posthog_outbox', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + { + accessor: 'byStatusCreatedAt', + algorithm: 'btree', + columns: ['status', 'createdAt'], + }, + { + accessor: 'byStatusNextAttemptAt', + algorithm: 'btree', + columns: ['status', 'nextAttemptAt'], + }, + { + accessor: 'byStatusClaimExpiresAtMicros', + algorithm: 'btree', + columns: ['status', 'claimExpiresAtMicros'], + }, + { + accessor: 'byStatusUpdatedAt', + algorithm: 'btree', + columns: ['status', 'updatedAt'], + }, + ], + }, + { + outboxId: t.string().primaryKey(), + idempotencyKey: t.option(t.string()), + distinctId: t.string(), + event: t.string(), + propertiesJson: t.option(t.string()), + status: outboxStatus, + attempts: t.u32(), + claimId: t.option(t.string()), + claimExpiresAtMicros: t.i64(), + nextAttemptAt: t.timestamp(), + lastStatusCode: t.option(t.u16()), + lastError: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + deliveredAt: t.option(t.timestamp()), + } +); + +export const posthogDeliveryStats = table( + { name: 'posthog_delivery_stats', public: false }, + { + singleton: t.bool().primaryKey(), + pending: t.u64(), + delivered: t.u64(), + failed: t.u64(), + updatedAt: t.timestamp(), + } +); + +export const posthogDeliveryLog = table( + { + name: 'posthog_delivery_log', + public: false, + indexes: [ + { + accessor: 'byAttemptedAt', + algorithm: 'btree', + columns: ['attemptedAt'], + }, + { + accessor: 'byAttemptedAtOrder', + algorithm: 'btree', + columns: ['attemptedAtOrder'], + }, + { accessor: 'byOk', algorithm: 'btree', columns: ['ok'] }, + ], + }, + { + deliveryId: t.u64().primaryKey().autoInc(), + source: deliverySource, + outboxId: t.option(t.string()), + distinctId: t.string(), + event: t.string(), + ok: t.bool(), + statusCode: t.u16(), + responseBody: t.string(), + errorMessage: t.option(t.string()), + attemptedAt: t.timestamp(), + attemptedAtOrder: t.i64(), + } +); + +export const posthogDeliveryLogRow = t.object('PostHogDeliveryLogRow', { + deliveryId: t.u64(), + source: deliverySource, + outboxId: t.option(t.string()), + distinctId: t.string(), + event: t.string(), + ok: t.bool(), + statusCode: t.u16(), + responseBody: t.string(), + errorMessage: t.option(t.string()), + attemptedAt: t.timestamp(), +}); + +export const spacetimedb = schema({ + posthogConfig, + posthogAdminIdentity, + posthogOutbox, + posthogDeliveryLog, + posthogDeliveryStats, +}); + +export const init = spacetimedb.init(ctx => { + installPostHog(ctx); +}); + +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; + +export { SenderError, t }; diff --git a/spacetime-posthog-ts/src/submodule/utils.ts b/spacetime-posthog-ts/src/submodule/utils.ts new file mode 100644 index 00000000000..204e63aa4dc --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/utils.ts @@ -0,0 +1,29 @@ +import { SenderError } from 'spacetimedb/server'; +export { isOkStatus, toStatusCode, truncateForLog } from './value-utils'; + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function normalizeHost(host: string): string { + const trimmed = host.trim(); + if (!trimmed) throwSenderError('posthog.invalid_host'); + return trimmed.replace(/\/+$/, ''); +} + +export function parseJsonObject( + json: string | undefined, + name: string +): unknown { + if (json === undefined) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throwSenderError(`posthog.invalid_${name}_json`); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throwSenderError(`posthog.invalid_${name}_json`); + } + return parsed; +} diff --git a/spacetime-posthog-ts/src/submodule/value-utils.ts b/spacetime-posthog-ts/src/submodule/value-utils.ts new file mode 100644 index 00000000000..51793498eba --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/value-utils.ts @@ -0,0 +1,16 @@ +const MAX_LOG_BODY = 2048; + +export function truncateForLog(body: string): string { + return body.length <= MAX_LOG_BODY + ? body + : `${body.slice(0, MAX_LOG_BODY)}...`; +} + +export function toStatusCode(status: number): number { + if (!Number.isInteger(status) || status < 0 || status > 0xffff) return 0; + return status; +} + +export function isOkStatus(status: number): boolean { + return status >= 200 && status < 300; +} diff --git a/spacetime-posthog-ts/tsconfig.json b/spacetime-posthog-ts/tsconfig.json new file mode 100644 index 00000000000..c659d97428a --- /dev/null +++ b/spacetime-posthog-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-presence-ts/LICENSE.txt b/spacetime-presence-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-presence-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-presence-ts/README.md b/spacetime-presence-ts/README.md new file mode 100644 index 00000000000..8a849e21864 --- /dev/null +++ b/spacetime-presence-ts/README.md @@ -0,0 +1,149 @@ +# @spacetimedb/presence + +Presence primitives for SpacetimeDB modules. + +## Install + +```bash +npm install @spacetimedb/presence spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +This package provides: + +- reusable presence table row/builders, +- helpers for heartbeats and status/activity updates, +- bounded sweep helpers for expired presence rows. + +## Usage + +### Integrate into an application + +Presence is a host-configured helper: the host chooses whether rows are public, +defines the scheduled sweep, and derives subjects from its authentication +model. The skeleton below owns those decisions explicitly. + +```ts +import { Range, schema, t, table } from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; +import { + createPresenceEntryTable, + createPresenceConfigTable, + installPresenceConfig, + presenceSweepTickRow, + runPresenceSweep, + upsertPresence, +} from '@spacetimedb/presence'; + +const presenceEntry = createPresenceEntryTable({ public: true }); +const presenceConfig = createPresenceConfigTable({ public: false }); +const presenceSweepTick = table( + { name: 'presence_sweep_tick', scheduled: (): any => presence_sweep }, + presenceSweepTickRow +); + +const spacetimedb = schema({ + presenceEntry, + presenceConfig, + presenceSweepTick, +}); + +export const init = spacetimedb.init(ctx => { + installPresenceConfig(ctx); + ctx.db.presenceSweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(30n * 1_000_000n), + }); +}); + +export const heartbeat = spacetimedb.procedure( + { scope: t.string(), status: t.option(t.string()) }, + t.unit(), + (ctx, args) => { + ctx.withTx(tx => { + upsertPresence(tx, { + scope: args.scope, + subject: ctx.sender.toHexString(), + status: args.status ?? 'online', + }); + }); + return {}; + } +); + +export const presence_sweep = spacetimedb.reducer( + { arg: presenceSweepTick.rowType }, + ctx => { + runPresenceSweep( + ctx, + ctx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: ctx.timestamp }) + ) + ); + } +); + +export default spacetimedb; +``` + +The public operation derives its subject from `ctx.sender`. Applications with +account authentication can use a verified session's stable user ID instead. +See the +[Presence Chat host module](./example/spacetimedb/) +for authenticated subjects, typing scopes, and bounded cleanup. + +After generating bindings, send heartbeats through the host procedure and +subscribe to the host's public or caller-scoped presence table: + +```ts +await conn.procedures.heartbeat({ + scope: 'room:42', + status: 'online', +}); + +conn + .subscriptionBuilder() + .subscribe(["SELECT * FROM presence_entry WHERE scope = 'room:42'"]); +``` + +## API + +- `createPresenceEntryTable` and `createPresenceConfigTable` create the host + tables. +- `installPresenceConfig` installs default expiration policy. +- `upsertPresence` records a heartbeat or status change. +- `touchPresence` extends an existing lease while preserving its metadata. +- `removePresence` removes one scope and subject pair. +- `buildPresenceKey` creates the collision-safe compound key used by the + default tables. +- `sweepPresence` removes expired rows from a supplied iterator. +- `runPresenceSweep` removes a bounded batch from an expiration-index iterator + supplied by the host. +- `resolvePresenceSweepBatch` validates configured cleanup batch sizes. +- `presenceEntryRow`, `presenceConfigRow`, `presenceSweepTickRow`, and + `presenceTables` support lower-level table composition. +- `DEFAULT_PRESENCE_TTL_SECONDS`, `DEFAULT_PRESENCE_SWEEP_BATCH`, and + `DEFAULT_PRESENCE_STATUS` expose the package defaults. + +Package entrypoints: + +- `@spacetimedb/presence` exports the full standalone helper surface. +- `@spacetimedb/presence/presence` exports presence operations. +- `@spacetimedb/presence/tables` exports table builders. +- `@spacetimedb/presence/submodule` exports the ready-made mounted + namespace. + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-presence-ts/example/.env.example b/spacetime-presence-ts/example/.env.example new file mode 100644 index 00000000000..b8f84760c7a --- /dev/null +++ b/spacetime-presence-ts/example/.env.example @@ -0,0 +1,26 @@ +# Copy to .env. The example server loads this on startup and bootstraps auth. + +# ---------------- Static server ---------------- +HOST=127.0.0.1 +PORT=8794 + +# ---------------- SpacetimeDB ---------------- +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_SERVER=http://127.0.0.1:3000 +STDB_APP_DATABASE=spacetime-presence-example + +# ---------------- Auth ---------------- +# Issuer URL is what gets embedded in the JWT and used for OAuth redirect +# callbacks. Defaults to http://localhost:${PORT}. +AUTH_ISSUER_URL= +AUTH_BASE_URL= +AUTH_COOKIE_NAME=stdb_auth +AUTH_SESSION_TTL_SECONDS=604800 +AUTH_ES256_PRIVATE_KEY_PEM= + +# ---------------- OAuth providers ---------------- +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/spacetime-presence-ts/example/README.md b/spacetime-presence-ts/example/README.md new file mode 100644 index 00000000000..d8e4d6b38d9 --- /dev/null +++ b/spacetime-presence-ts/example/README.md @@ -0,0 +1,184 @@ +# Presence chat example + +This example is a small authenticated chat application built with +[`@spacetimedb/presence`](../). It combines presence, auth, files, and rate +limiting in one SpacetimeDB module and uses caller-scoped views for the browser. + +## What this demonstrates + +- Email/password accounts and optional Google or GitHub OAuth. +- Online, away, do-not-disturb, and invisible presence states. +- Servers, public or private rooms, membership, and room categories. +- Messages, replies, reactions, pins, attachments, edits, and deletion. +- Typing indicators, read cursors, unread badges, and room activity labels. +- Procedure and reducer rate limits for user-generated activity. +- Authenticated, user-scoped subscriptions over private tables. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity. A fresh publish seeds the publisher as the initial + authentication administrator. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +Confirm the local environment first: + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-presence-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open , create an account, and create or join a server and +room. Open a private/incognito window with a second account to exercise presence, +typing, unread counts, and isolation between users. + +`build:module:fresh` deletes and recreates only the local `spacetime-presence-example` +database. Use `pnpm run build:module` when existing local data must be preserved. + +## Use in your project + +This workspace tests the component source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/presence spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Add Auth, +Files, and Rate Limit for the corresponding application features. Chat servers, +rooms, messages, and reactions belong to the host application. + +## Configuration + +| Variable | Default | Purpose | +| ------------------------------ | ---------------------------- | ----------------------------------------------------------------- | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8794` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth/file proxy. | +| `STDB_SERVER` | `STDB_HTTP` | CLI target used during startup configuration. | +| `STDB_APP_DATABASE` | `spacetime-presence-example` | Published database name. | +| `AUTH_ISSUER_URL` | `http://localhost:8794` | JWT issuer and OAuth redirect origin. | +| `AUTH_BASE_URL` | `AUTH_ISSUER_URL` | Browser-visible auth base URL. | +| `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | +| `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | +| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by component | Optional persistent ES256 signing key. | +| Google/GitHub client variables | empty | Enables the matching OAuth provider when both values are present. | + +The development server loads `.env` and calls `set_auth_config` automatically on +startup using the logged-in CLI identity. + +`STDB_URI`, `STDB_HTTP`, and `STDB_SERVER` must all address the same SpacetimeDB +instance. `AUTH_ISSUER_URL` must match the origin users actually load, including +its scheme and port. + +## Architecture and data visibility + +```text +Browser + -> /auth/* and /files through the same-origin development proxy + -> SpacetimeDB reducers, procedures, and scoped subscriptions + +SpacetimeDB module + -> auth session -> linked application connection + -> presence, chat, files, and rate-limit components + -> my_* views filtered for the linked user and room membership +``` + +The browser subscribes only to views such as `my_servers`, `my_rooms`, +`my_room_messages`, `my_presence_entries`, and `my_rate_limit_status`. Server-side +view logic determines which rows the linked user may see. Client-side filters +provide presentation behavior only. + +Presence is connection-sensitive. The client sends heartbeats while active and +uses explicit status changes for away, do-not-disturb, and invisible states. The +scheduled `chat_sweep` reducer cleans up expired transient state. + +## Authentication flow + +1. The browser sends signup, login, refresh, logout, or OAuth requests to the + same-origin `/auth/*` path. +2. The Node server forwards the request to the module HTTP router. +3. A successful auth response supplies a short-lived application token. +4. The browser opens a SpacetimeDB connection and calls `link_connection` with + that token before subscribing to user-scoped views. + +The SpacetimeDB connection identity and the authenticated application user are +different concepts. Authorization in this example derives from linked auth users +and membership tables. + +## Security and deployment boundaries + +- A fresh publish seeds the publisher as the auth administrator. +- Passwords, OAuth secrets, signing keys, `.env`, and browser tokens must not be + committed or logged. +- Private-room and attachment access checks belong in the module and must remain + effective even if a client issues its own subscription query. +- The included Express process serves local development. Production deployment + needs TLS, explicit network binding, trusted-proxy rules, + origin controls, durable key management, and process supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test, use two accounts and verify: + +1. Signup, login, refresh after reload, and logout all work. +2. Both users can join a public room and see messages, typing state, reactions, + presence changes, and read progress in real time. +3. A user outside a private room cannot subscribe to its messages or retrieve its + attachments. +4. Edits, deletes, pins, room administration, and server administration reject + unauthorized users. +5. Repeated writes eventually expose the expected rate-limit status and recover + after its window resets. + +## Troubleshooting + +- **The server exits during startup:** verify the local server is running, the + database is published, and the CLI identity is an auth administrator. +- **Auth works but subscriptions are empty:** inspect the `link_connection` call. + Scoped views begin returning rows after the connection is linked. +- **OAuth callback mismatch:** configure the provider with the exact callback URL + derived from `AUTH_ISSUER_URL`. +- **Users appear offline too quickly:** confirm the browser remains connected and + heartbeat calls reach the module within the configured limit. +- **A browser token is rejected after a fresh publish:** clear site data and sign in + again because the database and signing state were deliberately reset. + +## Important files + +- `spacetimedb/src/index.ts` - host schema, scoped views, chat operations, and + mounted component wiring. +- `server.ts` - environment loading, auth bootstrap, and HTTP proxy. +- `src/app.ts` - browser connection, linked-session setup, and subscriptions. +- `public/index.html` - the example interface. +- `public/ui.js` - chat state, rendering, and interaction handling. +- `public/styles.css` - chat presentation. diff --git a/spacetime-presence-ts/example/package.json b/spacetime-presence-ts/example/package.json new file mode 100644 index 00000000000..37bab181dea --- /dev/null +++ b/spacetime-presence-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-presence-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", + "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-presence-example && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-presence-example && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "test:unit": "node scripts/test-ui-model.mjs", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-presence-ts/example/public/assets/brand.svg b/spacetime-presence-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-presence-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-presence-ts/example/public/assets/logo.svg b/spacetime-presence-ts/example/public/assets/logo.svg new file mode 100644 index 00000000000..adaf36cbf9c --- /dev/null +++ b/spacetime-presence-ts/example/public/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/spacetime-presence-ts/example/public/chat-model.js b/spacetime-presence-ts/example/public/chat-model.js new file mode 100644 index 00000000000..c39acd42f39 --- /dev/null +++ b/spacetime-presence-ts/example/public/chat-model.js @@ -0,0 +1,170 @@ +import { chatState as state } from './chat-state.js'; + +export function hex(identity) { + return identity.toHexString(); +} + +export function activeRoom() { + return state.rooms.find(room => room.id === state.activeRoomId); +} + +export function activeServer() { + return state.servers.find(server => server.id === state.activeServerId); +} + +export function messageById(messageId) { + return state.messages.find(message => message.id === messageId); +} + +export function threadMessageById(messageId) { + return state.threadMessages.find(message => message.id === messageId); +} + +export function roomMembers(roomId) { + return state.members.filter(member => member.roomId === roomId); +} + +export function myMemberships() { + return state.members + .filter(member => member.userId === state.userId) + .map(member => member.roomId); +} + +function isOwnIdentity(identity) { + return Boolean(state.meHex) && hex(identity) === state.meHex; +} + +function canModerateActiveRoom() { + const room = activeRoom(); + const server = activeServer(); + return Boolean( + state.userId && + room && + (room.createdByUserId === state.userId || + server?.createdByUserId === state.userId) + ); +} + +export function canEditMessage(message) { + return Boolean(message && isOwnIdentity(message.author)); +} + +export function canDeleteMessage(message) { + return Boolean( + message && (isOwnIdentity(message.author) || canModerateActiveRoom()) + ); +} + +export function userByHex() { + const users = new Map(); + for (const user of state.users) users.set(hex(user.identity), user); + return users; +} + +export function userByUserId() { + const users = new Map(); + for (const user of state.users) users.set(user.userId, user); + return users; +} + +export function messageAuthorName(message, users = userByHex()) { + if (!message) return 'message'; + const authorHex = hex(message.author); + return users.get(authorHex)?.displayName || authorHex.slice(-6); +} + +export function messageSummary(message) { + if (!message) return 'Original message unavailable'; + const text = (message.content || '').trim(); + if (text) return text.length > 120 ? `${text.slice(0, 117)}...` : text; + const attachmentCount = state.attachments.filter( + attachment => attachment.messageId === message.id + ).length; + return attachmentCount + ? `${attachmentCount} attachment${attachmentCount === 1 ? '' : 's'}` + : 'Empty message'; +} + +function threadForRoot(rootMessageId) { + return state.threads.find(thread => thread.rootMessageId === rootMessageId); +} + +export function threadMessagesForRoot(rootMessageId) { + const thread = threadForRoot(rootMessageId); + if (!thread) return []; + return state.threadMessages.filter(message => message.threadId === thread.id); +} + +export function myServers() { + if (!state.userId) return []; + const serverIds = new Set(); + for (const member of state.serverMembers) { + if (member.userId === state.userId) { + serverIds.add(member.serverId.toString()); + } + } + return state.servers.filter(server => serverIds.has(server.id.toString())); +} + +export function roomsInServer(serverId) { + if (serverId === null || serverId === undefined) return []; + return state.rooms.filter(room => room.serverId === serverId); +} + +export function amServerOwner(serverId) { + const server = state.servers.find(candidate => candidate.id === serverId); + return Boolean(server && server.createdByUserId === state.userId); +} + +export function latestMessageByRoom() { + const latest = new Map(); + for (const message of state.messages) { + const existing = latest.get(message.roomId); + if (!existing || existing.id < message.id) { + latest.set(message.roomId, message); + } + } + return latest; +} + +export function myReadCursorByRoom() { + const cursors = new Map(); + for (const cursor of state.cursors) { + if (hex(cursor.identity) === state.meHex) { + cursors.set(cursor.roomId, cursor.lastReadMessageId); + } + } + return cursors; +} + +export function globalPresenceBySubject() { + const presence = new Map(); + for (const row of state.presence) { + if (row.scope === 'chat.global') presence.set(row.subject, row); + } + return presence; +} + +export function typingForRoom(roomId) { + const scope = `chat.typing:${roomId.toString()}`; + return state.presence + .filter(row => row.scope === scope) + .map(row => row.subject); +} + +export function statusOf(subjectHex, presence) { + return presence.get(subjectHex)?.status || 'invisible'; +} + +export function attachmentsByMessage() { + const attachments = new Map(); + for (const attachment of state.attachments) { + const existing = attachments.get(attachment.messageId) ?? []; + existing.push(attachment); + attachments.set(attachment.messageId, existing); + } + for (const list of attachments.values()) { + list.sort((left, right) => left.ordinal - right.ordinal); + } + return attachments; +} diff --git a/spacetime-presence-ts/example/public/chat-state.js b/spacetime-presence-ts/example/public/chat-state.js new file mode 100644 index 00000000000..fb31b9be7d8 --- /dev/null +++ b/spacetime-presence-ts/example/public/chat-state.js @@ -0,0 +1,27 @@ +export const chatState = { + meHex: '', + userId: null, + userEmail: '', + activeServerId: null, + activeRoomId: null, + authenticated: false, + admins: [], + servers: [], + serverMembers: [], + rooms: [], + users: [], + members: [], + messages: [], + reactions: [], + attachments: [], + threads: [], + threadMessages: [], + cursors: [], + presence: [], + rateLimitStatus: [], +}; + +export function applyChatData(next) { + const { userId, userEmail } = chatState; + Object.assign(chatState, next, { userId, userEmail }); +} diff --git a/spacetime-presence-ts/example/public/chat.css b/spacetime-presence-ts/example/public/chat.css new file mode 100644 index 00000000000..e767fff1f84 --- /dev/null +++ b/spacetime-presence-ts/example/public/chat.css @@ -0,0 +1,1390 @@ +/* Signed-in chat shell */ + +body { + background: var(--color-shade7); + margin: 0; +} +.app:not(.signed-out) { + background: var(--color-shade7); + padding: 0 !important; + height: 100vh; + width: 100vw; +} + +main.shell:not(.signed-out) { + width: 100vw; + max-width: 100vw; + height: 100vh; + margin: 0; + padding: 0; + display: flex !important; + flex-direction: row; + gap: 0; + border: none !important; + border-radius: 0 !important; + background: var(--color-shade7); + overflow: hidden; + box-shadow: none !important; +} + +/* Channel header sits across the top of (main + members), so toggling + member count keeps the header icons stable. chat-column is a + vertical stack: header on top, chat-body row underneath. */ +main.shell:not(.signed-out) > .chat-column { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--color-shade8); + overflow: hidden; +} +main.shell:not(.signed-out) > .chat-column > .chat-body { + flex: 1 1 auto; + display: flex; + flex-direction: row; + min-height: 0; +} +main.shell:not(.signed-out) > .chat-column > .chat-body > .main { + flex: 1 1 0; + min-width: 0; +} + +/* Members toggle hides the panel under the header without affecting the + header layout. */ +main.shell:not(.signed-out) .members.collapsed { + display: none !important; +} + +/* ---- server rail (far-left, 72px) ---- */ +main.shell:not(.signed-out) > .rail { + flex: 0 0 72px; + background: var(--color-shade7); + border-right: 1px solid var(--color-shade4); + padding: 12px 0 8px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + overflow-y: auto; + border-right: none; + margin: 0; +} +main.shell:not(.signed-out) > .rail::-webkit-scrollbar { + display: none; +} +.rail-icon, +.rail-home { + width: 48px; + height: 48px; + border-radius: 24px; + background: var(--color-shade5); + color: var(--color-green); + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + transition: + border-radius 120ms ease, + background 120ms ease, + color 120ms ease; + padding: 0; + font-family: var(--font-ibm); + font-size: 12px; + font-weight: 700; +} +.rail-icon:hover, +.rail-home:hover { + border-radius: 16px; + background: var(--color-green); + color: var(--color-shade8); +} +.rail-icon.active, +.rail-home.active { + border-radius: 16px; +} +.rail-icon.active::before, +.rail-home.active::before { + content: ''; + position: absolute; + left: -16px; + top: 50%; + transform: translateY(-50%); + width: 4px; + height: 40px; + background: var(--color-n1); + border-radius: 0 4px 4px 0; +} +.rail-divider { + width: 32px; + height: 2px; + background: var(--color-shade5); + border-radius: 1px; + margin: 4px 0; + flex: 0 0 2px; +} +.rail-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} +.rail-list .server-pill { + width: 48px; + height: 48px; + border-radius: 24px; + background: var(--color-shade5); + color: var(--color-n2); + border: none; + cursor: pointer; + font-family: var(--font-ibm); + font-size: 12px; + font-weight: 700; + transition: + border-radius 120ms ease, + background 120ms ease; +} +.rail-list .server-pill:hover { + border-radius: 16px; + background: var(--color-green); + color: var(--color-shade8); +} +.rail-list .server-pill.active { + border-radius: 16px; + background: var(--color-shade4); + color: var(--color-n1); +} + +.rail-create { + width: 48px; + height: 48px; + border-radius: 24px; + background: var(--color-shade5); + color: var(--color-green); + border: none; + cursor: pointer; + font-size: 24px; + line-height: 1; + margin-top: 4px; + transition: + border-radius 120ms ease, + background 120ms ease, + color 120ms ease; +} +.rail-create:hover { + border-radius: 16px; + background: var(--color-green); + color: var(--color-shade8); +} + +.sidebar-head { + position: relative; +} +.server-menu { + position: absolute; + top: 100%; + left: 8px; + right: 8px; + background: var(--color-shade8); + border: 1px solid var(--color-shade4); + border-radius: 6px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); + z-index: 20; + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; +} +.server-menu[hidden] { + display: none; +} +.server-menu button { + background: transparent; + border: none; + color: var(--color-n2); + font-size: 13px; + text-align: left; + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; +} +.server-menu button:hover { + background: var(--color-shade6); + color: var(--color-n1); +} +.server-menu button.danger { + color: var(--color-red, #f04747); +} +.server-menu button.danger:hover { + background: var(--color-red, #f04747); + color: var(--color-n1); +} + +/* ---- channel sidebar (240px) ---- */ +main.shell:not(.signed-out) > .sidebar { + flex: 0 0 240px; + background: var(--color-shade7); + border-right: 1px solid var(--color-shade4); + display: flex; + flex-direction: column; + min-width: 0; + padding: 0; +} +.sidebar-head { + height: 48px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 48px; + background: var(--color-shade7); + border-bottom: 1px solid var(--color-shade4); + cursor: pointer; +} +.sidebar-head:hover { + background: var(--color-shade4); +} +.sidebar-head-text { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} +.workspace-name { + font-size: 15px; + font-weight: 700; + color: var(--color-n1); + letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.workspace-chevron { + color: var(--color-n2); + flex: 0 0 18px; +} +.channels-scroll { + flex: 1; + overflow-y: auto; + padding: 8px 8px 0; + min-height: 0; +} +.channels-scroll::-webkit-scrollbar { + width: 8px; +} +.channels-scroll::-webkit-scrollbar-thumb { + background: var(--color-shade8); + border-radius: 4px; +} +.channels-scroll::-webkit-scrollbar-thumb:hover { + background: var(--color-shade7); +} + +/* Categories */ +.category > summary { + padding: 16px 6px 4px; + font-size: 12px; + color: var(--color-n4); + position: relative; + display: flex; + align-items: center; + gap: 4px; + min-height: 32px; + box-sizing: border-box; + outline: none; +} +.category > summary::-webkit-details-marker { + display: none; +} +.category > summary:hover { + color: var(--color-n2); +} +.category-add { + margin-left: auto; + background: transparent; + border: none; + color: var(--color-n4); + cursor: pointer; + font-size: 18px; + line-height: 1; + width: 18px; + height: 18px; + display: inline-flex; + visibility: hidden; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 2px; +} +.category > summary:hover .category-add { + visibility: visible; +} +.category-add:hover { + color: var(--color-n1); +} +.category > summary > .category-name { + flex: 1; +} + +/* Channel rows (Discord-tight) */ +.room-row { + padding: 6px 8px; + font-size: 15px; + color: var(--color-n4); + border-radius: 4px; + margin-bottom: 1px; +} +.room-row:hover { + background: var(--color-shade4); + color: var(--color-n2); +} +.room-row.active { + background: var(--color-shade4); + color: var(--color-n1); +} +.room-row.has-unread:not(.active) { + color: var(--color-n1); +} +.channel-hash { + font-size: 20px; + line-height: 1; + color: var(--color-n4); + margin-right: 6px; +} +.room-row:hover .channel-hash { + color: var(--color-n2); +} +.room-row.active .channel-hash { + color: var(--color-n1); +} +.room-row.has-unread:not(.active) .channel-hash { + color: var(--color-n1); +} +.unread-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-n1); + margin-left: auto; + flex: 0 0 8px; +} +.lock-icon { + font-size: 12px; +} + +/* User panel at sidebar foot */ +main.shell:not(.signed-out) > .sidebar > .user-panel { + height: 52px; + background: rgba(0, 0, 0, 0.25); + padding: 0 8px; + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 52px; + border-top: none; +} +main.shell:not(.signed-out) > .sidebar > .user-panel > #openAuthBtn { + width: 100%; +} +.user-bar { + width: 100%; + display: flex; + align-items: center; + gap: 4px; + border: none; + background: transparent; + padding: 0; + border-radius: 4px; +} +.user-bar.hidden { + display: none; +} +.user-bar-info { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + border-radius: 4px; + padding: 4px; + cursor: pointer; + background: transparent; + border: none; + text-align: left; +} +.user-bar-info:hover { + background: rgba(255, 255, 255, 0.04); +} +.user-bar .user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-green); + color: var(--color-shade8); + font-family: var(--font-ibm); + font-size: 14px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 32px; + position: relative; +} +.user-bar .user-avatar::after { + content: ''; + position: absolute; + right: -2px; + bottom: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-green); + border: 2px solid rgba(0, 0, 0, 0.5); +} +.user-bar-text { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0; +} +.user-bar-name { + font-size: 13px; + font-weight: 600; + color: var(--color-n1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 110px; + line-height: 1.2; +} +.user-bar-status { + font-size: 11px; + color: var(--color-n3); + font-family: var(--font-ibm); + line-height: 1.2; +} +.user-bar-actions { + display: flex; + gap: 0; + flex: 0 0 auto; +} +.user-bar-actions .head-icon { + width: 32px; + height: 32px; + border-radius: 4px; + padding: 0; +} +.user-bar-actions .head-icon:hover { + background: rgba(255, 255, 255, 0.04); + color: var(--color-n1); +} + +/* ---- main chat area ---- */ +main.shell:not(.signed-out) .chat-body > .main { + flex: 1; + background: var(--color-shade7); + display: flex; + flex-direction: column; + min-width: 0; + padding: 0; + border-radius: 0; + border: none; +} + +/* Channel header */ +.channel-header { + height: 48px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex: 0 0 48px; + background: var(--color-shade7); + border-bottom: 1px solid var(--color-shade4); + z-index: 1; +} +.channel-header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.channel-hash-lg { + font-size: 24px; + color: var(--color-n4); + font-weight: 400; + line-height: 1; +} +.channel-name { + font-size: 16px; + font-weight: 700; + color: var(--color-n1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.channel-header-right { + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; +} +.channel-search { + display: flex; + align-items: center; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: 4px; + padding: 0 6px; + height: 24px; + margin-left: 8px; + gap: 4px; +} +.channel-search input { + background: transparent; + border: none; + outline: none; + color: var(--color-n2); + font-size: 12px; + padding: 0 4px; + width: 160px; + height: 100%; + font-family: var(--font-ibm); +} +.channel-search input::placeholder { + color: var(--color-n4); +} +.channel-search svg { + color: var(--color-n4); + flex: 0 0 14px; +} + +.head-icon { + width: 32px; + height: 32px; + border-radius: 4px; + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} +.head-icon:hover { + color: var(--color-n1); + background: rgba(255, 255, 255, 0.04); +} +.head-icon.hidden { + display: none; +} + +/* Messages */ +main.shell:not(.signed-out) .chat-body > .main > .message-scroll { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 16px 0; + background: var(--color-shade7); +} +main.shell:not(.signed-out) + .chat-body + > .main + > .message-scroll::-webkit-scrollbar { + width: 8px; +} +main.shell:not(.signed-out) + .chat-body + > .main + > .message-scroll::-webkit-scrollbar-thumb { + background: var(--color-shade7); + border-radius: 4px; +} +main.shell:not(.signed-out) + .chat-body + > .main + > .message-scroll::-webkit-scrollbar-thumb:hover { + background: var(--color-shade8); +} +main.shell:not(.signed-out) .chat-body > .main > .message-scroll > .messages { + list-style: none; + margin: 0; + padding: 0; + display: block; +} +.msg { + position: relative; + padding: 2px 16px 2px 72px; + min-height: 22px; +} +.msg.chunk-start { + margin-top: 18px; + padding-top: 4px; + display: grid; + grid-template-columns: 40px 1fr; + gap: 0 16px; + padding-left: 16px; + padding-right: 16px; +} +.msg.chunk-cont { + display: block; + padding-left: 72px; +} +.msg:hover { + background: rgba(4, 4, 5, 0.07); +} +.msg .avatar { + width: 40px; + height: 40px; + font-size: 16px; +} +.msg-col { + min-width: 0; +} +.msg-head { + display: flex; + align-items: baseline; + gap: 6px; + margin-bottom: 2px; +} +.msg-author { + color: var(--color-n1); + font-size: 15px; + font-weight: 500; +} +.msg-time { + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); +} +.msg.chunk-cont .msg-stamp-rail { + display: none; +} +.msg-body { + color: var(--color-n2); + font-size: 15px; + line-height: 1.375; + white-space: pre-wrap; + word-break: break-word; +} +.react-row { + margin-top: 4px; +} +.react-btn { + background: var(--color-shade4); + border: 1px solid var(--color-shade3); + font-size: 13px; +} +.react-btn.mine { + background: rgba(76, 244, 144, 0.08); + border-color: rgba(76, 244, 144, 0.4); + color: var(--color-green); +} + +/* Typing line */ +main.shell:not(.signed-out) .chat-body > .main > .typing { + padding: 0 16px; + height: 24px; + font-size: 13px; + color: var(--color-n3); + font-family: var(--font-inter); + flex: 0 0 24px; +} +.composer-reply { + margin: 0 16px; + padding: 8px 12px; + background: var(--color-shade6); + border: 1px solid var(--color-shade4); + border-bottom: none; + border-radius: 8px 8px 0 0; + color: var(--color-n3); + display: flex; + align-items: center; + gap: 8px; + min-height: 34px; +} +.composer-reply.hidden { + display: none; +} +.composer-reply-label { + color: var(--color-n2); + font-size: 12px; + font-weight: 600; + flex: 0 0 auto; +} +.composer-reply-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} +.composer-reply-cancel { + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-n3); + cursor: pointer; + flex: 0 0 auto; +} +.composer-reply-cancel:hover { + background: var(--color-shade4); + color: var(--color-n1); +} +.composer-reply.editing { + border-color: rgba(76, 244, 144, 0.35); +} +.composer-rate-limit { + margin: 0 16px 6px; + padding: 7px 10px; + border: 1px solid rgba(255, 162, 92, 0.38); + border-radius: 8px; + background: rgba(255, 162, 92, 0.08); + color: var(--color-orange); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0; +} +.composer-rate-limit[hidden] { + display: none; +} + +/* Composer */ +main.shell:not(.signed-out) .chat-body > .main > .composer { + padding: 0 16px 24px; + background: var(--color-shade7); + flex: 0 0 auto; + display: block; +} +main.shell:not(.signed-out) .chat-body > .main > .composer.hidden { + display: none; +} +.composer { + background: var(--color-shade7) !important; + border: 1px solid var(--color-shade4) !important; + border-radius: 8px; + padding: 0 16px !important; + height: 44px; + display: flex !important; + align-items: center; + gap: 4px; + grid-template-columns: none !important; +} +.composer-attach { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + flex: 0 0 28px; +} +.composer-attach:hover { + color: var(--color-n1); +} +.composer-attach:disabled { + opacity: 0.35; + cursor: not-allowed; + pointer-events: none; +} +.composer #messageInput { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--color-n1); + font-size: 15px; + padding: 0 8px; + height: 44px; + width: auto; +} +.composer #messageInput::placeholder { + color: var(--color-n4); +} +.composer.rate-limited { + border-color: rgba(255, 162, 92, 0.45) !important; + background: rgba(255, 162, 92, 0.06) !important; +} +.composer-right { + display: flex; + gap: 2px; + align-items: center; + flex: 0 0 auto; +} +.composer-icon { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + padding: 0; + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 800; +} +.composer-icon:hover { + color: var(--color-n1); +} +.composer-gif { + border: 2px solid currentColor !important; + border-radius: 4px; + font-size: 9px; + width: auto; + padding: 0 4px; + line-height: 1; + height: 18px; +} +.composer-send { + background: var(--color-green); + border: none; + color: var(--color-shade8); + cursor: pointer; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + padding: 0; + margin-left: 4px; + flex: 0 0 28px; +} +.composer-send:hover:not(:disabled) { + background: var(--color-white); +} +.composer-send:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pending-attachments { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 16px; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: 8px; + margin: 4px 16px; +} +.pending-attachments[hidden] { + display: none; +} +.pending-att { + position: relative; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: 6px; + padding: 6px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 80px; + max-width: 140px; +} +.pending-att img { + max-width: 96px; + max-height: 64px; + border-radius: 4px; + object-fit: cover; +} +.pending-att-meta { + font-size: 10px; + color: var(--color-n4); + font-family: var(--font-ibm); + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.pending-att-x { + position: absolute; + top: -6px; + right: -6px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--color-shade8); + color: var(--color-n2); + border: 1px solid var(--color-shade4); + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.pending-att-x:hover { + color: var(--color-red, #f04747); +} + +.msg-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} +.msg-att-preview { + display: inline-flex; + padding: 0; + border: 0; + background: transparent; + cursor: zoom-in; +} +.msg-att-img { + max-width: 320px; + max-height: 220px; + border-radius: 6px; + object-fit: cover; + border: 1px solid var(--color-shade4); +} +.msg-att-preview:hover .msg-att-img { + border-color: var(--color-n4); +} +.msg-att-file { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: 6px; + padding: 8px 10px; + color: var(--color-n2); + text-decoration: none; + font-size: 13px; + font-family: var(--font-ibm); +} +.msg-att-file:hover { + background: var(--color-shade6); + color: var(--color-n1); +} +.msg-att-size { + color: var(--color-n4); + font-size: 11px; +} + +.image-lightbox { + position: fixed; + inset: 0; + z-index: 3000; + display: none; + align-items: center; + justify-content: center; + padding: 48px; + background: rgba(0, 0, 0, 0.82); +} +.image-lightbox.open { + display: flex; +} +.image-lightbox-frame { + position: relative; + max-width: min(1080px, 100%); + max-height: 100%; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + gap: 12px; +} +.image-lightbox img { + max-width: 100%; + max-height: calc(100vh - 128px); + border-radius: 8px; + object-fit: contain; + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.55); +} +.image-lightbox-caption { + color: var(--color-n2); + font-size: 13px; + text-align: center; + word-break: break-word; +} +.image-lightbox-close { + position: fixed; + top: 18px; + right: 18px; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--color-shade3); + background: var(--color-shade7); + color: var(--color-n2); + cursor: pointer; + font-size: 20px; + line-height: 1; +} +.image-lightbox-close:hover { + color: var(--color-n1); + border-color: var(--color-n4); +} + +/* Members panel */ +main.shell:not(.signed-out) .chat-body > .members { + flex: 0 0 240px; + background: var(--color-shade7); + border-left: 1px solid var(--color-shade4); +} +.members.no-room { + display: none; +} + +/* Auto-hide the chat shell sections during the anon (signed-out) view */ +main.shell.signed-out > .rail, +main.shell.signed-out > .sidebar, +main.shell.signed-out > .chat-column, +main.shell.signed-out > .main, +main.shell.signed-out > .members { + display: none; +} + +/* Cover the interface until restoreSession resolves to keep valid sessions + from flashing the login card. */ +.boot-splash { + position: fixed; + inset: 0; + z-index: 9999; + background: var(--color-shade7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + color: var(--color-green); + transition: opacity 200ms ease; +} +.boot-splash svg { + animation: boot-pulse 1.4s ease-in-out infinite; +} +.boot-splash-label { + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-n4); +} +.boot-splash.fading { + opacity: 0; + pointer-events: none; +} +@keyframes boot-pulse { + 0%, + 100% { + opacity: 0.4; + transform: scale(0.95); + } + 50% { + opacity: 1; + transform: scale(1); + } +} +.app.loading { + display: none !important; +} + +/* Channel-header overlay panels (pinned messages / search results) */ +main.shell:not(.signed-out) .chat-body > .main { + position: relative; +} +.header-overlay { + position: absolute; + top: 0; + right: 0; + width: 420px; + max-width: 90%; + max-height: calc(100vh - 96px); + background: var(--color-shade6); + border-left: 1px solid var(--color-shade8); + border-bottom: 1px solid var(--color-shade8); + border-radius: 0 0 0 8px; + box-shadow: -4px 8px 20px rgba(0, 0, 0, 0.5); + z-index: 10; + display: flex; + flex-direction: column; + overflow: hidden; +} +.header-overlay[hidden] { + display: none; +} +.overlay-head { + height: 48px; + flex: 0 0 48px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--color-shade8); + font-size: 14px; + font-weight: 600; + color: var(--color-n1); +} +.overlay-close { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + font-size: 22px; + line-height: 1; + padding: 4px 8px; +} +.overlay-close:hover { + color: var(--color-n1); +} +.overlay-list { + list-style: none; + margin: 0; + padding: 8px; + overflow-y: auto; + flex: 1; + min-height: 0; +} +.overlay-msg { + padding: 10px 12px; + border-radius: 4px; + background: var(--color-shade5); + margin-bottom: 6px; +} +.overlay-msg:hover { + background: var(--color-shade4); + cursor: pointer; +} +.overlay-msg-head { + display: flex; + gap: 8px; + align-items: baseline; + margin-bottom: 4px; +} +.overlay-msg-author { + color: var(--color-n1); + font-weight: 600; + font-size: 13px; +} +.overlay-msg-time { + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); +} +.overlay-msg-body { + color: var(--color-n2); + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} +.overlay-empty { + padding: 16px; + color: var(--color-n4); + font-size: 13px; + text-align: center; +} + +/* Absolute positioning keeps hover controls from resizing message rows. */ +.msg-toolbar { + position: absolute; + top: -14px; + right: 16px; + align-items: center; + background: var(--color-shade7); + border: 1px solid var(--color-shade8); + border-radius: 6px; + padding: 2px 4px; + display: none; + gap: 2px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + z-index: 5; +} +.msg:hover .msg-toolbar, +.msg:focus-within .msg-toolbar { + display: flex; +} +.msg-toolbar button { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 4px; +} +.msg-toolbar button:hover { + color: var(--color-n1); + background: var(--color-shade4); +} +.msg-toolbar button.active { + color: var(--color-green); +} +.msg-toolbar .toolbar-react { + color: var(--color-n2); + font-size: 17px; +} +.msg-toolbar-sep { + width: 1px; + height: 20px; + margin: 0 2px; + background: var(--color-shade4); +} +.thread-pill { + display: inline-flex; + align-items: center; + gap: 3px; + margin-top: 5px; + padding: 0; + border: none; + background: transparent; + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); + cursor: pointer; +} +.thread-pill:hover { + color: var(--color-green); +} +.msg-toolbar button.has-thread { + color: var(--color-green); +} + +.thread-panel { + width: 440px; + max-width: min(92%, 440px); +} +.thread-root { + padding: 12px 14px; + border-bottom: 1px solid var(--color-shade8); + background: var(--color-shade6); +} +.thread-root-label { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 6px; +} +.thread-messages { + list-style: none; + margin: 0; + padding: 8px; + overflow-y: auto; + flex: 1; + min-height: 160px; +} +.thread-msg { + position: relative; + padding: 8px 10px; + border-radius: 4px; + margin-bottom: 4px; + background: var(--color-shade5); +} +.thread-msg-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 3px; +} +.thread-msg-author { + color: var(--color-n1); + font-size: 13px; + font-weight: 600; +} +.thread-msg-time { + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); +} +.thread-msg-body { + color: var(--color-n2); + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} +.thread-msg-actions { + margin-left: auto; + display: none; + gap: 2px; +} +.thread-msg:hover .thread-msg-actions, +.thread-msg:focus-within .thread-msg-actions { + display: inline-flex; +} +.thread-msg-actions button { + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-n3); + cursor: pointer; +} +.thread-msg-actions button:hover { + background: var(--color-shade4); + color: var(--color-n1); +} +.thread-msg-actions button.danger:hover { + color: var(--color-red, #f04747); +} +.thread-composer { + border-top: 1px solid var(--color-shade8); + padding: 10px; + display: flex; + gap: 8px; + background: var(--color-shade6); +} +.thread-composer input { + flex: 1; + min-width: 0; + height: 34px; +} +.thread-composer button { + flex: 0 0 auto; + height: 34px; +} + +.msg.pinned { + background: rgba(76, 244, 144, 0.03); +} +.pinned-marker { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + color: var(--color-green); + font-family: var(--font-ibm); + margin-left: 6px; +} + +/* Search input is a fixed-width inline field (no awkward expand) */ +.search-submit { + background: transparent; + border: none; + color: var(--color-n4); + cursor: pointer; + padding: 0 2px; + display: inline-flex; + align-items: center; +} +.search-submit:hover { + color: var(--color-n1); +} diff --git a/spacetime-presence-ts/example/public/index.html b/spacetime-presence-ts/example/public/index.html new file mode 100644 index 00000000000..06579dfb6cc --- /dev/null +++ b/spacetime-presence-ts/example/public/index.html @@ -0,0 +1,702 @@ + + + + + + + SpacetimeDB Chat + + + + +
        + + SpacetimeDB Chat +
        + +
        +
        +
        +
        +
        + +

        Welcome to chat

        +

        Sign in to continue.

        + +
        + + +
        + +
        or
        + +
        + + +
        + +
        + + +
        + + + + + +

        + Forgot password? +

        +

        + Don't have an account? + Sign up +

        +
        +
        +
        + + + + + +
        +
        +
        + # + +
        +
        + + + +
        +
        +
        +
        +
        +
          +
          + + + +
          + + + +
          + + + + +
          +
          + + +
          +
          +
          +
          + + + + + + + + + + + + + + + + + + + diff --git a/spacetime-presence-ts/example/public/styles.css b/spacetime-presence-ts/example/public/styles.css new file mode 100644 index 00000000000..609c4092f86 --- /dev/null +++ b/spacetime-presence-ts/example/public/styles.css @@ -0,0 +1,1361 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap'); + +:root { + /* Tokens match spacetime-web/spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + --color-muted: var(--color-n4); + + --25px: 1.5625rem; + --28px: 1.75rem; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-white); + background: var(--color-shade7); + overflow: hidden; +} +.hidden { + display: none !important; +} +[hidden] { + display: none !important; +} +.mono { + font-family: var(--font-ibm); +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} + +.app { + width: 100%; + height: 100dvh; + padding: 12px; +} +.app.signed-out { + display: flex; + align-items: flex-start; + justify-content: center; + padding: 48px 32px; +} + +.shell { + width: 100%; + height: calc(100dvh - 24px); + border: 1px solid #17303b; + border-radius: var(--radius-lg); + overflow: hidden; + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + box-shadow: inset 0 1px 0 #26435166; + display: grid; + grid-template-columns: 72px 300px minmax(0, 1fr) 280px; +} +.shell.signed-out { + width: min(540px, 100%); + height: auto; + min-height: 0; + display: flex; + flex-direction: column; + background: transparent; + border: none; + border-radius: 0; + box-shadow: none; +} +.shell.signed-out .rail, +.shell.signed-out .sidebar, +.shell.signed-out .main, +.shell.signed-out .members { + display: none; +} + +.anon-view { + display: none; +} +.shell.signed-out .anon-view { + display: flex; + position: fixed; + inset: 0; + z-index: 50; + align-items: center; + justify-content: center; + padding: 24px; +} +.anon-body { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +/* ============================================================ + Auth panel. Shared block across the auth-using test apps. + Uses STDB tokens (--color-*, --font-*, --radius-*). + Keep these rules in sync across apps. + ============================================================ */ +.auth-shell, +.shell.signed-out .anon-view { + background: + radial-gradient( + ellipse 80% 50% at 50% 0%, + var(--color-green-20), + transparent 60% + ), + var(--color-shade7); +} +.auth-shell { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 50; +} +.auth-card { + width: 100%; + max-width: 380px; + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + padding: 28px; + display: flex; + flex-direction: column; + gap: 12px; +} +.auth-logo { + width: 56px; + height: auto; + margin: 0 auto 4px; + display: block; +} +.auth-card h1 { + font-family: var(--font-inter); + font-size: 18px; + font-weight: 700; + margin: 0; + text-align: center; + color: var(--color-n1); +} +.auth-sub { + font-family: var(--font-inter); + font-size: 13px; + color: var(--color-n4); + margin: 0 0 8px; + text-align: center; +} +.auth-oauth { + display: flex; + flex-direction: column; + gap: 8px; +} +.btn.oauth { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px 14px; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 500; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + color: var(--color-n1); + border-radius: var(--radius-sm); + cursor: pointer; +} +.btn.oauth:hover:not(:disabled) { + background: var(--color-shade4); + border-color: var(--color-n4); +} +.btn.oauth svg { + flex-shrink: 0; + width: 16px; + height: 16px; +} +.btn.block { + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} +.auth-divider { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0; + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.auth-divider::before, +.auth-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--color-shade4); +} +.auth-field { + display: flex; + flex-direction: column; + gap: 4px; +} +.auth-field label { + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-n4); +} +.auth-field input { + background: var(--color-shade6); + border: 1px solid var(--color-shade4); + color: var(--color-n1); + font-family: var(--font-inter); + font-size: 13px; + padding: 8px 10px; + border-radius: var(--radius-sm); + outline: none; +} +.auth-field input:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +.landing-status { + margin: 4px 0 0; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid #5a2222; + background: #2a121299; + color: var(--color-orange); + font-family: var(--font-ibm); + font-size: 12px; + line-height: 1.4; +} +.landing-status.ok { + border-color: #2f644a; + background: #122a1f99; + color: var(--color-green); +} +.auth-foot { + margin: 0; + text-align: center; + font-family: var(--font-inter); + font-size: 12px; + color: var(--color-n4); +} +.auth-foot a { + color: var(--color-green); + cursor: pointer; + text-decoration: none; + font-weight: 600; +} +.auth-foot a:hover { + text-decoration: underline; +} +.auth-card .btn.primary.block { + margin-top: 4px; +} +/* Lock down sizing so the card renders identically across apps + regardless of their per-app global input/.btn rules. */ +.auth-card { + width: 380px; + gap: 12px; +} +.auth-card .auth-logo { + width: 56px; + height: 56px; +} +.auth-card h1 { + font-size: 18px; + line-height: 24px; +} +.auth-card .auth-sub { + font-size: 13px; + line-height: 18px; +} +.auth-card .auth-field input, +.auth-card .btn { + height: 40px; + box-sizing: border-box; + width: 100%; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; +} +.auth-card .auth-field input { + padding: 0 12px; +} +.auth-card .btn.oauth { + padding: 0 14px; +} +.auth-card .auth-field label { + line-height: 14px; +} +.auth-card .auth-foot { + font-size: 12px; + line-height: 18px; +} + +.rail { + background: var(--color-shade8); + border-right: 1px solid var(--color-shade4); + padding: 10px 8px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 10px; +} +.home-btn { + width: 56px; + height: 56px; + margin: 0 auto; + border: 1px solid #275061; + border-radius: 16px; + background: linear-gradient(180deg, #1f6f64, #174f46); + color: var(--color-white); + font-family: var(--font-ibm); + font-size: 16px; + letter-spacing: 0.07em; + cursor: pointer; +} +.home-btn:hover { + filter: brightness(1.07); +} + +.server-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + align-content: start; + gap: 8px; + overflow: auto; +} +.server-pill { + width: 56px; + height: 56px; + margin: 0 auto; + border: 1px solid var(--color-shade1); + border-radius: 16px; + background: var(--color-shade6); + color: var(--color-n2); + font-family: var(--font-ibm); + font-size: 12px; + display: grid; + place-items: center; + cursor: pointer; + position: relative; +} +.server-pill:hover { + background: var(--color-shade4); + color: var(--color-n1); +} +.server-pill.active { + border-color: var(--color-green); + color: var(--color-green); + background: var(--color-shade3); +} +.server-pill.unread::before { + content: ''; + position: absolute; + left: -8px; + width: 4px; + height: 18px; + border-radius: 999px; + background: var(--color-white); +} + +.sidebar { + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + border-right: 1px solid var(--color-shade4); + min-height: 0; + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr) auto; +} +.server-head { + border-bottom: 1px solid var(--color-shade4); + padding: 10px 12px; + display: grid; + gap: 8px; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; +} +.brand-wordmark { + display: block; + height: 26px; +} +.brand-sub { + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.conn { + width: fit-content; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid #27414e; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-n3); +} +.conn-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-n4); +} +.conn.good { + border-color: #31684c; + color: var(--color-green); +} +.conn.good .conn-dot { + background: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +.conn.bad { + border-color: #6a2929; + color: var(--color-orange); +} +.conn.bad .conn-dot { + background: #ff4c4c; +} + +.channel-label { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 12px 6px; + font-family: var(--font-ibm); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-n4); +} +.channel-add { + background: transparent; + border: none; + color: var(--color-n4); + cursor: pointer; + font-size: 18px; + line-height: 1; + width: 22px; + height: 22px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} +.channel-add:hover { + color: var(--color-n1); + background: var(--color-shade4); +} + +input, +select { + width: 100%; + height: 40px; + background: var(--color-shade6); + border: 1px solid var(--color-shade4); + border-radius: 8px; + color: var(--color-n1); + font-size: 13px; + padding: 0 20px; + outline: none; + transition: border-color 0.15s ease; +} +input:hover, +select:hover { + border-color: var(--color-shade1); +} +input:focus, +select:focus { + border-color: var(--color-green); +} + +/* Buttons match spacetimedb.com Button.module.css: + default (tertiary) = shade7 bg, n2 text, shade4 hover bg, green active text + primary = n3 bg, n8 text, white hover bg, green active bg + secondary = transparent bg, shade1 border, green-on-active text + ghost / text = transparent bg, green text, shade5 hover bg */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 8px 16px; + border-radius: 4px; + border: 1px solid transparent; + background: var(--color-shade7); + color: var(--color-n2); + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s, + color 0.2s; +} +.btn:hover:not(:disabled) { + background: var(--color-shade4); + color: var(--color-white); +} +.btn:active:not(:disabled) { + background: var(--color-shade4); + color: var(--color-green); +} +.btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); + color: var(--color-n8); +} +.btn.primary:disabled { + pointer-events: none; + background-color: var(--color-n4); + border-color: var(--color-n4); +} + +.btn.secondary { + background: transparent; + border: 1px solid var(--color-shade1); + color: var(--color-white); +} +.btn.secondary:hover:not(:disabled) { + background: transparent; + border-color: var(--color-white); + color: var(--color-white); +} +.btn.secondary:active:not(:disabled) { + background: transparent; + border-color: var(--color-green); + color: var(--color-green); +} + +.btn.ghost { + background: transparent; + border: none; + color: var(--color-green); +} +.btn.ghost:hover:not(:disabled) { + background: var(--color-shade5); + color: var(--color-green); +} +.btn.tiny { + height: auto; + padding: 4px 9px; + border-radius: 999px; + font-size: 11px; + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.room-list-wrap { + min-height: 0; + overflow: auto; + padding: 8px; +} +.rooms { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} +.category { + margin-top: 4px; +} +.category > summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + padding: 12px 6px 4px; + font-family: var(--font-ibm); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-n4); + font-weight: 600; + user-select: none; +} +.category > summary::-webkit-details-marker { + display: none; +} +.category-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + flex: 0 0 12px; + transition: transform 120ms ease; + color: currentColor; +} +.category-chevron svg { + width: 12px; + height: 12px; + display: block; +} +.category:not([open]) .category-chevron { + transform: rotate(-90deg); +} +.category > summary:hover { + color: var(--color-n2); +} +.category-rooms { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.room-row { + border: 1px solid transparent; + border-radius: 4px; + padding: 5px 8px; + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + color: var(--color-n3); + font-size: 15px; + position: relative; +} +.channel-hash { + color: var(--color-n4); + font-weight: 400; + font-size: 18px; + line-height: 1; + margin-right: 2px; +} +.room-row:hover { + background: var(--color-shade4); + color: var(--color-n1); +} +.room-row:hover .channel-hash { + color: var(--color-n2); +} +.room-row.active { + background: var(--color-shade3); + color: var(--color-n1); +} +.room-row.active .channel-hash { + color: var(--color-n1); +} +.room-row.has-unread:not(.active) { + color: var(--color-n1); +} +.room-row.has-unread:not(.active) .channel-hash { + color: var(--color-n1); +} +.room-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.unread-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-green); + flex: 0 0 8px; + margin-left: auto; +} +.small { + color: var(--color-n4); + font-size: 11px; +} +.badge { + border-radius: 999px; + padding: 1px 6px; + background: #1f3f55; + color: #98d8ff; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.room-row .row-actions { + display: none; + gap: 4px; + align-items: center; +} +.room-row:hover .row-actions { + display: inline-flex; +} +.room-row .lock-icon { + color: var(--color-n4); + font-size: 12px; +} + +.user-panel { + border-top: 1px solid var(--color-shade4); + padding: 10px; +} +#openAuthBtn { + width: 100%; +} + +.main { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto auto; +} +.main-head { + border-bottom: 1px solid var(--color-shade4); + padding: 0 14px; + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.main-head h2 { + margin: 0; + color: var(--color-n1); + font-size: 16px; + font-weight: 600; + letter-spacing: 0; +} +.main-meta { + display: flex; + align-items: center; + gap: 4px; +} + +.message-scroll { + min-height: 0; + overflow: auto; +} +.messages { + list-style: none; + margin: 0; + padding: 12px 0 16px; +} +.avatar { + width: var(--av-size, 32px); + height: var(--av-size, 32px); + border-radius: 50%; + background: var(--av-bg, var(--color-shade3)); + color: #fff; + font-family: var(--font-inter); + font-size: calc(var(--av-size, 32px) * 0.42); + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 var(--av-size, 32px); + user-select: none; +} +.msg { + position: relative; + padding: 2px 16px 2px 16px; + display: grid; + grid-template-columns: 40px 1fr; + gap: 0 12px; +} +.msg.chunk-start { + margin-top: 14px; + padding-top: 4px; +} +.msg.chunk-cont { + padding-top: 0; + padding-bottom: 0; +} +.msg:hover { + background: rgba(255, 255, 255, 0.025); +} +.msg-stamp-rail { + width: 36px; + height: 18px; + display: inline-block; +} +.msg-col { + min-width: 0; +} +.msg-head { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 1px; +} +.msg-author { + color: var(--color-n1); + font-size: 14px; + font-weight: 600; +} +.msg-time { + color: var(--color-n4); + font-size: 11px; + font-family: var(--font-ibm); +} +/* Chunk-start rows carry timestamps. */ +.msg-body { + color: var(--color-n2); + font-size: 14px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} +.edited-tag { + color: var(--color-n4); + font-size: 10px; + font-family: var(--font-ibm); + margin-left: 4px; +} +.reply-reference { + display: inline-flex; + position: relative; + align-items: center; + max-width: min(520px, 100%); + gap: 6px; + margin: -3px 0 2px 28px; + padding: 0; + border: none; + background: transparent; + color: var(--color-n4); + font: inherit; + font-size: 12px; + line-height: 1.3; + text-align: left; + cursor: pointer; +} +.reply-reference::before { + content: ''; + position: absolute; + left: -26px; + top: 4px; + width: 20px; + height: 8px; + border-left: 1px solid var(--color-n4); + border-top: 1px solid var(--color-n4); + border-radius: 6px 0 0 0; + opacity: 0.5; +} +.reply-reference:hover { + color: var(--color-n2); +} +.reply-author { + color: var(--color-n2); + font-weight: 600; +} +.reply-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.react-row { + margin-top: 4px; + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; +} +.react-btn { + border: 1px solid var(--color-shade1); + border-radius: 999px; + background: var(--color-shade6); + color: var(--color-n3); + padding: 3px 8px; + font-size: 11px; + cursor: pointer; +} +.react-btn:hover { + border-color: var(--color-white); + color: var(--color-white); +} +.react-btn.mine { + border-color: #2f7452; + background: #163629; + color: #b4f7d1; +} + +.empty-state { + padding: 40px 16px; + text-align: left; + color: var(--color-n3); +} +.empty-state h3 { + margin: 0 0 6px; + font-size: 28px; + line-height: 1.2; + font-weight: 700; + color: var(--color-n1); + letter-spacing: -0.02em; +} +.empty-state p { + margin: 0 0 14px; + color: var(--color-n4); + font-size: 14px; +} + +.sidebar-empty { + padding: 24px 12px; + color: var(--color-n4); + font-size: 13px; + display: flex; + flex-direction: column; + gap: 12px; +} +.sidebar-empty p { + margin: 0; +} + +.landing { + margin: 22px auto; + width: min(640px, 100%); + border: 1px solid #1f4a5d; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade2), var(--color-shade3)); + padding: 22px; + display: grid; + gap: 10px; +} +.landing h3 { + margin: 0; + color: var(--color-n1); + font-size: 31px; + font-weight: 500; + line-height: 1; + letter-spacing: -0.04em; +} +.landing p { + margin: 0; + color: var(--color-n3); + font-size: 16px; +} + +.typing { + min-height: 28px; + padding: 6px 14px; + color: var(--color-n4); + font-size: 12px; +} +.composer { + padding: 10px 12px; + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} +.head-icon { + background: transparent; + border: none; + color: var(--color-n3); + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.head-icon:hover { + color: var(--color-n1); + background: var(--color-shade4); +} + +.user-bar { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--color-n1); + cursor: pointer; + text-align: left; +} +.user-bar:hover { + background: var(--color-shade4); +} +.user-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--color-green); + color: var(--color-shade7); + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--font-ibm); + font-size: 11px; + font-weight: 700; + flex: 0 0 28px; +} +.user-bar-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; +} +.user-bar-cog { + color: var(--color-n4); + flex: 0 0 14px; +} +.user-bar:hover .user-bar-cog { + color: var(--color-n1); +} + +.user-menu { + position: fixed; + bottom: 60px; + left: 88px; + width: 260px; + background: var(--color-shade5); + border: 1px solid var(--color-shade3); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5); + padding: 12px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 10px; +} +.user-menu[hidden] { + display: none; +} +.user-menu-row { + display: flex; + flex-direction: column; + gap: 4px; +} +.user-menu-row label { + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-n4); +} +.user-menu-sep { + height: 1px; + background: var(--color-shade3); + margin: 2px 0; +} +.btn.danger { + color: var(--color-orange); + border-color: #4a2222; +} +.btn.danger:hover { + background: #2a1212; + border-color: #6a2929; +} +.btn.block { + width: 100%; +} + +.toast-host { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 8px; + z-index: 2000; + pointer-events: none; +} +.toast { + background: var(--color-shade5); + border: 1px solid #2f644a; + color: var(--color-green); + font-family: var(--font-ibm); + font-size: 12px; + padding: 8px 14px; + border-radius: 999px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + opacity: 1; + transition: + opacity 220ms ease, + transform 220ms ease; +} +.toast.error { + border-color: #6a2929; + color: var(--color-orange); +} +.toast.hide { + opacity: 0; + transform: translateY(8px); +} + +.members.collapsed { + display: none; +} +.members { + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + border-left: 1px solid var(--color-shade4); + min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); +} +.members-head { + border-bottom: 1px solid var(--color-shade4); + padding: 12px; + display: flex; + align-items: center; + justify-content: space-between; +} +.members-head h2 { + margin: 0; + color: var(--color-n1); + font-size: 28px; + font-weight: 500; + letter-spacing: -0.04em; +} +.profile { + border-bottom: 1px solid var(--color-shade4); + padding: 10px 12px; + display: grid; + gap: 8px; +} +.profile-row { + display: grid; + grid-template-columns: 1fr 90px; + gap: 8px; +} +.member-scroll { + min-height: 0; + overflow: auto; +} +.member-list { + list-style: none; + margin: 0; + padding: 8px 6px; + display: flex; + flex-direction: column; + gap: 1px; +} +.member-section { + font-family: var(--font-ibm); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-n4); + font-weight: 600; + padding: 14px 6px 4px; +} +.member-row { + border-radius: 4px; + padding: 4px 6px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} +.member-row:hover { + background: var(--color-shade4); +} +.member-row.offline { + opacity: 0.4; +} +.member-row.offline:hover { + opacity: 0.7; +} +.member-avatar-wrap { + position: relative; + flex: 0 0 32px; +} +.presence-pip { + position: absolute; + right: -2px; + bottom: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-n4); + border: 2px solid var(--color-shade5); +} +.presence-pip.online { + background: var(--color-green); +} +.presence-pip.away { + background: #f3c460; +} +.presence-pip.dnd { + background: #ff4c4c; +} +.presence-pip.invisible, +.presence-pip.offline { + background: var(--color-n4); +} +.member-name { + flex: 1; + font-size: 14px; + color: var(--color-n2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.member-you { + color: var(--color-n4); + font-size: 12px; + font-weight: 400; +} +.empty { + padding: 12px; + color: var(--color-n4); + font-size: 12px; +} + +.modal { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 16px; + background: #00000099; + z-index: 50; +} +.modal.open { + display: flex; +} +.modal-card { + width: min(420px, 100%); + border: 1px solid #1f4a5d; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + box-shadow: 0 22px 52px #00000088; +} +.modal-head { + border-bottom: 1px solid var(--color-shade4); + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; +} +.modal-head h3 { + margin: 0; + color: var(--color-n1); + font-size: 16px; +} +.modal-body { + padding: 12px; + display: grid; + gap: 8px; +} +.modal-actions { + display: flex; + gap: 8px; +} + +@media (max-width: 1300px) { + .shell { + grid-template-columns: 72px 284px minmax(0, 1fr); + } + .members { + display: none; + } +} +@media (max-width: 980px) { + .shell { + grid-template-columns: 1fr; + } + .rail, + .sidebar { + display: none; + } +} diff --git a/spacetime-presence-ts/example/public/ui.js b/spacetime-presence-ts/example/public/ui.js new file mode 100644 index 00000000000..ea8c726bac6 --- /dev/null +++ b/spacetime-presence-ts/example/public/ui.js @@ -0,0 +1,1896 @@ +import { + activeRoom, + activeServer, + amServerOwner, + attachmentsByMessage, + canDeleteMessage, + canEditMessage, + globalPresenceBySubject, + hex, + latestMessageByRoom, + messageAuthorName, + messageById, + messageSummary, + myMemberships, + myReadCursorByRoom, + myServers, + roomMembers, + roomsInServer, + statusOf, + threadMessageById, + threadMessagesForRoot, + typingForRoom, + userByHex, + userByUserId, +} from './chat-model.js'; +import { applyChatData, chatState as state } from './chat-state.js'; + +const $ = id => document.getElementById(id); +const REACTIONS = [ + { id: '+1', label: '\u{1F44D}' }, + { id: 'heart', label: '\u2764\uFE0F' }, + { id: 'joy', label: '\u{1F602}' }, + { id: 'wow', label: '\u{1F62E}' }, + { id: 'sad', label: '\u{1F622}' }, + { id: 'fire', label: '\u{1F525}' }, +]; + +let typingTimer = null; +let typingRenewTimer = null; +let pendingAtts = []; // { id, file, name, mimeType, bytes, previewUrl } +let pendingAttSeq = 0; +let replyTargetId = null; +let editTargetId = null; +let activeThreadRootMessageId = null; +let threadEditTargetId = null; +let composerRateLimitTimer = null; +const ATT_MAX_BYTES = 4_000_000; +const ATT_MAX_COUNT = 5; +const AUTH_TOKEN_KEY = 'chat:auth_token'; +const STDB_TOKEN_KEY = 'chat:stdb_token'; +const attachmentBlobUrls = new Map(); + +function fmtBytes(n) { + if (!Number.isFinite(n)) return '?'; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +} + +function authHeaderCandidates() { + try { + const tokens = [ + localStorage.getItem(AUTH_TOKEN_KEY), + localStorage.getItem(STDB_TOKEN_KEY), + ].filter((token, idx, arr) => token && arr.indexOf(token) === idx); + return tokens.map(token => ({ authorization: `Bearer ${token}` })); + } catch { + return []; + } +} + +async function attachmentBlobUrl(fileId, url) { + const cacheKey = `file:${fileId}`; + let blobUrl = attachmentBlobUrls.get(cacheKey); + if (blobUrl) return blobUrl; + if (window.chat?.getAttachmentFile) { + const file = await window.chat.getAttachmentFile(BigInt(fileId)); + blobUrl = URL.createObjectURL( + new Blob([file.bytes], { type: file.mimeType }) + ); + } else if (url) { + const attempts = [...authHeaderCandidates(), {}]; + let res = null; + for (const headers of attempts) { + res = await fetch(url, { headers, credentials: 'same-origin' }); + if (res.ok) break; + if (res.status !== 401 && res.status !== 403) break; + } + if (!res) throw new Error('no_response'); + if (!res.ok) throw new Error(`http_${res.status}`); + blobUrl = URL.createObjectURL(await res.blob()); + } else { + throw new Error('missing_file_url'); + } + attachmentBlobUrls.set(cacheKey, blobUrl); + return blobUrl; +} + +async function hydrateAttachmentImages(root = document) { + const imgs = [...root.querySelectorAll('img[data-file-id]')]; + for (const img of imgs) { + const fileId = img.dataset.fileId; + const url = img.dataset.fileUrl; + if (!fileId || img.dataset.loaded === '1') continue; + img.dataset.loaded = '1'; + try { + const blobUrl = await attachmentBlobUrl(fileId, url); + img.src = blobUrl; + const preview = img.closest('[data-preview-file-id]'); + if (preview) preview.dataset.previewSrc = blobUrl; + } catch (err) { + img.dataset.loaded = '0'; + img.classList.add('broken'); + img.alt = `${img.alt || 'attachment'} (failed to load)`; + console.warn('attachment image load failed', url, err); + } + } +} + +function closeImageLightbox() { + const box = $('imageLightbox'); + box.classList.remove('open'); + $('imageLightboxImg').removeAttribute('src'); + $('imageLightboxCaption').textContent = ''; +} + +function showImageLightbox(src, name) { + $('imageLightboxImg').src = src; + $('imageLightboxImg').alt = name || 'attachment'; + $('imageLightboxCaption').textContent = name || ''; + $('imageLightbox').classList.add('open'); + $('imageLightboxClose').focus(); +} + +async function openAttachmentPreview(button) { + const fileId = button.dataset.previewFileId; + const url = button.dataset.previewUrl; + const name = button.dataset.previewName || 'attachment'; + if (!fileId) return; + try { + const src = + button.dataset.previewSrc || (await attachmentBlobUrl(fileId, url)); + button.dataset.previewSrc = src; + showImageLightbox(src, name); + } catch (err) { + console.warn('attachment preview failed', err); + setResult('Image preview failed.', false); + } +} + +document.addEventListener('click', e => { + const button = e.target.closest('[data-preview-file-id]'); + if (!button) return; + e.preventDefault(); + openAttachmentPreview(button); +}); +$('imageLightboxClose').addEventListener('click', closeImageLightbox); +$('imageLightbox').addEventListener('click', e => { + if (e.target === $('imageLightbox')) closeImageLightbox(); +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && $('imageLightbox').classList.contains('open')) { + closeImageLightbox(); + } +}); + +function openAuthModal() { + $('authModal').classList.add('open'); + $('authEmail').focus(); +} +function closeAuthModal() { + $('authModal').classList.remove('open'); +} +function setResult(text, ok = true) { + if (!text) return; + const host = + document.getElementById('toastHost') || + (() => { + const d = document.createElement('div'); + d.id = 'toastHost'; + d.className = 'toast-host'; + document.body.appendChild(d); + return d; + })(); + const el = document.createElement('div'); + el.className = 'toast' + (ok ? '' : ' error'); + el.textContent = text; + host.appendChild(el); + setTimeout(() => { + el.classList.add('hide'); + setTimeout(() => el.remove(), 250); + }, 2200); +} +function reportAsync(label, promise) { + Promise.resolve(promise).catch(err => + setResult(`${label} failed: ${err.message ?? err}`, false) + ); +} +function resetAtMs(row) { + return Number(row.resetAt.microsSinceUnixEpoch) / 1000; +} +function currentSendLimit() { + if (!state.authenticated || !activeRoom()) return null; + const row = (state.rateLimitStatus || []).find( + r => r.scope === 'chat.send_message' + ); + if (!row || row.remaining > 0) return null; + const remainingSeconds = Math.ceil((resetAtMs(row) - Date.now()) / 1000); + return remainingSeconds > 0 ? { ...row, remainingSeconds } : null; +} +function renderComposerRateLimit() { + const el = $('composerRateLimit'); + if (!el) return; + const limited = currentSendLimit(); + el.hidden = !limited; + el.textContent = limited + ? `Rate limited. Try again in ${limited.remainingSeconds}s.` + : ''; +} +function updateRateLimitTicker() { + const limited = currentSendLimit(); + if (limited && !composerRateLimitTimer) { + composerRateLimitTimer = setInterval(updateComposerState, 250); + } else if (!limited && composerRateLimitTimer) { + clearInterval(composerRateLimitTimer); + composerRateLimitTimer = null; + } +} +function renderUserBar() { + if (!state.authenticated) return; + const users = userByHex(); + const me = users.get(state.meHex); + const fallback = + state.userEmail || (state.meHex ? state.meHex.slice(-6) : ''); + const label = me?.displayName || fallback; + const avatarSeed = label || '?'; + $('userBarName').textContent = label; + $('userAvatar').textContent = (avatarSeed[0] || '?').toUpperCase(); + const dn = $('displayName'); + if (dn && document.activeElement !== dn) dn.value = me?.displayName || ''; +} + +function setAuthedUi(user) { + const openBtn = $('openAuthBtn'); + const userBar = $('openUserMenuBtn'); + const shell = document.querySelector('.shell'); + const app = document.querySelector('.app'); + if (user) { + openBtn.classList.add('hidden'); + userBar.classList.remove('hidden'); + state.userEmail = user.email || ''; + renderUserBar(); + shell.classList.remove('signed-out'); + app.classList.remove('signed-out'); + closeAuthModal(); + } else { + openBtn.classList.remove('hidden'); + userBar.classList.add('hidden'); + $('userBarName').textContent = ''; + $('userAvatar').textContent = ''; + state.userEmail = ''; + shell.classList.add('signed-out'); + app.classList.add('signed-out'); + closeUserMenu(); + } +} +function updateComposerState() { + const room = activeRoom(); + const enabled = state.authenticated && Boolean(room); + const sendLimited = Boolean(currentSendLimit()); + $('messageInput').disabled = !enabled || sendLimited; + $('sendBtn').disabled = !enabled || sendLimited; + $('sendBtn').title = sendLimited ? 'Rate limited' : 'Send'; + $('attachBtn').disabled = !enabled || sendLimited; + $('composerRow').classList.toggle('rate-limited', sendLimited); + $('threadInput').disabled = sendLimited; + $('threadComposer').querySelector('button').disabled = sendLimited; + $('saveProfileBtn').disabled = !state.authenticated; + $('displayName').disabled = !state.authenticated; + $('status').disabled = !state.authenticated; + $('typingLine').classList.toggle('hidden', !state.authenticated); + $('composerRow').classList.toggle('hidden', !state.authenticated); + if (!enabled) clearComposerTarget(); + else renderComposerReply(); + $('toggleMembersBtn').classList.toggle('hidden', !room); + $('membersPanel').classList.toggle('no-room', !room); + if (!state.authenticated) + $('messageInput').placeholder = 'Sign in to send messages'; + else if (!room) $('messageInput').placeholder = 'Select a channel'; + else $('messageInput').placeholder = `Message #${room.name}`; + renderComposerRateLimit(); + updateRateLimitTicker(); +} +function requireAuthAction() { + if (state.authenticated) return true; + openAuthModal(); + setResult('Sign in to continue.', false); + return false; +} +function microsToDate(micros) { + return new Date(Number(micros) / 1000); +} +function fmtTime(ts) { + return microsToDate(ts.microsSinceUnixEpoch).toLocaleTimeString(); +} +function editIcon() { + return ''; +} +function deleteIcon() { + return ''; +} +function renderReplyReference(message, users) { + const parentId = message.replyToMessageId; + if (parentId === undefined || parentId === null) return ''; + const parent = messageById(parentId); + return ``; +} +function renderComposerReply() { + const preview = $('replyPreview'); + if (editTargetId !== null) { + const target = messageById(editTargetId); + const valid = Boolean( + target && activeRoom() && target.roomId === activeRoom().id + ); + preview.classList.toggle('hidden', !valid); + preview.classList.toggle('editing', true); + if (valid) { + $('replyPreviewLabel').textContent = 'Editing message'; + $('replyPreviewText').textContent = messageSummary(target); + } else { + editTargetId = null; + } + return; + } + preview.classList.toggle('editing', false); + const target = replyTargetId === null ? null : messageById(replyTargetId); + const room = activeRoom(); + const valid = Boolean(target && room && target.roomId === room.id); + preview.classList.toggle('hidden', !valid); + if (!valid) return; + $('replyPreviewLabel').textContent = + `Replying to ${messageAuthorName(target)}`; + $('replyPreviewText').textContent = messageSummary(target); +} +function setReplyTarget(messageId) { + const target = messageById(messageId); + const room = activeRoom(); + if (!target || !room || target.roomId !== room.id) return; + clearEditTarget(); + replyTargetId = messageId; + renderComposerReply(); + $('messageInput').focus(); +} +function clearReplyTarget() { + replyTargetId = null; + $('replyPreview').classList.remove('editing'); + $('replyPreview').classList.add('hidden'); +} +function setEditTarget(messageId) { + const target = messageById(messageId); + const room = activeRoom(); + if (!target || !room || target.roomId !== room.id || !canEditMessage(target)) + return; + clearReplyTarget(); + clearPendingAtts(); + editTargetId = messageId; + $('messageInput').value = target.content || ''; + renderComposerReply(); + $('messageInput').focus(); +} +function clearEditTarget() { + editTargetId = null; + $('replyPreview').classList.remove('editing'); + $('replyPreview').classList.add('hidden'); +} +function clearComposerTarget() { + clearReplyTarget(); + clearEditTarget(); +} +function closeThreadPanel() { + activeThreadRootMessageId = null; + clearThreadEditTarget(); + $('threadPanel').hidden = true; +} +function openThread(rootMessageId) { + const root = messageById(rootMessageId); + const room = activeRoom(); + if (!root || !room || root.roomId !== room.id) return; + activeThreadRootMessageId = rootMessageId; + closePinnedPanel(); + closeSearchPanel(); + renderThreadPanel(); + $('threadPanel').hidden = false; + $('threadInput').focus(); +} +function renderThreadPanel() { + const panel = $('threadPanel'); + if (activeThreadRootMessageId === null) { + panel.hidden = true; + return; + } + const root = messageById(activeThreadRootMessageId); + const room = activeRoom(); + if (!root || !room || root.roomId !== room.id) { + closeThreadPanel(); + return; + } + const users = userByHex(); + const threadMessages = threadMessagesForRoot(root.id); + if (threadEditTargetId !== null && !threadMessageById(threadEditTargetId)) + clearThreadEditTarget(); + $('threadHead').textContent = `Thread in #${room.name}`; + $('threadRoot').innerHTML = ` +
          Original message
          +
          + ${escapeHtml(messageAuthorName(root, users))} + ${fmtTime(root.createdAt)} +
          +
          ${escapeHtml(messageSummary(root))}
          + `; + $('threadList').innerHTML = + threadMessages.length === 0 + ? '
        • No thread replies yet.
        • ' + : threadMessages + .map( + m => ` +
        • +
          + ${escapeHtml(messageAuthorName(m, users))} + ${fmtTime(m.createdAt)} + ${m.editedAt ? '(edited)' : ''} + ${ + canEditMessage(m) || canDeleteMessage(m) + ? ` + ${canEditMessage(m) ? `` : ''} + ${canDeleteMessage(m) ? `` : ''} + ` + : '' + } +
          +
          ${escapeHtml(m.content)}
          +
        • + ` + ) + .join(''); + $('threadList') + .querySelectorAll('[data-thread-edit]') + .forEach(btn => { + btn.addEventListener('click', () => + setThreadEditTarget(BigInt(btn.dataset.threadEdit)) + ); + }); + $('threadList') + .querySelectorAll('[data-thread-delete]') + .forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Delete this thread message?')) return; + try { + await window.chat.deleteThreadMessage( + BigInt(btn.dataset.threadDelete) + ); + } catch (err) { + setResult(`thread delete failed: ${err.message ?? err}`, false); + } + }); + }); +} +function setThreadEditTarget(messageId) { + const target = threadMessageById(messageId); + if (!target || !canEditMessage(target)) return; + threadEditTargetId = messageId; + $('threadInput').value = target.content || ''; + $('threadInput').placeholder = 'Edit thread message'; + $('threadInput').focus(); +} +function clearThreadEditTarget() { + threadEditTargetId = null; + const input = $('threadInput'); + if (input) { + input.value = ''; + input.placeholder = 'Reply in thread'; + } +} +function bindRoomActions(container) { + container.querySelectorAll('[data-room-id]').forEach(node => { + node.addEventListener('click', async e => { + const target = e.target.closest('[data-action]') || e.target; + const roomId = BigInt(node.dataset.roomId); + if (target?.dataset?.action === 'leave') { + if (!requireAuthAction()) return; + try { + await window.chat.leaveRoom(roomId); + if (state.activeRoomId === roomId) { + state.activeRoomId = null; + clearComposerTarget(); + closeThreadPanel(); + window.chat.setActiveRoom(null); + } + } catch (err) { + console.error('leaveRoom failed', err); + setResult(`leave failed: ${err.message ?? err}`, false); + } + return; + } + if (target?.dataset?.action === 'settings') { + e.stopPropagation(); + if (!requireAuthAction()) return; + openChannelSettings(roomId); + return; + } + if (!requireAuthAction()) return; + const joined = new Set(myMemberships().map(x => x.toString())); + if (!joined.has(roomId.toString())) { + try { + await window.chat.joinRoom(roomId); + } catch (err) { + console.error('joinRoom failed', err); + setResult(`open failed: ${err.message ?? err}`, false); + return; + } + } + if (state.activeRoomId && state.activeRoomId !== roomId) { + stopTypingNow(); + clearComposerTarget(); + closeThreadPanel(); + } + state.activeRoomId = roomId; + window.chat.setActiveRoom(roomId); + try { + await window.chat.markRoomRead(roomId); + } catch (err) { + console.warn('markRoomRead failed', err); + } + renderAll(); + }); + }); +} + +function renderMessageAttachments( + messageId, + groupedAtts = attachmentsByMessage() +) { + const list = groupedAtts.get(messageId); + if (!list || list.length === 0) return ''; + return `
          ${list + .map(a => { + const isImg = a.mimeType.startsWith('image/'); + const url = `/files?id=${encodeURIComponent(a.fileId.toString())}`; + const fname = a.filename || `attachment-${a.id.toString()}`; + if (isImg) { + return ``; + } + return ` + ${escapeHtml(fname)} + ${fmtBytes(Number(a.size))} + `; + }) + .join('')}
          `; +} + +function hueFromString(s) { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) | 0; + } + return Math.abs(h) % 360; +} +function avatarSwatch(seed, label, sizePx) { + const hue = hueFromString(seed || '?'); + const text = (label || '?').trim().charAt(0).toUpperCase() || '?'; + return `${escapeHtml(text)}`; +} + +function renderServerRail() { + const rail = $('serverRail'); + if (!rail) return; + const mine = myServers(); + rail.innerHTML = mine + .map(s => { + const initials = (s.name || '?').trim().slice(0, 2).toUpperCase() || '?'; + const isActive = state.activeServerId === s.id; + return `
        • `; + }) + .join(''); + rail.querySelectorAll('[data-server-id]').forEach(btn => { + btn.addEventListener('click', () => { + const id = BigInt(btn.dataset.serverId); + if (state.activeServerId === id) return; + window.chat.setActiveServer(id); + }); + }); +} + +function renderSidebarHead() { + const srv = activeServer(); + const nameEl = $('activeServerName'); + if (nameEl) + nameEl.textContent = srv + ? srv.name + : state.authenticated && myServers().length === 0 + ? 'No server' + : 'SpacetimeDB'; + $('sidebarHead').classList.toggle('hidden', !state.authenticated); +} + +function renderRooms() { + const roomList = $('roomList'); + const latestByRoom = latestMessageByRoom(); + const myCursor = myReadCursorByRoom(); + + const visibleRooms = + state.activeServerId !== null ? roomsInServer(state.activeServerId) : []; + + const grouped = new Map(); + for (const r of visibleRooms) { + const cat = + r.category && r.category.trim() ? r.category.trim() : 'Text Channels'; + if (!grouped.has(cat)) grouped.set(cat, []); + grouped.get(cat).push(r); + } + const catNames = [...grouped.keys()].sort((a, b) => { + if (a === 'Text Channels') return -1; + if (b === 'Text Channels') return 1; + return a.localeCompare(b); + }); + + const isAdmin = + (state.admins || []).length === 0 || + (state.admins || []).includes(state.meHex); + const renderRow = r => { + const latest = latestByRoom.get(r.id); + const read = myCursor.get(r.id) ?? 0n; + const unread = latest ? latest.id > read : false; + const lockIcon = r.isPrivate + ? '🔒' + : ''; + const ownsRoom = r.createdByUserId === state.userId; + const canEdit = ownsRoom || isAdmin; + const gearSvg = + ''; + const settingsBtn = canEdit + ? `` + : ''; + const leaveBtn = !canEdit + ? '' + : ''; + const action = `${settingsBtn}${leaveBtn}`; + const badge = unread ? '' : ''; + return `
        • + # + ${escapeHtml(r.name)} + ${lockIcon}${badge} + ${action} +
        • `; + }; + + if (state.activeServerId === null) { + if (myServers().length === 0) { + roomList.innerHTML = ``; + $('sidebarCreateFirstServer')?.addEventListener( + 'click', + openCreateServerModal + ); + } else { + roomList.innerHTML = ``; + } + return; + } + if (visibleRooms.length === 0) { + roomList.innerHTML = ``; + $('sidebarCreateFirst')?.addEventListener('click', openCreateRoomModal); + return; + } + + const sections = catNames + .map(cat => { + const rows = grouped.get(cat).map(renderRow).join(''); + return `
          + + + ${escapeHtml(cat.toUpperCase())} + + +
            ${rows}
          +
          `; + }) + .join(''); + + roomList.innerHTML = sections; + bindRoomActions(roomList); +} + +function renderMessages() { + const room = activeRoom(); + const ul = $('messageList'); + if (!state.authenticated) { + ul.innerHTML = ''; + return; + } + if (!room) { + const mine = myServers(); + if (mine.length === 0) { + ul.innerHTML = `
        • Welcome

          Create your first server to start chatting.

        • `; + $('emptyCreateBtn')?.addEventListener('click', openCreateServerModal); + $('roomTitle').textContent = ''; + } else if (state.activeServerId === null) { + ul.innerHTML = `
        • Pick a server

          Choose a server on the left.

        • `; + $('roomTitle').textContent = ''; + } else if (roomsInServer(state.activeServerId).length === 0) { + ul.innerHTML = `
        • No channels yet

          Create a channel to start chatting in this server.

        • `; + $('emptyCreateRoomBtn')?.addEventListener('click', openCreateRoomModal); + $('roomTitle').textContent = ''; + } else { + ul.innerHTML = `
        • No channel selected

          Pick a channel on the left to start.

        • `; + $('roomTitle').textContent = 'Select a channel'; + } + const hash = document.getElementById('channelHashIcon'); + if (hash) hash.hidden = true; + return; + } + const hashEl = document.getElementById('channelHashIcon'); + if (hashEl) hashEl.hidden = false; + $('roomTitle').textContent = room.name; + const users = userByHex(); + const roomMessages = state.messages + .filter(m => m.roomId === room.id) + .slice(-300); + if (roomMessages.length === 0) { + ul.innerHTML = `
        • Welcome to #${escapeHtml(room.name)}!

          This is the start of the #${escapeHtml(room.name)} channel.

        • `; + return; + } + + const groupedReactions = new Map(); + for (const r of state.reactions) { + let byEmoji = groupedReactions.get(r.messageId); + if (!byEmoji) { + byEmoji = new Map(); + groupedReactions.set(r.messageId, byEmoji); + } + let entry = byEmoji.get(r.emoji); + if (!entry) { + entry = { count: 0, mine: false }; + byEmoji.set(r.emoji, entry); + } + entry.count++; + if (hex(r.identity) === state.meHex) entry.mine = true; + } + + const groupedAtts = attachmentsByMessage(); + const CHUNK_GAP_MICROS = 5n * 60n * 1_000_000n; // 5 minutes + const chipMap = new Map(REACTIONS.map(x => [x.id, x.label])); + + let prev = null; + const html = roomMessages + .map(m => { + const authorHex = hex(m.author); + const author = users.get(authorHex); + const name = author?.displayName || authorHex.slice(-6); + + const sameAuthor = prev && hex(prev.author) === authorHex; + const gap = prev + ? m.createdAt.microsSinceUnixEpoch - prev.createdAt.microsSinceUnixEpoch + : null; + const chunkStart = + !sameAuthor || (gap !== null && gap > CHUNK_GAP_MICROS); + prev = m; + + const byEmoji = groupedReactions.get(m.id) || new Map(); + const chips = [...byEmoji.entries()] + .map( + ([emoji, meta]) => + `` + ) + .join(''); + const reactionRow = chips ? `
          ${chips}
          ` : ''; + const mine = new Set( + [...byEmoji.entries()] + .filter(([, meta]) => meta.mine) + .map(([emoji]) => emoji) + ); + const quickReactions = REACTIONS.filter(r => !mine.has(r.id)) + .map( + r => + `` + ) + .join(''); + const editedTag = m.editedAt + ? ' (edited)' + : ''; + const replyRef = renderReplyReference(m, users); + + const isPinned = m.pinnedAt !== undefined && m.pinnedAt !== null; + const pinIcon = isPinned ? 'active' : ''; + const pinTitle = isPinned ? 'Unpin' : 'Pin'; + const threadMessages = threadMessagesForRoot(m.id); + const hasThread = threadMessages.length > 0; + const threadPill = hasThread + ? `` + : ''; + const replySvg = + ''; + const threadSvg = + ''; + const pinSvg = + ''; + const toolbarSep = quickReactions + ? '' + : ''; + const toolbar = `
          + ${quickReactions} + ${toolbarSep} + + + + ${canEditMessage(m) ? `` : ''} + ${canDeleteMessage(m) ? `` : ''} +
          `; + const pinnedMarker = isPinned + ? `${pinSvg}pinned` + : ''; + + if (chunkStart) { + return `
        • + ${avatarSwatch(authorHex, name, 36)} +
          +
          + ${escapeHtml(name)} + ${fmtTime(m.createdAt)} + ${pinnedMarker} +
          + ${replyRef} +
          ${escapeHtml(m.content)}${editedTag}
          + ${renderMessageAttachments(m.id, groupedAtts)} + ${reactionRow}${threadPill} +
          + ${toolbar} +
        • `; + } + return `
        • + +
          + ${replyRef} +
          ${escapeHtml(m.content)}${editedTag}${pinnedMarker}
          + ${renderMessageAttachments(m.id, groupedAtts)} + ${reactionRow}${threadPill} +
          + ${toolbar} +
        • `; + }) + .join(''); + ul.innerHTML = html; + hydrateAttachmentImages(ul); + + ul.querySelectorAll('[data-react]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + const mid = BigInt(btn.dataset.mid); + const emoji = btn.dataset.react; + try { + reportAsync('reaction', window.chat.toggleReaction(mid, emoji)); + } catch (err) { + setResult(`reaction failed: ${err.message ?? err}`, false); + } + }); + }); + ul.querySelectorAll('[data-reply]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + setReplyTarget(BigInt(btn.dataset.reply)); + }); + }); + ul.querySelectorAll('[data-thread]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + openThread(BigInt(btn.dataset.thread)); + }); + }); + ul.querySelectorAll('[data-jump-message]').forEach(btn => { + btn.addEventListener('click', () => { + const target = ul.querySelector( + `[data-message-id="${btn.dataset.jumpMessage}"]` + ); + if (target) + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }); + }); + ul.querySelectorAll('[data-pin]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + const mid = BigInt(btn.dataset.pin); + const isPinned = btn.classList.contains('active'); + try { + if (isPinned) reportAsync('unpin', window.chat.unpinMessage(mid)); + else reportAsync('pin', window.chat.pinMessage(mid)); + } catch (err) { + setResult(`pin failed: ${err.message ?? err}`, false); + } + }); + }); + ul.querySelectorAll('[data-edit]').forEach(btn => { + btn.addEventListener('click', () => { + if (!requireAuthAction()) return; + setEditTarget(BigInt(btn.dataset.edit)); + }); + }); + ul.querySelectorAll('[data-delete]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!requireAuthAction()) return; + if (!confirm('Delete this message?')) return; + try { + await window.chat.deleteMessage(BigInt(btn.dataset.delete)); + } catch (err) { + setResult(`delete failed: ${err.message ?? err}`, false); + } + }); + }); +} + +function closePinnedPanel() { + $('pinnedPanel').hidden = true; +} +function closeSearchPanel() { + $('searchPanel').hidden = true; + $('searchList').innerHTML = ''; +} +function renderPinnedPanel() { + const room = activeRoom(); + const list = $('pinnedList'); + if (!room) { + list.innerHTML = '
        • No channel selected.
        • '; + return; + } + const users = userByHex(); + const pinned = state.messages + .filter(m => m.roomId === room.id && m.pinnedAt) + .sort((a, b) => + a.pinnedAt.microsSinceUnixEpoch < b.pinnedAt.microsSinceUnixEpoch ? 1 : -1 + ); + if (pinned.length === 0) { + list.innerHTML = + '
        • No pinned messages in this channel.
        • '; + return; + } + const groupedAtts = attachmentsByMessage(); + list.innerHTML = pinned + .map(m => { + const author = users.get(hex(m.author)); + const name = author?.displayName || hex(m.author).slice(-6); + return `
        • +
          + ${escapeHtml(name)} + ${fmtTime(m.createdAt)} +
          +
          ${escapeHtml(m.content)}
          + ${renderMessageAttachments(m.id, groupedAtts)} +
        • `; + }) + .join(''); + hydrateAttachmentImages(list); +} +$('togglePinnedBtn').addEventListener('click', () => { + if ($('pinnedPanel').hidden) { + closeSearchPanel(); + renderPinnedPanel(); + $('pinnedPanel').hidden = false; + } else { + closePinnedPanel(); + } +}); +$('closePinnedBtn').addEventListener('click', closePinnedPanel); + +$('searchForm').addEventListener('submit', async e => { + e.preventDefault(); + if (!requireAuthAction()) return; + const room = activeRoom(); + if (!room) return; + const q = $('searchInput').value.trim(); + if (!q) { + closeSearchPanel(); + return; + } + closePinnedPanel(); + $('searchHead').textContent = + `Search: "${q.length > 30 ? q.slice(0, 30) + '…' : q}"`; + $('searchList').innerHTML = '
        • Searching…
        • '; + $('searchPanel').hidden = false; + try { + const results = await window.chat.searchMessages(room.id, q); + const users = userByHex(); + if (!results || results.length === 0) { + $('searchList').innerHTML = '
        • No matches.
        • '; + return; + } + $('searchList').innerHTML = results + .map(m => { + const author = users.get(hex(m.author)); + const name = author?.displayName || hex(m.author).slice(-6); + return `
        • +
          + ${escapeHtml(name)} + ${fmtTime(m.createdAt)} +
          +
          ${escapeHtml(m.content)}
          +
        • `; + }) + .join(''); + } catch (err) { + $('searchList').innerHTML = + `
        • Error: ${escapeHtml(err.message ?? String(err))}
        • `; + } +}); +$('closeSearchBtn').addEventListener('click', closeSearchPanel); + +function renderTyping() { + const room = activeRoom(); + if (!room) { + $('typingLine').textContent = ''; + return; + } + const users = userByHex(); + const typing = typingForRoom(room.id) + .filter(id => id !== state.meHex) + .map(id => users.get(id)?.displayName || id.slice(-6)); + if (typing.length === 0) $('typingLine').textContent = ''; + else if (typing.length === 1) + $('typingLine').textContent = `${typing[0]} is typing...`; + else $('typingLine').textContent = `${typing.length} people are typing...`; +} + +function renderMembers() { + const room = activeRoom(); + const list = $('memberList'); + if (!state.authenticated) { + list.innerHTML = '
        • Sign in to view members.
        • '; + return; + } + if (!room) { + list.innerHTML = '
        • Select a channel.
        • '; + return; + } + const presenceMap = globalPresenceBySubject(); + const usersByUid = userByUserId(); + const rows = roomMembers(room.id) + .map(m => { + const user = usersByUid.get(m.userId); + const h = user ? hex(user.identity) : ''; + const status = statusOf(h, presenceMap); + return { + hex: h, + name: user?.displayName || m.userId.slice(-6), + status, + mine: m.userId === state.userId, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const online = rows.filter( + m => m.status !== 'invisible' && m.status !== 'offline' + ); + const offline = rows.filter( + m => m.status === 'invisible' || m.status === 'offline' + ); + + const renderRow = + m => `
        • + + ${avatarSwatch(m.hex, m.name, 32)} + + + ${escapeHtml(m.name)}${m.mine ? " (you)" : ''} +
        • `; + + const sections = []; + if (online.length) + sections.push( + `
        • Online (${online.length})
        • `, + ...online.map(renderRow) + ); + if (offline.length) + sections.push( + `
        • Offline (${offline.length})
        • `, + ...offline.map(renderRow) + ); + list.innerHTML = sections.join(''); + $('memberCount').textContent = `${rows.length}`; +} + +function scrollMessagesToBottom() { + const node = $('messageScroll'); + node.scrollTop = node.scrollHeight; +} +function renderAll() { + renderServerRail(); + renderSidebarHead(); + renderRooms(); + renderMessages(); + renderTyping(); + renderMembers(); + renderUserBar(); + updateComposerState(); + renderThreadPanel(); + if (!$('pinnedPanel').hidden) renderPinnedPanel(); +} +function escapeHtml(s) { + return String(s ?? '').replace( + /[&<>"']/g, + c => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[c] + ); +} + +function scheduleTyping() { + if (!state.authenticated) return; + const room = activeRoom(); + if (!room) return; + try { + void window.chat.startTyping(room.id).catch(() => {}); + } catch { + // The connection can close while the typing update is queued. + } + if (typingTimer) clearTimeout(typingTimer); + if (typingRenewTimer) clearInterval(typingRenewTimer); + typingTimer = setTimeout(() => { + try { + void window.chat.stopTyping(room.id).catch(() => {}); + } catch { + // The connection can close before the timer fires. + } + }, 1800); + typingRenewTimer = setInterval(() => { + try { + void window.chat.startTyping(room.id).catch(() => {}); + } catch { + // The connection can close while the typing state is renewed. + } + }, 1500); +} + +function stopTypingNow() { + const room = activeRoom(); + if (!room) return; + if (typingTimer) clearTimeout(typingTimer); + if (typingRenewTimer) clearInterval(typingRenewTimer); + typingTimer = null; + typingRenewTimer = null; + try { + void window.chat.stopTyping(room.id).catch(() => {}); + } catch { + // The connection can close while the typing state is cleared. + } +} + +function dismissBootSplash() { + const splash = document.getElementById('bootSplash'); + const app = document.querySelector('.app'); + if (app) app.classList.remove('loading'); + if (splash) { + splash.classList.add('fading'); + setTimeout(() => splash.remove(), 250); + } +} +window.addEventListener('chat:ready', dismissBootSplash); +// Fallback in case chat:ready never fires. +setTimeout(dismissBootSplash, 4000); + +window.addEventListener('chat:conn', e => { + const { state, detail } = e.detail; + for (const pill of [$('conn'), $('connAnon')]) { + if (!pill) continue; + pill.innerHTML = `${state === 'connected' ? 'connected' : detail || state}`; + pill.className = `conn ${state === 'connected' ? 'good' : 'bad'}`; + } +}); + +window.addEventListener('chat:data', e => { + const next = e.detail; + const roomChanged = state.activeRoomId !== next.activeRoomId; + applyChatData(next); + if (roomChanged) { + clearComposerTarget(); + closeThreadPanel(); + } + // Auto-pick a server when none is active and the user belongs to at least one. + if (state.authenticated && state.activeServerId === null) { + const mine = myServers(); + if (mine.length > 0) { + window.chat.setActiveServer(mine[0].id); + return; // setActiveServer fires emitData → another chat:data event will refresh + } + } + renderAll(); + if (roomChanged) scrollMessagesToBottom(); +}); + +window.addEventListener('chat:me', e => { + state.meHex = e.detail.meHex; +}); +window.addEventListener('chat:auth', e => { + const user = e.detail.user || null; + state.authenticated = Boolean(user); + state.userId = user?.userId || null; + if (!user && state.activeRoomId !== null) { + state.activeRoomId = null; + clearComposerTarget(); + closeThreadPanel(); + window.chat.setActiveRoom(null); + } + setAuthedUi(user); + renderAll(); +}); + +$('openAuthBtn').addEventListener('click', openAuthModal); +$('closeAuthBtn').addEventListener('click', closeAuthModal); +$('authModal').addEventListener('click', e => { + if (e.target === $('authModal')) closeAuthModal(); +}); +window.addEventListener('keydown', e => { + if (e.key === 'Escape') closeAuthModal(); +}); + +async function loginFrom(emailId, passId) { + await window.chat.login({ + email: $(emailId).value.trim(), + password: $(passId).value, + }); + $(passId).value = ''; + setResult('Signed in.'); +} + +async function signupFrom(emailId, passId) { + await window.chat.signup({ + email: $(emailId).value.trim(), + password: $(passId).value, + }); + $(passId).value = ''; + setResult('Account created.'); +} + +$('authLoginBtn').addEventListener('click', async () => { + try { + await loginFrom('authEmail', 'authPass'); + } catch (err) { + setResult(`sign in failed: ${err.message ?? err}`, false); + } +}); + +$('authSignupBtn').addEventListener('click', async () => { + try { + await signupFrom('authEmail', 'authPass'); + } catch (err) { + setResult(`sign up failed: ${err.message ?? err}`, false); + } +}); + +let landingAuthMode = 'login'; +function setLandingAuthMode(mode) { + landingAuthMode = mode; + const status = document.getElementById('landingStatus'); + if (status) { + status.hidden = true; + status.textContent = ''; + status.classList.remove('ok'); + } + const title = $('landingAuthTitle'); + const sub = $('landingAuthSub'); + const submit = $('landingSubmitBtn'); + const togglePrompt = $('togglePrompt'); + const toggleLink = $('toggleLink'); + const forgotFoot = $('forgotFoot'); + const nameField = $('authNameField'); + const passField = $('authPassField'); + if (mode === 'signup') { + title.textContent = 'Create an account'; + sub.textContent = 'Sign up to start chatting.'; + submit.textContent = 'Create account'; + togglePrompt.textContent = 'Already have an account?'; + toggleLink.textContent = 'Sign in'; + forgotFoot.hidden = true; + nameField.hidden = false; + passField.hidden = false; + $('authPassLanding').autocomplete = 'new-password'; + } else if (mode === 'forgot') { + title.textContent = 'Reset password'; + sub.textContent = "Enter your email and we'll send a reset link."; + submit.textContent = 'Send reset link'; + togglePrompt.textContent = 'Remembered it?'; + toggleLink.textContent = 'Sign in'; + forgotFoot.hidden = true; + nameField.hidden = true; + passField.hidden = true; + } else { + title.textContent = 'Welcome to chat'; + sub.textContent = 'Sign in to continue.'; + submit.textContent = 'Sign in'; + togglePrompt.textContent = "Don't have an account?"; + toggleLink.textContent = 'Sign up'; + forgotFoot.hidden = false; + nameField.hidden = true; + passField.hidden = false; + $('authPassLanding').autocomplete = 'current-password'; + } +} + +$('toggleLink').addEventListener('click', () => { + setLandingAuthMode(landingAuthMode === 'login' ? 'signup' : 'login'); +}); +$('forgotLink').addEventListener('click', () => setLandingAuthMode('forgot')); + +function setLandingStatus(text, ok = false) { + const el = $('landingStatus'); + if (!text) { + el.hidden = true; + el.textContent = ''; + return; + } + el.hidden = false; + el.textContent = text; + el.classList.toggle('ok', ok); +} + +$('authCard').addEventListener('submit', async e => { + e.preventDefault(); + if (!window.chat) return; + const email = $('authEmailLanding').value.trim(); + const password = $('authPassLanding').value; + const submit = $('landingSubmitBtn'); + submit.disabled = true; + setLandingStatus(''); + try { + if (landingAuthMode === 'signup') { + const name = $('authNameLanding').value.trim() || undefined; + await window.chat.signup({ email, password, name }); + } else if (landingAuthMode === 'forgot') { + await window.chat.forgotPassword(email); + setLandingStatus( + 'Reset link sent. Check the STDB module log (dev mailer).', + true + ); + setLandingAuthMode('login'); + } else { + await window.chat.login({ email, password }); + } + } catch (err) { + setLandingStatus(`${landingAuthMode} failed: ${err.message ?? err}`, false); + } finally { + submit.disabled = false; + } +}); + +$('oauthGoogle').addEventListener('click', () => + window.chat?.oauthStart('google') +); +$('oauthGithub').addEventListener('click', () => + window.chat?.oauthStart('github') +); + +$('authLogoutBtn').addEventListener('click', async () => { + try { + await window.chat.logout(); + setResult('Signed out.'); + } catch (err) { + setResult(`logout failed: ${err.message ?? err}`, false); + } +}); + +$('saveProfileBtn').addEventListener('click', async () => { + if (!requireAuthAction()) return; + try { + const displayName = $('displayName').value.trim(); + const status = $('status').value; + if (displayName) await window.chat.setDisplayName(displayName); + await window.chat.setStatus(status); + await window.chat.heartbeat(); + setResult('Profile updated.'); + } catch (err) { + setResult(`profile update failed: ${err.message ?? err}`, false); + } +}); + +function openCreateRoomModal() { + if (!requireAuthAction()) return; + if (state.activeServerId === null) { + openCreateServerModal(); + return; + } + $('createRoomModal').classList.add('open'); + $('roomName').focus(); +} +function closeCreateRoomModal() { + $('createRoomModal').classList.remove('open'); + $('roomName').value = ''; + $('roomCategory').value = ''; +} +$('openCreateRoomBtn')?.addEventListener('click', openCreateRoomModal); +$('closeCreateRoomBtn')?.addEventListener('click', closeCreateRoomModal); +$('createRoomModal')?.addEventListener('click', e => { + if (e.target.id === 'createRoomModal') closeCreateRoomModal(); +}); +document.getElementById('roomList')?.addEventListener('click', e => { + if (e.target.closest('.category-add')) { + e.preventDefault(); + e.stopPropagation(); + openCreateRoomModal(); + } +}); + +$('createRoomBtn').addEventListener('click', async () => { + if (!requireAuthAction()) return; + const serverId = state.activeServerId; + if (serverId === null) { + setResult('Pick a server first.', false); + return; + } + const name = $('roomName').value.trim(); + if (!name) return; + const isPrivate = $('roomPrivacy').value === 'private'; + const category = $('roomCategory').value.trim() || undefined; + const beforeIds = new Set(state.rooms.map(r => r.id.toString())); + const waitForRoom = e => { + const match = e.detail.rooms.find( + r => + !beforeIds.has(r.id.toString()) && + r.name === name && + r.serverId === serverId && + r.createdByUserId === state.userId + ); + if (!match) return; + window.removeEventListener('chat:data', waitForRoom); + if (state.activeRoomId && state.activeRoomId !== match.id) { + stopTypingNow(); + clearComposerTarget(); + closeThreadPanel(); + } + state.activeRoomId = match.id; + window.chat.setActiveRoom(match.id); + }; + window.addEventListener('chat:data', waitForRoom); + try { + await window.chat.createRoom(serverId, name, isPrivate, category); + closeCreateRoomModal(); + } catch (err) { + window.removeEventListener('chat:data', waitForRoom); + setResult(`create room failed: ${err.message ?? err}`, false); + } +}); + +function openCreateServerModal() { + if (!requireAuthAction()) return; + $('createServerModal').classList.add('open'); + $('serverNameInput').focus(); +} +function closeCreateServerModal() { + $('createServerModal').classList.remove('open'); + $('serverNameInput').value = ''; +} +$('createServerBtn')?.addEventListener('click', openCreateServerModal); +$('closeCreateServerBtn')?.addEventListener('click', closeCreateServerModal); +$('createServerModal')?.addEventListener('click', e => { + if (e.target.id === 'createServerModal') closeCreateServerModal(); +}); +$('createServerSubmitBtn')?.addEventListener('click', async () => { + if (!requireAuthAction()) return; + const name = $('serverNameInput').value.trim(); + if (!name) return; + const waitForServer = e => { + const match = e.detail.servers.find( + s => s.name === name && s.createdByUserId === state.userId + ); + if (!match) return; + window.removeEventListener('chat:data', waitForServer); + window.chat.setActiveServer(match.id); + }; + window.addEventListener('chat:data', waitForServer); + try { + await window.chat.createServer(name); + closeCreateServerModal(); + } catch (err) { + window.removeEventListener('chat:data', waitForServer); + setResult(`create server failed: ${err.message ?? err}`, false); + } +}); + +function openRenameServerModal() { + const srv = activeServer(); + if (!srv) return; + $('renameServerInput').value = srv.name; + $('renameServerModal').classList.add('open'); + $('renameServerInput').focus(); +} +function closeRenameServerModal() { + $('renameServerModal').classList.remove('open'); + $('renameServerInput').value = ''; +} +$('closeRenameServerBtn')?.addEventListener('click', closeRenameServerModal); +$('renameServerModal')?.addEventListener('click', e => { + if (e.target.id === 'renameServerModal') closeRenameServerModal(); +}); +$('renameServerSubmitBtn')?.addEventListener('click', async () => { + const srv = activeServer(); + if (!srv) { + closeRenameServerModal(); + return; + } + const name = $('renameServerInput').value.trim(); + if (!name || name === srv.name) { + closeRenameServerModal(); + return; + } + try { + await window.chat.renameServer(srv.id, name); + closeRenameServerModal(); + } catch (err) { + setResult(`rename failed: ${err.message ?? err}`, false); + } +}); + +// Sidebar header dropdown (server menu) +function toggleServerMenu(force) { + const menu = $('serverMenu'); + if (!menu) return; + if (force === false || !menu.hidden) menu.hidden = true; + else menu.hidden = false; +} +$('serverHeadBtn')?.addEventListener('click', e => { + e.stopPropagation(); + if (!state.activeServerId) { + if (state.authenticated) openCreateServerModal(); + return; + } + toggleServerMenu(); +}); +document.addEventListener('click', e => { + const menu = $('serverMenu'); + if (!menu || menu.hidden) return; + if (e.target.closest('#serverMenu') || e.target.closest('#serverHeadBtn')) + return; + toggleServerMenu(false); +}); +$('serverMenu')?.addEventListener('click', async e => { + const action = e.target.closest('[data-srv-action]')?.dataset.srvAction; + if (!action) return; + toggleServerMenu(false); + const srv = activeServer(); + if (!srv) return; + if (action === 'rename') { + if (!amServerOwner(srv.id)) { + setResult('Only the owner can rename.', false); + return; + } + openRenameServerModal(); + } else if (action === 'delete') { + if (!amServerOwner(srv.id)) { + setResult('Only the owner can delete.', false); + return; + } + if ( + !confirm( + `Delete server "${srv.name}"? This removes all channels and messages.` + ) + ) + return; + try { + await window.chat.deleteServer(srv.id); + window.chat.setActiveServer(null); + } catch (err) { + setResult(`delete failed: ${err.message ?? err}`, false); + } + } else if (action === 'leave') { + if (amServerOwner(srv.id)) { + setResult("Owners can't leave; delete the server instead.", false); + return; + } + if (!confirm(`Leave "${srv.name}"?`)) return; + try { + await window.chat.leaveServer(srv.id); + window.chat.setActiveServer(null); + } catch (err) { + setResult(`leave failed: ${err.message ?? err}`, false); + } + } +}); + +let csRoomId = null; +function openChannelSettings(roomId) { + const room = state.rooms.find(r => r.id === roomId); + if (!room) return; + csRoomId = roomId; + $('csName').value = room.name; + $('csName').disabled = false; + $('csCategory').value = room.category || ''; + $('csPrivacy').value = room.isPrivate ? 'private' : 'public'; + $('csPrivacy').disabled = false; + $('csDeleteBtn').disabled = false; + $('csDeleteBtn').title = ''; + $('channelSettingsModal').classList.add('open'); + $('csName').focus(); +} +function closeChannelSettings() { + csRoomId = null; + $('channelSettingsModal').classList.remove('open'); +} +$('closeChannelSettingsBtn')?.addEventListener('click', closeChannelSettings); +$('channelSettingsModal')?.addEventListener('click', e => { + if (e.target.id === 'channelSettingsModal') closeChannelSettings(); +}); +$('csSaveBtn')?.addEventListener('click', async () => { + if (csRoomId === null) return; + const room = state.rooms.find(r => r.id === csRoomId); + if (!room) { + closeChannelSettings(); + return; + } + const newName = $('csName').value.trim(); + const newCategory = $('csCategory').value.trim() || undefined; + const newPrivate = $('csPrivacy').value === 'private'; + try { + if (newName && newName !== room.name) + await window.chat.renameRoom(csRoomId, newName); + if ((room.category || '') !== (newCategory || '')) + await window.chat.setRoomCategory(csRoomId, newCategory); + if (newPrivate !== room.isPrivate) + await window.chat.setRoomPrivacy(csRoomId, newPrivate); + closeChannelSettings(); + } catch (err) { + setResult(`save failed: ${err.message ?? err}`, false); + } +}); +$('csDeleteBtn')?.addEventListener('click', async () => { + if (csRoomId === null) return; + const room = state.rooms.find(r => r.id === csRoomId); + if (!room) return; + if (!confirm(`Delete #${room.name}? This removes all messages and members.`)) + return; + try { + const deletedId = csRoomId; + await window.chat.deleteRoom(deletedId); + closeChannelSettings(); + if (state.activeRoomId === deletedId) { + state.activeRoomId = null; + clearComposerTarget(); + closeThreadPanel(); + window.chat.setActiveRoom(null); + } + } catch (err) { + setResult(`delete failed: ${err.message ?? err}`, false); + } +}); + +function openUserMenu() { + $('userMenu').hidden = false; +} +function closeUserMenu() { + $('userMenu').hidden = true; +} +function toggleUserMenu(e) { + e.stopPropagation(); + if ($('userMenu').hidden) openUserMenu(); + else closeUserMenu(); +} +$('openUserMenuBtn')?.addEventListener('click', toggleUserMenu); +document.addEventListener('click', e => { + const menu = $('userMenu'); + if (!menu || menu.hidden) return; + if (menu.contains(e.target)) return; + if (e.target.closest('#openUserMenuBtn')) return; + closeUserMenu(); +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + closeUserMenu(); + closeCreateRoomModal(); + closeChannelSettings(); + closeCreateServerModal(); + closeRenameServerModal(); + toggleServerMenu(false); + } +}); + +$('toggleMembersBtn').addEventListener('click', () => { + $('membersPanel').classList.toggle('collapsed'); +}); + +$('homeBtn').addEventListener('click', () => { + if (!state.authenticated) { + openAuthModal(); + return; + } + const mine = myServers(); + if (mine.length === 0) { + openCreateServerModal(); + return; + } + window.chat.setActiveServer(mine[0].id); +}); + +function renderPendingAtts() { + const root = $('pendingAttachments'); + if (pendingAtts.length === 0) { + root.hidden = true; + root.innerHTML = ''; + return; + } + root.hidden = false; + root.innerHTML = pendingAtts + .map(p => { + const isImg = p.mimeType.startsWith('image/'); + const preview = isImg + ? `${escapeHtml(p.name)}` + : `
          ${escapeHtml(p.name)}
          `; + const meta = isImg + ? `
          ${escapeHtml(p.name)} - ${fmtBytes(p.bytes.length)}
          ` + : `
          ${fmtBytes(p.bytes.length)}
          `; + return `
          + ${preview} + ${meta} + +
          `; + }) + .join(''); + root.querySelectorAll('[data-remove]').forEach(btn => { + btn.addEventListener('click', () => { + const id = Number(btn.dataset.remove); + const idx = pendingAtts.findIndex(p => p.id === id); + if (idx < 0) return; + const removed = pendingAtts.splice(idx, 1)[0]; + if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl); + renderPendingAtts(); + }); + }); +} + +async function readFileAsBytes(file) { + const buf = await file.arrayBuffer(); + return new Uint8Array(buf); +} + +$('attachBtn')?.addEventListener('click', () => { + if (!requireAuthAction()) return; + $('attachInput').click(); +}); +$('attachInput')?.addEventListener('change', async e => { + const files = [...(e.target.files || [])]; + e.target.value = ''; + for (const f of files) { + if (pendingAtts.length >= ATT_MAX_COUNT) { + setResult(`Max ${ATT_MAX_COUNT} attachments.`, false); + break; + } + if (f.size > ATT_MAX_BYTES) { + setResult( + `${f.name}: too large (${fmtBytes(f.size)} > ${fmtBytes(ATT_MAX_BYTES)}).`, + false + ); + continue; + } + try { + const bytes = await readFileAsBytes(f); + const mimeType = f.type || 'application/octet-stream'; + const previewUrl = mimeType.startsWith('image/') + ? URL.createObjectURL(f) + : null; + pendingAtts.push({ + id: ++pendingAttSeq, + file: f, + name: f.name, + mimeType, + bytes, + previewUrl, + }); + } catch (err) { + setResult(`${f.name}: read failed - ${err.message ?? err}`, false); + } + } + renderPendingAtts(); +}); + +function clearPendingAtts() { + for (const p of pendingAtts) + if (p.previewUrl) URL.revokeObjectURL(p.previewUrl); + pendingAtts = []; + renderPendingAtts(); +} + +async function sendCurrentMessage() { + if (!state.authenticated) { + console.warn('send blocked: not authenticated'); + openAuthModal(); + return; + } + const room = activeRoom(); + if (!room) { + console.warn( + 'send blocked: no active room (activeRoomId=', + state.activeRoomId, + ')' + ); + setResult('Select a room first.', false); + return; + } + if (currentSendLimit()) { + updateComposerState(); + return; + } + const content = $('messageInput').value.trim(); + if (editTargetId !== null) { + if (!content) return; + try { + await window.chat.editMessage(editTargetId, content); + $('messageInput').value = ''; + clearComposerTarget(); + clearPendingAtts(); + } catch (err) { + console.error('editMessage failed', err); + setResult(`edit failed: ${err.message ?? err}`, false); + } + return; + } + const atts = pendingAtts.map(p => ({ + mimeType: p.mimeType, + filename: p.name, + bytes: p.bytes, + })); + if (!content && atts.length === 0) return; + const replyTo = replyTargetId === null ? undefined : replyTargetId; + try { + await window.chat.sendMessage(room.id, content, replyTo, atts); + $('messageInput').value = ''; + clearComposerTarget(); + clearPendingAtts(); + stopTypingNow(); + try { + await window.chat.markRoomRead(room.id); + } catch (e) { + console.warn('markRoomRead after send failed', e); + } + scrollMessagesToBottom(); + } catch (err) { + console.error('sendMessage failed', err); + setResult(`send failed: ${err.message ?? err}`, false); + } +} + +$('sendBtn').addEventListener('click', sendCurrentMessage); +$('cancelReplyBtn').addEventListener('click', () => { + clearComposerTarget(); + $('messageInput').value = ''; +}); +$('closeThreadBtn').addEventListener('click', closeThreadPanel); +$('threadComposer').addEventListener('submit', async e => { + e.preventDefault(); + if (!requireAuthAction()) return; + if (activeThreadRootMessageId === null) return; + if (currentSendLimit()) { + updateComposerState(); + return; + } + const content = $('threadInput').value.trim(); + if (!content) return; + try { + if (threadEditTargetId !== null) { + await window.chat.editThreadMessage(threadEditTargetId, content); + clearThreadEditTarget(); + return; + } + await window.chat.sendThreadMessage(activeThreadRootMessageId, content); + $('threadInput').value = ''; + } catch (err) { + console.error('sendThreadMessage failed', err); + setResult(`thread send failed: ${err.message ?? err}`, false); + } +}); +$('messageInput').addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendCurrentMessage(); + return; + } + scheduleTyping(); +}); +$('messageInput').addEventListener('input', scheduleTyping); +$('messageInput').addEventListener('blur', stopTypingNow); diff --git a/spacetime-presence-ts/example/scripts/test-ui-model.mjs b/spacetime-presence-ts/example/scripts/test-ui-model.mjs new file mode 100644 index 00000000000..e0bb7030ce4 --- /dev/null +++ b/spacetime-presence-ts/example/scripts/test-ui-model.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { + activeRoom, + activeServer, + amServerOwner, + attachmentsByMessage, + canDeleteMessage, + canEditMessage, + globalPresenceBySubject, + latestMessageByRoom, + messageAuthorName, + messageSummary, + myMemberships, + myReadCursorByRoom, + myServers, + roomsInServer, + threadMessagesForRoot, + typingForRoom, + userByHex, +} from '../public/chat-model.js'; +import { applyChatData, chatState } from '../public/chat-state.js'; + +const identity = value => ({ toHexString: () => value }); +const me = identity('me'); +const other = identity('other'); + +Object.assign(chatState, { + meHex: 'me', + userId: 10n, + userEmail: 'me@example.com', + activeServerId: 1n, + activeRoomId: 2n, + servers: [{ id: 1n, name: 'Server', createdByUserId: 10n }], + serverMembers: [ + { serverId: 1n, userId: 10n }, + { serverId: 3n, userId: 20n }, + ], + rooms: [ + { id: 2n, serverId: 1n, createdByUserId: 20n }, + { id: 4n, serverId: 3n, createdByUserId: 20n }, + ], + users: [ + { userId: 10n, identity: me, displayName: 'Me' }, + { userId: 20n, identity: other, displayName: 'Other' }, + ], + members: [ + { roomId: 2n, userId: 10n }, + { roomId: 4n, userId: 20n }, + ], + messages: [ + { id: 5n, roomId: 2n, author: other, content: 'First' }, + { id: 8n, roomId: 2n, author: me, content: '' }, + ], + attachments: [ + { id: 2n, fileId: 2n, messageId: 8n, ordinal: 2 }, + { id: 1n, fileId: 1n, messageId: 8n, ordinal: 1 }, + ], + threads: [{ id: 7n, rootMessageId: 5n }], + threadMessages: [ + { id: 9n, threadId: 7n }, + { id: 10n, threadId: 8n }, + ], + cursors: [ + { identity: me, roomId: 2n, lastReadMessageId: 5n }, + { identity: other, roomId: 2n, lastReadMessageId: 8n }, + ], + presence: [ + { scope: 'chat.global', subject: 'me', status: 'online' }, + { scope: 'chat.typing:2', subject: 'other', status: 'online' }, + ], +}); + +assert.equal(activeServer()?.id, 1n); +assert.equal(activeRoom()?.id, 2n); +assert.equal(amServerOwner(1n), true); +assert.deepEqual( + myServers().map(server => server.id), + [1n] +); +assert.deepEqual( + roomsInServer(1n).map(room => room.id), + [2n] +); +assert.deepEqual(myMemberships(), [2n]); +assert.equal(canEditMessage(chatState.messages[1]), true); +assert.equal(canEditMessage(chatState.messages[0]), false); +assert.equal(canDeleteMessage(chatState.messages[0]), true); +assert.equal(messageAuthorName(chatState.messages[0]), 'Other'); +assert.equal(messageSummary(chatState.messages[1]), '2 attachments'); +assert.deepEqual( + threadMessagesForRoot(5n).map(message => message.id), + [9n] +); +assert.equal(latestMessageByRoom().get(2n)?.id, 8n); +assert.equal(myReadCursorByRoom().get(2n), 5n); +assert.equal(globalPresenceBySubject().get('me')?.status, 'online'); +assert.deepEqual(typingForRoom(2n), ['other']); +assert.equal(userByHex().get('me')?.displayName, 'Me'); +assert.deepEqual( + attachmentsByMessage() + .get(8n) + ?.map(attachment => attachment.id), + [1n, 2n] +); + +applyChatData({ activeServerId: 3n, activeRoomId: 4n, rooms: [] }); +assert.equal(chatState.activeServerId, 3n); +assert.equal(chatState.userId, 10n); +assert.equal(chatState.userEmail, 'me@example.com'); + +console.log('presence UI model tests passed'); diff --git a/spacetime-presence-ts/example/server.ts b/spacetime-presence-ts/example/server.ts new file mode 100644 index 00000000000..69263ca125f --- /dev/null +++ b/spacetime-presence-ts/example/server.ts @@ -0,0 +1,202 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared env supplies secrets; example-local env supplies app defaults. +// Blank placeholders in the example .env should not erase shared secrets. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8794', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const STDB_APP_DB = + process.env.STDB_APP_DATABASE ?? 'spacetime-presence-example'; +const AUTH_ISSUER_URL = + process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; +const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME ?? 'stdb_auth'; +const AUTH_SESSION_TTL_SECONDS = Number.parseInt( + process.env.AUTH_SESSION_TTL_SECONDS ?? `${60 * 60 * 24 * 7}`, + 10 +); +if ( + !Number.isInteger(AUTH_SESSION_TTL_SECONDS) || + AUTH_SESSION_TTL_SECONDS <= 0 +) { + throw new Error('AUTH_SESSION_TTL_SECONDS must be a positive integer'); +} +const STDB_SERVER = process.env.STDB_SERVER ?? STDB_HTTP; +const SPACETIME_BIN = 'spacetime'; + +function configuredValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function configuredPem(value: string | undefined): string | undefined { + return configuredValue(value)?.replace(/\\n/g, '\n'); +} + +const opt = (value: string | undefined) => + value === undefined ? JSON.stringify([1, []]) : JSON.stringify([0, value]); + +function configureAuthFromEnv(): void { + const args = [ + JSON.stringify(AUTH_ISSUER_URL), + opt(AUTH_BASE_URL), + opt(AUTH_COOKIE_NAME), + JSON.stringify([0, AUTH_SESSION_TTL_SECONDS]), + opt(configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM)), + opt(configuredValue(process.env.GOOGLE_CLIENT_ID)), + opt(configuredValue(process.env.GOOGLE_CLIENT_SECRET)), + opt(configuredValue(process.env.GITHUB_CLIENT_ID)), + opt(configuredValue(process.env.GITHUB_CLIENT_SECRET)), + ]; + + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, STDB_APP_DB, 'set_auth_config', ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`auth config bootstrap failed (exit ${result.status})`); + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +app.get('/auth/password/reset', (_req: Request, res: Response) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +function proxyStdbRoute(prefix: string) { + return async (req: Request, res: Response) => { + const mountedUrl = req.url.startsWith('/?') ? req.url.slice(1) : req.url; + const fullPath = `${prefix}${mountedUrl}`; + const qIdx = fullPath.indexOf('?'); + const routePath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${routePath}${query}`; + + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + delete headers.host; + delete headers['content-length']; + headers['x-forwarded-proto'] = headers['x-forwarded-proto'] ?? req.protocol; + + const init: RequestInit = { + method: req.method, + headers, + redirect: 'manual', + }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = JSON.stringify(req.body); + headers['content-type'] = 'application/json'; + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, val); + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + res.send(buf); + } catch (err) { + res.status(502).json({ + error: 'upstream_unreachable', + detail: (err as Error).message, + }); + } + }; +} + +app.use('/auth', proxyStdbRoute('/auth')); +app.use('/files', proxyStdbRoute('/files')); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, app: STDB_APP_DB }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + stdbUri: STDB_URI, + appDatabase: STDB_APP_DB, + auth: { + issuerUrl: AUTH_ISSUER_URL, + baseUrl: AUTH_BASE_URL, + cookieName: AUTH_COOKIE_NAME, + sessionTtlSeconds: AUTH_SESSION_TTL_SECONDS, + hasEs256PrivateKeyPem: Boolean( + configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM) + ), + }, + oauth: { + google: Boolean( + process.env.GOOGLE_CLIENT_ID?.trim() && + process.env.GOOGLE_CLIENT_SECRET?.trim() + ), + github: Boolean( + process.env.GITHUB_CLIENT_ID?.trim() && + process.env.GITHUB_CLIENT_SECRET?.trim() + ), + }, + }); +}); + +app.use(express.static(path.join(__dirname, 'public'))); + +try { + console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); + configureAuthFromEnv(); + console.log(`[auth] bootstrapped env config issuer=${AUTH_ISSUER_URL}`); +} catch (err) { + console.error( + `[auth] env config bootstrap failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[auth] is the SpacetimeDB host running and the presence example module published?' + ); + process.exit(1); +} + +app.listen(PORT, HOST, () => { + console.log(`Chat test app running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxy /auth/*, /files)`); + console.log(` Database -> ${STDB_APP_DB}`); +}); diff --git a/spacetime-presence-ts/example/spacetimedb/package.json b/spacetime-presence-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..5751003f618 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/package.json @@ -0,0 +1,21 @@ +{ + "name": "spacetime-presence-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-presence-example" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/files": "workspace:*", + "@spacetimedb/presence": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-presence-ts/example/spacetimedb/src/chat-policy.ts b/spacetime-presence-ts/example/spacetimedb/src/chat-policy.ts new file mode 100644 index 00000000000..6fe122809c4 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/chat-policy.ts @@ -0,0 +1,32 @@ +export const PRESENCE_SCOPE_GLOBAL = 'chat.global'; +export const PRESENCE_SCOPE_TYPING_PREFIX = 'chat.typing:'; + +export const RATE_LIMIT_SEND = { + scope: 'chat.send_message', + limit: 20, + windowSeconds: 30, +}; +export const RATE_LIMIT_TYPING = { + scope: 'chat.typing', + limit: 40, + windowSeconds: 10, +}; +export const RATE_LIMIT_ROOM_WRITE = { + scope: 'chat.room_write', + limit: 10, + windowSeconds: 60, +}; +export const RATE_LIMIT_REACTION = { + scope: 'chat.reaction', + limit: 40, + windowSeconds: 60, +}; +export const RATE_LIMIT_PROFILE = { + scope: 'chat.profile', + limit: 20, + windowSeconds: 60, +}; + +export function typingScope(roomId: bigint): string { + return `${PRESENCE_SCOPE_TYPING_PREFIX}${roomId}`; +} diff --git a/spacetime-presence-ts/example/spacetimedb/src/domain.ts b/spacetime-presence-ts/example/spacetimedb/src/domain.ts new file mode 100644 index 00000000000..32f8095743f --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/domain.ts @@ -0,0 +1,331 @@ +import { Identity } from 'spacetimedb'; +import { + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import { getCallerUserId } from '@spacetimedb/auth/submodule'; +import { consumeRateLimit } from '@spacetimedb/rate-limit/submodule'; +import { removePresence, upsertPresence } from '@spacetimedb/presence'; +import { PRESENCE_SCOPE_GLOBAL, typingScope } from './chat-policy'; +import { ChatUserStatus } from './model'; +import type { DbSchema } from './index'; + +const ONE_SECOND_MICROS = 1_000_000n; +const GLOBAL_PRESENCE_TTL_SECONDS = 35; +const ACTIVITY_WINDOW_SECONDS = 5 * 60; +const ROOM_ACTIVITY_HOT_THRESHOLD = 20; +const ROOM_ACTIVITY_ACTIVE_THRESHOLD = 5; + +export type Tx = ReducerCtx; +type CallerCtx = ProcedureCtx | ReducerCtx; + +export function senderError(message: string): never { + throw new SenderError(message); +} + +export function normalizeText( + name: string, + value: string, + maxLength: number +): string { + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) senderError(`chat.invalid_${name}`); + if (normalized.length > maxLength) senderError(`chat.${name}_too_long`); + return normalized; +} + +export function identityHex(identity: Identity): string { + return identity.toHexString(); +} + +export function identitiesEqual(left: Identity, right: Identity): boolean { + return left.isEqual(right); +} + +export function requireAuthenticatedUserId(ctx: CallerCtx): string { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) senderError('auth.not_authenticated'); + return userId; +} + +export function enforceChatRateLimit( + tx: Tx, + userId: string, + scope: string, + limit: number, + windowSeconds: number, + cost = 1 +): void { + const result = consumeRateLimit(tx.as.rateLimit, { + key: `${scope}:user:${userId}`, + scope, + limit, + windowSeconds, + cost, + }); + if (!result.allowed) + senderError(`chat.rate_limited:${result.retryAfterSeconds}`); +} + +export function chatStatusToString(status: { tag: string }): string { + return status.tag.toLowerCase(); +} + +export function ensureUser(tx: Tx, userId: string) { + const existing = tx.db.chatUser.identity.find(tx.sender); + const authUser = tx.db.auth.authUser.userId.find(userId); + if (existing) { + if (existing.userId !== userId && authUser) { + tx.db.chatUser.identity.update({ + ...existing, + userId, + displayName: authUser.name ?? existing.displayName, + }); + } + return tx.db.chatUser.identity.find(tx.sender) ?? existing; + } + + const hex = identityHex(tx.sender); + const suffix = hex.slice(Math.max(0, hex.length - 6)); + const row = tx.db.chatUser.insert({ + identity: tx.sender, + userId, + displayName: authUser?.name ?? authUser?.email ?? `User-${suffix}`, + status: ChatUserStatus.Online, + createdAt: tx.timestamp, + lastActiveAt: tx.timestamp, + lastMessageAt: tx.timestamp, + }); + updateGlobalPresence(tx, row); + return row; +} + +export function updateGlobalPresence( + tx: Tx, + user: ReturnType +): void { + upsertPresence(tx, { + scope: PRESENCE_SCOPE_GLOBAL, + subject: identityHex(user.identity), + status: chatStatusToString(user.status), + payloadJson: JSON.stringify({ + displayName: user.displayName, + userId: user.userId, + }), + ttlSeconds: GLOBAL_PRESENCE_TTL_SECONDS, + }); +} + +export function requireRoom(tx: Tx, roomId: bigint) { + const row = tx.db.room.id.find(roomId); + if (!row) senderError('chat.room_not_found'); + return row; +} + +export function findMembership(tx: Tx, roomId: bigint, userId: string) { + for (const membership of tx.db.roomMember.roomId.filter(roomId)) { + if (membership.userId === userId) return membership; + } + return undefined; +} + +export function requireMembership(tx: Tx, roomId: bigint, userId: string) { + const membership = findMembership(tx, roomId, userId); + if (!membership) senderError('chat.not_room_member'); + return membership; +} + +function countRecentActivityEvents(tx: Tx, roomId: bigint): number { + const cutoff = + tx.timestamp.microsSinceUnixEpoch - + BigInt(ACTIVITY_WINDOW_SECONDS) * ONE_SECOND_MICROS; + let count = 0; + for (const event of tx.db.roomActivityEvent.roomId.filter(roomId)) { + if (event.createdAt.microsSinceUnixEpoch >= cutoff) count++; + } + return count; +} + +function activityLabel(score: number): string { + if (score >= ROOM_ACTIVITY_HOT_THRESHOLD) return 'hot'; + if (score >= ROOM_ACTIVITY_ACTIVE_THRESHOLD) return 'active'; + return score > 0 ? 'warm' : 'quiet'; +} + +export function updateRoomActivity(tx: Tx, roomId: bigint): void { + const room = tx.db.room.id.find(roomId); + if (!room) return; + const score = countRecentActivityEvents(tx, roomId); + tx.db.room.id.update({ + ...room, + activityScore: score, + activityLabel: activityLabel(score), + lastActivityAt: score > 0 ? tx.timestamp : room.lastActivityAt, + }); +} + +export function deleteAttachmentWithFile( + tx: Tx, + attachment: { id: bigint; fileId: bigint } +): void { + const blob = tx.db.files.fileBlob.fileId.find(attachment.fileId); + if (blob) tx.db.files.fileBlob.delete(blob); + const file = tx.db.files.file.id.find(attachment.fileId); + if (file) tx.db.files.file.delete(file); + tx.db.attachment.id.delete(attachment.id); +} + +export function deleteThread(tx: Tx, threadId: bigint): void { + for (const message of [...tx.db.threadMessage.threadId.filter(threadId)]) { + tx.db.threadMessage.id.delete(message.id); + } + const thread = tx.db.messageThread.id.find(threadId); + if (thread) tx.db.messageThread.id.delete(thread.id); +} + +export function deleteMessageTree(tx: Tx, message: { id: bigint }): void { + const thread = tx.db.messageThread.rootMessageId.find(message.id); + if (thread) deleteThread(tx, thread.id); + for (const attachment of [...tx.db.attachment.messageId.filter(message.id)]) { + deleteAttachmentWithFile(tx, attachment); + } + for (const reaction of [ + ...tx.db.messageReaction.messageId.filter(message.id), + ]) { + tx.db.messageReaction.id.delete(reaction.id); + } + tx.db.message.id.delete(message.id); +} + +export function upsertRoomReadCursor( + tx: Tx, + roomId: bigint, + lastReadMessageId: bigint +): void { + for (const cursor of tx.db.roomReadCursor.identity.filter(tx.sender)) { + if (cursor.roomId !== roomId) continue; + if (cursor.lastReadMessageId >= lastReadMessageId) return; + tx.db.roomReadCursor.id.update({ + ...cursor, + lastReadMessageId, + lastReadAt: tx.timestamp, + }); + return; + } + tx.db.roomReadCursor.insert({ + id: 0n, + roomId, + identity: tx.sender, + lastReadMessageId, + lastReadAt: tx.timestamp, + }); +} + +export function removeTypingPresence( + tx: Tx, + roomId: bigint, + identity: Identity +): void { + removePresence(tx, typingScope(roomId), identityHex(identity)); +} + +export function insertRoom( + tx: Tx, + options: { + serverId: bigint; + name: string; + category?: string; + isPrivate: boolean; + createdByUserId: string; + role: string; + } +) { + const room = tx.db.room.insert({ + id: 0n, + serverId: options.serverId, + name: options.name, + category: options.category, + createdByUserId: options.createdByUserId, + createdAt: tx.timestamp, + isPrivate: options.isPrivate, + activityLabel: 'quiet', + activityScore: 0, + lastActivityAt: undefined, + }); + tx.db.roomMember.insert({ + id: 0n, + roomId: room.id, + userId: options.createdByUserId, + role: options.role, + joinedAt: tx.timestamp, + }); + return room; +} + +export function requireServer(tx: Tx, serverId: bigint) { + const server = tx.db.server.id.find(serverId); + if (!server) senderError('chat.server_not_found'); + return server; +} + +export function findServerMembership(tx: Tx, serverId: bigint, userId: string) { + for (const membership of tx.db.serverMember.serverId.filter(serverId)) { + if (membership.userId === userId) return membership; + } + return undefined; +} + +export function requireServerMembership( + tx: Tx, + serverId: bigint, + userId: string +) { + const membership = findServerMembership(tx, serverId, userId); + if (!membership) senderError('chat.not_server_member'); + return membership; +} + +export function requireRoomAdminOrOwner( + tx: Tx, + roomId: bigint, + userId: string +) { + const targetRoom = requireRoom(tx, roomId); + const server = tx.db.server.id.find(targetRoom.serverId); + if ( + server?.createdByUserId === userId || + targetRoom.createdByUserId === userId + ) { + return targetRoom; + } + senderError('chat.not_room_admin'); +} + +export function canModerateRoom( + tx: Tx, + roomId: bigint, + userId: string +): boolean { + const targetRoom = requireRoom(tx, roomId); + const server = tx.db.server.id.find(targetRoom.serverId); + return ( + server?.createdByUserId === userId || targetRoom.createdByUserId === userId + ); +} + +export function canReadAttachmentFile( + tx: TransactionCtx, + userId: string, + fileId: bigint +): boolean { + for (const attachment of tx.db.attachment.fileId.filter(fileId)) { + const message = tx.db.message.id.find(attachment.messageId); + if (!message) continue; + for (const member of tx.db.roomMember.roomId.filter(message.roomId)) { + if (member.userId === userId) return true; + } + } + return false; +} diff --git a/spacetime-presence-ts/example/spacetimedb/src/index.ts b/spacetime-presence-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..a36457da775 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,1271 @@ +import { ScheduleAt } from 'spacetimedb'; +import { + Router, + Range, + schema, + table, + t, + type InferSchema, + type TransactionCtx, +} from 'spacetimedb/server'; +import { + installPresenceConfig, + removePresence, + runPresenceSweep, + upsertPresence, +} from '@spacetimedb/presence'; +import { + getPublicKeyPemParams, + githubCallbackHandler, + githubStartHandler, + googleCallbackHandler, + googleStartHandler, + linkConnectionParams, + listMySessionsParams, + logoutHandler, + makeEmailVerifyHandler, + makeEmailVerifyRequestHandler, + makeForgotPasswordHandler, + meHandler, + passwordLoginHandler, + passwordSignupHandler, + parseCookies, + refreshHandler, + resetPasswordHandler, + revokeMySessionParams, + revokeSessionParams, + setAuthConfigParams, + unlinkConnectionParams, + updateProfileParams, + publicKeyFromPem, + verifyJwt, + type MailParams, + type SendMailFn, +} from '@spacetimedb/auth/submodule'; +import * as auth from '@spacetimedb/auth/submodule'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { + fileSha256Hex, + FILE_VISIBILITY_OWNER, +} from '@spacetimedb/files/submodule'; +import * as files from '@spacetimedb/files/submodule'; +import { + RATE_LIMIT_PROFILE, + RATE_LIMIT_REACTION, + RATE_LIMIT_ROOM_WRITE, + RATE_LIMIT_SEND, + RATE_LIMIT_TYPING, + typingScope, +} from './chat-policy'; +import { registerChatViews } from './views'; + +const ONE_SECOND_MICROS = 1_000_000n; +const TYPING_TTL_SECONDS = 4; +const GLOBAL_PRESENCE_TTL_SECONDS = 35; +const CHAT_SWEEP_INTERVAL_SECONDS = 10n; +const ACTIVITY_WINDOW_SECONDS = 5 * 60; +const ACTIVITY_CLEANUP_BATCH = 1000; +const ROOM_NAME_MAX = 64; +const DISPLAY_NAME_MAX = 32; +const MESSAGE_MAX = 2000; +const ATTACHMENT_MAX_BYTES = 4_000_000; +const ATTACHMENT_MAX_COUNT = 5; +const ATTACHMENT_MIME_MAX = 128; +const ATTACHMENT_FILENAME_MAX = 256; + +const ALLOWED_REACTIONS = new Set(['+1', 'heart', 'joy', 'wow', 'sad', 'fire']); + +const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { + console.log( + `[mail] to=${params.to} subject=${params.subject}\n${params.text}` + ); +}; + +// Chat presence states. The presence-ts submodule's presence_entry.status +// stays a free-form string (the submodule is consumer-agnostic); chat_user +// pins down the exact set of values this app supports. +import { + chatUserStatus, + chatUser, + server, + serverMember, + room, + roomMember, + message, + messageReaction, + messageThread, + threadMessage, + attachment, + roomReadCursor, + roomActivityEvent, + presenceEntry, +} from './model'; +import { + canModerateRoom, + canReadAttachmentFile, + deleteMessageTree, + chatStatusToString, + enforceChatRateLimit, + ensureUser, + findMembership, + findServerMembership, + identitiesEqual as eqIdentity, + identityHex, + insertRoom, + normalizeText, + removeTypingPresence, + requireAuthenticatedUserId, + requireMembership, + requireRoom, + requireRoomAdminOrOwner, + requireServer, + requireServerMembership, + senderError, + updateGlobalPresence, + updateRoomActivity, + upsertRoomReadCursor, + type Tx, +} from './domain'; + +const presenceConfig = table( + { name: 'presence_config', public: false }, + { + singleton: t.bool().primaryKey(), + defaultTtlSeconds: t.u32(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), + } +); + +const chatSweepTick = table( + { name: 'chat_sweep_tick', scheduled: (): any => chat_sweep }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + auth, + files, + rateLimit, + chatUser, + server, + serverMember, + room, + roomMember, + message, + messageReaction, + messageThread, + threadMessage, + attachment, + roomReadCursor, + roomActivityEvent, + presenceEntry, + presenceConfig, + chatSweepTick, +}); +export default spacetimedb; + +export type DbSchema = InferSchema; +export const { + myServers, + myServerMembers, + myChatUsers, + myPresenceEntries, + myRooms, + myRoomMembers, + myRoomMessages, + myRoomMessageReactions, + myMessageThreads, + myThreadMessages, + myRoomAttachments, + myRoomReadCursors, + myAuthUser, + myRateLimitStatus, +} = registerChatViews(spacetimedb); +export const init = spacetimedb.init(ctx => { + auth.installAuth(ctx.as.auth); + files.installFiles(ctx.as.files); + rateLimit.installRateLimit(ctx.as.rateLimit); + installPresenceConfig(ctx, { + defaultTtlSeconds: GLOBAL_PRESENCE_TTL_SECONDS, + sweepBatch: 1000, + }); + ctx.db.chatSweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval( + CHAT_SWEEP_INTERVAL_SECONDS * ONE_SECOND_MICROS + ), + }); +}); + +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.set_auth_config(ctx.as.auth, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.get_auth_public_key(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +export const link_connection = spacetimedb.reducer( + linkConnectionParams, + (ctx, args) => { + auth.link_connection(ctx.as.auth, args); + } +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlink_connection(ctx.as.auth, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.update_profile(ctx.as.auth, args); + } +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revoke_session(ctx.as.auth, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + (ctx, args) => auth.list_my_sessions(ctx.as.auth, args) +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revoke_my_session(ctx.as.auth, args); + } +); + +export const heartbeat = spacetimedb.reducer({}, ctx => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + const user = ensureUser(tx, userId); + const next = { ...user, lastActiveAt: tx.timestamp }; + tx.db.chatUser.identity.update(next); + updateGlobalPresence(tx, next); +}); + +export const whoami = spacetimedb.procedure( + {}, + t.object('WhoAmI', { + userId: t.option(t.string()), + senderIdentityHex: t.string(), + userDisplayName: t.option(t.string()), + userStatus: t.option(t.string()), + }), + ctx => + ctx.withTx(tx => { + const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( + tx.sender + ); + const userId = binding?.userId ?? undefined; + const user = tx.db.chatUser.identity.find(tx.sender); + return { + userId, + senderIdentityHex: identityHex(tx.sender), + userDisplayName: user?.displayName, + userStatus: user ? chatStatusToString(user.status) : undefined, + }; + }) +); + +export const set_display_name = spacetimedb.reducer( + { displayName: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const displayName = normalizeText( + 'display_name', + args.displayName, + DISPLAY_NAME_MAX + ); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_PROFILE.scope, + RATE_LIMIT_PROFILE.limit, + RATE_LIMIT_PROFILE.windowSeconds + ); + const user = ensureUser(tx, userId); + const next = { ...user, displayName, lastActiveAt: tx.timestamp }; + tx.db.chatUser.identity.update(next); + updateGlobalPresence(tx, next); + } +); + +export const set_status = spacetimedb.reducer( + { status: chatUserStatus }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_PROFILE.scope, + RATE_LIMIT_PROFILE.limit, + RATE_LIMIT_PROFILE.windowSeconds + ); + const user = ensureUser(tx, userId); + const next = { ...user, status: args.status, lastActiveAt: tx.timestamp }; + tx.db.chatUser.identity.update(next); + updateGlobalPresence(tx, next); + } +); + +export const create_server = spacetimedb.reducer( + { name: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const name = normalizeText('server_name', args.name, ROOM_NAME_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + ensureUser(tx, userId); + const srv = tx.db.server.insert({ + id: 0n, + name, + createdByUserId: userId, + createdAt: tx.timestamp, + }); + tx.db.serverMember.insert({ + id: 0n, + serverId: srv.id, + userId, + role: 'owner', + joinedAt: tx.timestamp, + }); + insertRoom(tx, { + serverId: srv.id, + name: 'general', + isPrivate: false, + createdByUserId: userId, + role: 'owner', + }); + } +); + +export const rename_server = spacetimedb.reducer( + { serverId: t.u64(), name: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const name = normalizeText('server_name', args.name, ROOM_NAME_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const srv = requireServer(tx, args.serverId); + if (srv.createdByUserId !== userId) senderError('chat.not_server_owner'); + tx.db.server.id.update({ ...srv, name }); + } +); + +export const delete_server = spacetimedb.reducer( + { serverId: t.u64() }, + (ctx, { serverId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const srv = requireServer(tx, serverId); + if (srv.createdByUserId !== userId) senderError('chat.not_server_owner'); + + for (const r of [...tx.db.room.serverId.filter(serverId)]) { + for (const m of [...tx.db.message.roomId.filter(r.id)]) { + deleteMessageTree(tx, m); + } + for (const c of [...tx.db.roomReadCursor.roomId.filter(r.id)]) + tx.db.roomReadCursor.id.delete(c.id); + for (const mem of [...tx.db.roomMember.roomId.filter(r.id)]) + tx.db.roomMember.id.delete(mem.id); + for (const ev of [...tx.db.roomActivityEvent.roomId.filter(r.id)]) + tx.db.roomActivityEvent.id.delete(ev.id); + tx.db.room.id.delete(r.id); + } + for (const sm of [...tx.db.serverMember.serverId.filter(serverId)]) + tx.db.serverMember.id.delete(sm.id); + tx.db.server.id.delete(serverId); + } +); + +export const join_server = spacetimedb.reducer( + { serverId: t.u64() }, + (ctx, { serverId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + ensureUser(tx, userId); + requireServer(tx, serverId); + if (findServerMembership(tx, serverId, userId)) return; + tx.db.serverMember.insert({ + id: 0n, + serverId, + userId, + role: 'member', + joinedAt: tx.timestamp, + }); + } +); + +export const leave_server = spacetimedb.reducer( + { serverId: t.u64() }, + (ctx, { serverId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + const srv = requireServer(tx, serverId); + if (srv.createdByUserId === userId) + senderError('chat.owner_cannot_leave_server'); + const mem = requireServerMembership(tx, serverId, userId); + tx.db.serverMember.id.delete(mem.id); + for (const r of [...tx.db.room.serverId.filter(serverId)]) { + const rm = findMembership(tx, r.id, userId); + if (rm) tx.db.roomMember.id.delete(rm.id); + } + } +); + +export const create_room = spacetimedb.reducer( + { + serverId: t.u64(), + name: t.string(), + isPrivate: t.bool(), + category: t.option(t.string()), + }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const name = normalizeText('room_name', args.name, ROOM_NAME_MAX); + const category = args.category + ? normalizeText('room_category', args.category, ROOM_NAME_MAX) + : undefined; + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + ensureUser(tx, userId); + requireServer(tx, args.serverId); + requireServerMembership(tx, args.serverId, userId); + insertRoom(tx, { + serverId: args.serverId, + name, + category, + isPrivate: args.isPrivate, + createdByUserId: userId, + role: 'owner', + }); + } +); + +export const join_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + ensureUser(tx, userId); + const targetRoom = requireRoom(tx, roomId); + if (targetRoom.isPrivate) senderError('chat.room_private'); + const existing = findMembership(tx, roomId, userId); + if (existing) return; + tx.db.roomMember.insert({ + id: 0n, + roomId, + userId, + role: 'member', + joinedAt: tx.timestamp, + }); + } +); + +export const leave_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + requireRoom(tx, roomId); + const membership = requireMembership(tx, roomId, userId); + tx.db.roomMember.id.delete(membership.id); + removeTypingPresence(tx, roomId, tx.sender); + } +); + +export const rename_room = spacetimedb.reducer( + { roomId: t.u64(), name: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const name = normalizeText('room_name', args.name, ROOM_NAME_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const room = requireRoomAdminOrOwner(tx, args.roomId, userId); + tx.db.room.id.update({ ...room, name }); + } +); + +export const set_room_category = spacetimedb.reducer( + { roomId: t.u64(), category: t.option(t.string()) }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const category = args.category + ? normalizeText('room_category', args.category, ROOM_NAME_MAX) + : undefined; + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const room = requireRoomAdminOrOwner(tx, args.roomId, userId); + tx.db.room.id.update({ ...room, category }); + } +); + +export const set_room_privacy = spacetimedb.reducer( + { roomId: t.u64(), isPrivate: t.bool() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + const room = requireRoomAdminOrOwner(tx, args.roomId, userId); + tx.db.room.id.update({ ...room, isPrivate: args.isPrivate }); + } +); + +export const delete_room = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_ROOM_WRITE.scope, + RATE_LIMIT_ROOM_WRITE.limit, + RATE_LIMIT_ROOM_WRITE.windowSeconds + ); + requireRoomAdminOrOwner(tx, roomId, userId); + + for (const m of [...tx.db.message.roomId.filter(roomId)]) { + deleteMessageTree(tx, m); + } + for (const c of [...tx.db.roomReadCursor.roomId.filter(roomId)]) + tx.db.roomReadCursor.id.delete(c.id); + for (const mem of [...tx.db.roomMember.roomId.filter(roomId)]) + tx.db.roomMember.id.delete(mem.id); + for (const ev of [...tx.db.roomActivityEvent.roomId.filter(roomId)]) + tx.db.roomActivityEvent.id.delete(ev.id); + removePresence(tx, typingScope(roomId), identityHex(tx.sender)); + tx.db.room.id.delete(roomId); + } +); + +const attachmentInput = t.object('AttachmentInput', { + mimeType: t.string(), + filename: t.option(t.string()), + bytes: t.array(t.u8()), +}); + +const attachmentFileResult = t.object('AttachmentFileResult', { + filename: t.option(t.string()), + mimeType: t.string(), + bytes: t.array(t.u8()), +}); + +export const get_attachment_file = spacetimedb.procedure( + { fileId: t.u64() }, + attachmentFileResult, + (ctx, args) => + ctx.withTx(tx => { + const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( + tx.sender + ); + if (!binding || !canReadAttachmentFile(tx, binding.userId, args.fileId)) { + senderError('chat.attachment_not_found'); + } + const file = tx.db.files.file.id.find(args.fileId); + if (!file) senderError('chat.attachment_not_found'); + const blob = tx.db.files.fileBlob.fileId.find(args.fileId); + if (!blob) senderError('chat.attachment_not_found'); + let filename: string | undefined; + for (const a of tx.db.attachment.fileId.filter(args.fileId)) { + filename = a.filename ?? undefined; + break; + } + return { + filename, + mimeType: file.mimeType, + bytes: blob.bytes, + }; + }) +); + +export const send_message = spacetimedb.reducer( + { + roomId: t.u64(), + content: t.string(), + replyToMessageId: t.option(t.u64()), + attachments: t.array(attachmentInput), + }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const trimmedContent = args.content.trim().replace(/\s+/g, ' '); + const hasAttachments = args.attachments.length > 0; + if (!hasAttachments && trimmedContent.length === 0) + senderError('chat.invalid_message'); + if (trimmedContent.length > MESSAGE_MAX) + senderError('chat.message_too_long'); + + if (args.attachments.length > ATTACHMENT_MAX_COUNT) { + senderError( + `chat.too_many_attachments:${args.attachments.length}/${ATTACHMENT_MAX_COUNT}` + ); + } + for (const a of args.attachments) { + if (a.mimeType.length === 0 || a.mimeType.length > ATTACHMENT_MIME_MAX) + senderError('chat.invalid_attachment_mime'); + if ( + a.filename !== undefined && + a.filename.length > ATTACHMENT_FILENAME_MAX + ) + senderError('chat.invalid_attachment_filename'); + if (a.bytes.length === 0) senderError('chat.empty_attachment'); + if (a.bytes.length > ATTACHMENT_MAX_BYTES) + senderError( + `chat.attachment_too_large:${a.bytes.length}/${ATTACHMENT_MAX_BYTES}` + ); + } + + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + const user = ensureUser(tx, userId); + requireRoom(tx, args.roomId); + requireMembership(tx, args.roomId, userId); + + if (args.replyToMessageId !== undefined) { + const parent = tx.db.message.id.find(args.replyToMessageId); + if (!parent || parent.roomId !== args.roomId) + senderError('chat.invalid_reply_target'); + } + + const msg = tx.db.message.insert({ + id: 0n, + roomId: args.roomId, + author: tx.sender, + content: trimmedContent, + createdAt: tx.timestamp, + editedAt: undefined, + replyToMessageId: args.replyToMessageId, + pinnedAt: undefined, + pinnedBy: undefined, + }); + + for (let i = 0; i < args.attachments.length; i++) { + const a = args.attachments[i]!; + const path = `/room/${args.roomId}/msg/${msg.id}/${i}`; + const file = tx.db.files.file.insert({ + id: 0n, + ownerPathKey: files.ownerPathKey(userId, path), + path, + ownerUserId: userId, + mimeType: a.mimeType, + size: BigInt(a.bytes.length), + sha256Hex: fileSha256Hex(a.bytes), + visibility: FILE_VISIBILITY_OWNER, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + tx.db.files.fileBlob.insert({ fileId: file.id, bytes: a.bytes }); + tx.db.attachment.insert({ + id: 0n, + messageId: msg.id, + fileId: file.id, + ownerUserId: userId, + ordinal: i, + filename: a.filename, + createdAt: tx.timestamp, + }); + } + + tx.db.roomActivityEvent.insert({ + id: 0n, + roomId: args.roomId, + createdAt: tx.timestamp, + }); + updateRoomActivity(tx, args.roomId); + removeTypingPresence(tx, args.roomId, tx.sender); + const nextUser = { + ...user, + lastActiveAt: tx.timestamp, + lastMessageAt: tx.timestamp, + }; + tx.db.chatUser.identity.update(nextUser); + updateGlobalPresence(tx, nextUser); + } +); + +export const edit_message = spacetimedb.reducer( + { messageId: t.u64(), content: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const content = normalizeText('message', args.content, MESSAGE_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + if (!eqIdentity(msg.author, tx.sender)) + senderError('chat.not_message_author'); + tx.db.message.id.update({ + ...msg, + content, + editedAt: tx.timestamp, + }); + } +); + +export const delete_message = spacetimedb.reducer( + { messageId: t.u64() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + if ( + !eqIdentity(msg.author, tx.sender) && + !canModerateRoom(tx, msg.roomId, userId) + ) + senderError('chat.not_message_author'); + deleteMessageTree(tx, msg); + } +); + +export const send_thread_message = spacetimedb.reducer( + { rootMessageId: t.u64(), content: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const content = normalizeText('thread_message', args.content, MESSAGE_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + const user = ensureUser(tx, userId); + const root = tx.db.message.id.find(args.rootMessageId); + if (!root) senderError('chat.message_not_found'); + requireMembership(tx, root.roomId, userId); + + let thread = tx.db.messageThread.rootMessageId.find(root.id); + if (!thread) { + thread = tx.db.messageThread.insert({ + id: 0n, + rootMessageId: root.id, + roomId: root.roomId, + createdBy: tx.sender, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + } else { + tx.db.messageThread.id.update({ ...thread, updatedAt: tx.timestamp }); + } + + tx.db.threadMessage.insert({ + id: 0n, + threadId: thread.id, + author: tx.sender, + content, + createdAt: tx.timestamp, + editedAt: undefined, + }); + + const nextUser = { + ...user, + lastActiveAt: tx.timestamp, + lastMessageAt: tx.timestamp, + }; + tx.db.chatUser.identity.update(nextUser); + updateGlobalPresence(tx, nextUser); + } +); + +export const edit_thread_message = spacetimedb.reducer( + { threadMessageId: t.u64(), content: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const content = normalizeText('thread_message', args.content, MESSAGE_MAX); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.threadMessage.id.find(args.threadMessageId); + if (!msg) senderError('chat.thread_message_not_found'); + const thread = tx.db.messageThread.id.find(msg.threadId); + if (!thread) senderError('chat.thread_not_found'); + requireMembership(tx, thread.roomId, userId); + if (!eqIdentity(msg.author, tx.sender)) + senderError('chat.not_message_author'); + tx.db.threadMessage.id.update({ ...msg, content, editedAt: tx.timestamp }); + } +); + +export const delete_thread_message = spacetimedb.reducer( + { threadMessageId: t.u64() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_SEND.scope, + RATE_LIMIT_SEND.limit, + RATE_LIMIT_SEND.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.threadMessage.id.find(args.threadMessageId); + if (!msg) senderError('chat.thread_message_not_found'); + const thread = tx.db.messageThread.id.find(msg.threadId); + if (!thread) senderError('chat.thread_not_found'); + requireMembership(tx, thread.roomId, userId); + if ( + !eqIdentity(msg.author, tx.sender) && + !canModerateRoom(tx, thread.roomId, userId) + ) + senderError('chat.not_message_author'); + tx.db.threadMessage.id.delete(msg.id); + let latestAt = thread.createdAt; + let remaining = 0; + for (const row of tx.db.threadMessage.threadId.filter(thread.id)) { + remaining++; + if ( + (row.createdAt.microsSinceUnixEpoch as bigint) > + (latestAt.microsSinceUnixEpoch as bigint) + ) + latestAt = row.createdAt; + } + if (remaining === 0) tx.db.messageThread.id.delete(thread.id); + else tx.db.messageThread.id.update({ ...thread, updatedAt: latestAt }); + } +); + +export const start_typing = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_TYPING.scope, + RATE_LIMIT_TYPING.limit, + RATE_LIMIT_TYPING.windowSeconds + ); + const user = ensureUser(tx, userId); + requireRoom(tx, roomId); + requireMembership(tx, roomId, userId); + upsertPresence(tx, { + scope: typingScope(roomId), + subject: identityHex(tx.sender), + status: 'typing', + activity: 'typing', + payloadJson: JSON.stringify({ + displayName: user.displayName, + userId: user.userId, + }), + ttlSeconds: TYPING_TTL_SECONDS, + }); + } +); + +export const stop_typing = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + removeTypingPresence(tx, roomId, tx.sender); + } +); + +export const mark_room_read = spacetimedb.reducer( + { roomId: t.u64() }, + (ctx, { roomId }) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + requireRoom(tx, roomId); + requireMembership(tx, roomId, userId); + let latestMessageId = 0n; + let latestMicros = 0n; + for (const msg of tx.db.message.roomId.filter(roomId)) { + const micros = msg.createdAt.microsSinceUnixEpoch as bigint; + if (micros > latestMicros) { + latestMicros = micros; + latestMessageId = msg.id; + } + } + upsertRoomReadCursor(tx, roomId, latestMessageId); + } +); + +export const toggle_reaction = spacetimedb.reducer( + { messageId: t.u64(), emoji: t.string() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const emoji = args.emoji.trim(); + if (!ALLOWED_REACTIONS.has(emoji)) + senderError('chat.invalid_reaction_emoji'); + const tx: Tx = ctx; + enforceChatRateLimit( + tx, + userId, + RATE_LIMIT_REACTION.scope, + RATE_LIMIT_REACTION.limit, + RATE_LIMIT_REACTION.windowSeconds + ); + ensureUser(tx, userId); + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + for (const r of tx.db.messageReaction.messageId.filter(msg.id)) { + if (eqIdentity(r.identity, tx.sender) && r.emoji === emoji) { + tx.db.messageReaction.id.delete(r.id); + return; + } + } + tx.db.messageReaction.insert({ + id: 0n, + messageId: msg.id, + identity: tx.sender, + emoji, + createdAt: tx.timestamp, + }); + } +); + +export const pin_message = spacetimedb.reducer( + { messageId: t.u64() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + if (msg.pinnedAt) return; + tx.db.message.id.update({ + ...msg, + pinnedAt: tx.timestamp, + pinnedBy: tx.sender, + }); + } +); + +export const unpin_message = spacetimedb.reducer( + { messageId: t.u64() }, + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const tx: Tx = ctx; + const msg = tx.db.message.id.find(args.messageId); + if (!msg) senderError('chat.message_not_found'); + requireMembership(tx, msg.roomId, userId); + if (!msg.pinnedAt) return; + tx.db.message.id.update({ + ...msg, + pinnedAt: undefined, + pinnedBy: undefined, + }); + } +); + +const SEARCH_MAX_RESULTS = 50; +const SEARCH_QUERY_MAX = 200; +export const search_messages = spacetimedb.procedure( + { roomId: t.u64(), query: t.string() }, + t.array(message.rowType), + (ctx, args) => { + const userId = requireAuthenticatedUserId(ctx); + const q = args.query.trim().slice(0, SEARCH_QUERY_MAX).toLowerCase(); + if (q.length === 0) return []; + return ctx.withTx(tx => { + requireMembership(tx, args.roomId, userId); + const matches = [...tx.db.message.roomId.filter(args.roomId)].filter(m => + m.content.toLowerCase().includes(q) + ); + matches.sort((a, b) => + b.createdAt.microsSinceUnixEpoch < a.createdAt.microsSinceUnixEpoch + ? -1 + : 1 + ); + return matches.slice(0, SEARCH_MAX_RESULTS); + }); + } +); + +export const chat_sweep = spacetimedb.reducer( + { arg: chatSweepTick.rowType }, + ctx => { + runPresenceSweep( + ctx, + ctx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: ctx.timestamp }) + ) + ); + + const cutoff = + (ctx.timestamp.microsSinceUnixEpoch as bigint) - + BigInt(ACTIVITY_WINDOW_SECONDS) * ONE_SECOND_MICROS; + let deleted = 0; + const affectedRoomIds = new Set(); + for (const evt of ctx.db.roomActivityEvent.iter()) { + if (deleted >= ACTIVITY_CLEANUP_BATCH) break; + if ((evt.createdAt.microsSinceUnixEpoch as bigint) >= cutoff) continue; + affectedRoomIds.add(evt.roomId); + ctx.db.roomActivityEvent.delete(evt); + deleted++; + } + + for (const roomId of affectedRoomIds) { + updateRoomActivity(ctx, roomId); + } + } +); + +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + passwordSignupHandler(ctx.as.auth, req) +); +export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => + passwordLoginHandler(ctx.as.auth, req) +); +export const authMe = spacetimedb.httpHandler((ctx, req) => + meHandler(ctx.as.auth, req) +); +export const authLogout = spacetimedb.httpHandler((ctx, req) => + logoutHandler(ctx.as.auth, req) +); +export const authRefresh = spacetimedb.httpHandler((ctx, req) => + refreshHandler(ctx.as.auth, req) +); +export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => + googleStartHandler(ctx.as.auth, req) +); +export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => + googleCallbackHandler(ctx.as.auth, req) +); +export const authGithubStart = spacetimedb.httpHandler((ctx, req) => + githubStartHandler(ctx.as.auth, req) +); +export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => + githubCallbackHandler(ctx.as.auth, req) +); + +const fileServeHandler = files.makeFileServeImpl({ + getOwner: (ctx, req) => + ctx.withTx((tx: TransactionCtx) => { + const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( + tx.sender + ); + if (binding) return binding.userId; + + const cfg = tx.db.auth.authConfig.singleton.find(true); + if (!cfg) return undefined; + const bearer = req.headers.get('authorization'); + const cookies = parseCookies(req.headers.get('cookie')); + const tokens = [ + bearer && bearer.toLowerCase().startsWith('bearer ') + ? bearer.slice(7).trim() + : undefined, + cookies[cfg.cookieName], + ].filter((token): token is string => Boolean(token)); + for (const token of tokens) { + const verified = verifyJwt( + publicKeyFromPem(cfg.es256PublicKeyPem), + token, + { + issuer: cfg.issuerUrl, + nowSeconds: Number( + (tx.timestamp.microsSinceUnixEpoch as bigint) / 1_000_000n + ), + } + ); + if (!verified.ok || !verified.claims.jti) continue; + + const session = tx.db.auth.authSession.sessionId.find( + verified.claims.jti + ); + if (!session) continue; + if ( + (session.expiresAt.microsSinceUnixEpoch as bigint) <= + (tx.timestamp.microsSinceUnixEpoch as bigint) + ) { + continue; + } + if (session.userId === verified.claims.sub) return session.userId; + } + return undefined; + }), + canAccess: (ctx, _req, file, userId) => + ctx.withTx((tx: TransactionCtx) => { + if (!userId) return false; + if (file.ownerUserId === userId) return true; + for (const a of tx.db.attachment.fileId.filter(file.id)) { + const msg = tx.db.message.id.find(a.messageId); + if (!msg) continue; + for (const member of tx.db.roomMember.roomId.filter(msg.roomId)) { + if (member.userId === userId) return true; + } + } + return false; + }), +}); +export const fileServe = spacetimedb.httpHandler(fileServeHandler); + +const forgotHandler = makeForgotPasswordHandler({ + sendMail: consoleSendMail, + appName: 'Chat', +}); +const verifyRequestHandler = makeEmailVerifyRequestHandler({ + sendMail: consoleSendMail, + appName: 'Chat', +}); +const verifyHandler = makeEmailVerifyHandler({ + successRedirect: '/?verified=1', +}); + +export const authPasswordForgot = spacetimedb.httpHandler((ctx, req) => + forgotHandler(ctx.as.auth, req) +); +export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => + resetPasswordHandler(ctx.as.auth, req) +); +export const authEmailVerifyRequest = spacetimedb.httpHandler((ctx, req) => + verifyRequestHandler(ctx.as.auth, req) +); +export const authEmailVerify = spacetimedb.httpHandler((ctx, req) => + verifyHandler(ctx.as.auth, req) +); + +export const router = spacetimedb.httpRouter( + new Router() + .post('/auth/password/signup', authPasswordSignup) + .post('/auth/password/login', authPasswordLogin) + .post('/auth/session/refresh', authRefresh) + .get('/auth/me', authMe) + .post('/auth/logout', authLogout) + .get('/auth/google/start', authGoogleStart) + .get('/auth/google/callback', authGoogleCallback) + .get('/auth/github/start', authGithubStart) + .get('/auth/github/callback', authGithubCallback) + .post('/auth/password/forgot', authPasswordForgot) + .post('/auth/password/reset', authPasswordReset) + .post('/auth/email/verify-request', authEmailVerifyRequest) + .get('/auth/email/verify', authEmailVerify) + .get('/files', fileServe) + .get('/files/', fileServe) + .head('/files/', fileServe) + .head('/files', fileServe) +); diff --git a/spacetime-presence-ts/example/spacetimedb/src/model.ts b/spacetime-presence-ts/example/spacetimedb/src/model.ts new file mode 100644 index 00000000000..f323c640f47 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/model.ts @@ -0,0 +1,192 @@ +import { table, t } from 'spacetimedb/server'; + +export const chatUserStatus = t.enum('ChatUserStatus', [ + 'Online', + 'Away', + 'Dnd', + 'Invisible', +]); +export const ChatUserStatus = { + Online: { tag: 'Online' as const }, + Away: { tag: 'Away' as const }, + Dnd: { tag: 'Dnd' as const }, + Invisible: { tag: 'Invisible' as const }, +}; + +export const chatUser = table( + { name: 'chat_user', public: false }, + { + identity: t.identity().primaryKey(), + userId: t.string().index(), + displayName: t.string(), + status: chatUserStatus.index(), + createdAt: t.timestamp().index(), + lastActiveAt: t.timestamp().index(), + lastMessageAt: t.timestamp(), + } +); + +export const server = table( + { name: 'server', public: false }, + { + id: t.u64().primaryKey().autoInc(), + name: t.string().index(), + createdByUserId: t.string().index(), + createdAt: t.timestamp(), + } +); + +export const serverMember = table( + { name: 'server_member', public: false }, + { + id: t.u64().primaryKey().autoInc(), + serverId: t.u64().index(), + userId: t.string().index(), + role: t.string(), + joinedAt: t.timestamp(), + } +); + +// Chat tables are scoped per-user via views below. Clients subscribe to the +// `my_*` views; the underlying tables are private. +export const room = table( + { name: 'room', public: false }, + { + id: t.u64().primaryKey().autoInc(), + serverId: t.u64().index(), + name: t.string().index(), + category: t.option(t.string()), + createdByUserId: t.string().index(), + createdAt: t.timestamp().index(), + isPrivate: t.bool(), + activityLabel: t.string().index(), + activityScore: t.u32(), + lastActivityAt: t.option(t.timestamp()), + } +); + +export const roomMember = table( + { name: 'room_member', public: false }, + { + id: t.u64().primaryKey().autoInc(), + roomId: t.u64().index(), + userId: t.string().index(), + role: t.string().index(), + joinedAt: t.timestamp(), + } +); + +export const message = table( + { name: 'message', public: false }, + { + id: t.u64().primaryKey().autoInc(), + roomId: t.u64().index(), + author: t.identity().index(), + content: t.string(), + createdAt: t.timestamp().index(), + editedAt: t.option(t.timestamp()), + replyToMessageId: t.option(t.u64()), + pinnedAt: t.option(t.timestamp()), + pinnedBy: t.option(t.identity()), + } +); + +export const messageReaction = table( + { name: 'message_reaction', public: false }, + { + id: t.u64().primaryKey().autoInc(), + messageId: t.u64().index(), + identity: t.identity().index(), + emoji: t.string().index(), + createdAt: t.timestamp(), + } +); + +export const messageThread = table( + { name: 'message_thread', public: false }, + { + id: t.u64().primaryKey().autoInc(), + rootMessageId: t.u64().unique(), + roomId: t.u64().index(), + createdBy: t.identity().index(), + createdAt: t.timestamp().index(), + updatedAt: t.timestamp().index(), + } +); + +export const threadMessage = table( + { name: 'thread_message', public: false }, + { + id: t.u64().primaryKey().autoInc(), + threadId: t.u64().index(), + author: t.identity().index(), + content: t.string(), + createdAt: t.timestamp().index(), + editedAt: t.option(t.timestamp()), + } +); + +export const attachment = table( + { name: 'attachment', public: false }, + { + id: t.u64().primaryKey().autoInc(), + messageId: t.u64().index(), + fileId: t.u64().index(), + ownerUserId: t.string().index(), + ordinal: t.u32(), + filename: t.option(t.string()), + createdAt: t.timestamp(), + } +); + +export const attachmentViewRow = t.object('RoomAttachment', { + id: t.u64(), + messageId: t.u64(), + fileId: t.u64(), + ownerUserId: t.string(), + ordinal: t.u32(), + filename: t.option(t.string()), + path: t.string(), + mimeType: t.string(), + size: t.u64(), + sha256Hex: t.string(), + visibility: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +export const roomReadCursor = table( + { name: 'room_read_cursor', public: false }, + { + id: t.u64().primaryKey().autoInc(), + roomId: t.u64().index(), + identity: t.identity().index(), + lastReadMessageId: t.u64(), + lastReadAt: t.timestamp().index(), + } +); + +export const roomActivityEvent = table( + { name: 'room_activity_event', public: false }, + { + id: t.u64().primaryKey().autoInc(), + roomId: t.u64().index(), + createdAt: t.timestamp().index(), + } +); + +export const presenceEntry = table( + { name: 'presence_entry', public: false }, + { + key: t.string().primaryKey(), + scope: t.string().index(), + subject: t.string().index(), + status: t.string().index(), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + joinedAt: t.timestamp().index(), + lastSeenAt: t.timestamp().index(), + expiresAt: t.timestamp().index(), + updatedAt: t.timestamp(), + } +); diff --git a/spacetime-presence-ts/example/spacetimedb/src/views.ts b/spacetime-presence-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..90b11e7cb52 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,376 @@ +import { t, type ViewCtx } from 'spacetimedb/server'; +import { + RATE_LIMIT_PROFILE, + RATE_LIMIT_REACTION, + RATE_LIMIT_ROOM_WRITE, + RATE_LIMIT_SEND, + RATE_LIMIT_TYPING, + PRESENCE_SCOPE_GLOBAL, + typingScope, +} from './chat-policy'; +import { + attachmentViewRow, + chatUser, + message, + messageReaction, + messageThread, + presenceEntry, + room, + roomMember, + roomReadCursor, + server, + serverMember, + threadMessage, +} from './model'; +import type { DbSchema } from './index'; + +type SpacetimeDb = typeof import('./index').default; + +function myRoomIds(ctx: ViewCtx): Set { + const out = new Set(); + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return out; + for (const membership of ctx.db.roomMember.userId.filter(binding.userId)) { + out.add(membership.roomId); + } + return out; +} + +function myServerIds(ctx: ViewCtx): Set { + const out = new Set(); + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return out; + for (const membership of ctx.db.serverMember.userId.filter(binding.userId)) { + out.add(membership.serverId); + } + for (const roomId of myRoomIds(ctx)) { + const roomRow = ctx.db.room.id.find(roomId); + if (roomRow) out.add(roomRow.serverId); + } + return out; +} + +function myMessageIds( + ctx: ViewCtx, + roomIds: Set +): Set { + const out = new Set(); + for (const roomId of roomIds) { + for (const row of ctx.db.message.roomId.filter(roomId)) out.add(row.id); + } + return out; +} + +function myThreadIds( + ctx: ViewCtx, + roomIds: Set +): Set { + const out = new Set(); + for (const roomId of roomIds) { + for (const row of ctx.db.messageThread.roomId.filter(roomId)) + out.add(row.id); + } + return out; +} + +function myVisibleUserIds( + ctx: ViewCtx, + serverIds = myServerIds(ctx), + roomIds = myRoomIds(ctx) +): Set { + const out = new Set(); + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (binding) out.add(binding.userId); + for (const serverId of serverIds) { + for (const row of ctx.db.serverMember.serverId.filter(serverId)) + out.add(row.userId); + } + for (const roomId of roomIds) { + for (const row of ctx.db.roomMember.roomId.filter(roomId)) + out.add(row.userId); + } + return out; +} + +function myVisibleIdentitySubjects( + ctx: ViewCtx, + userIds: Set +): Set { + const out = new Set(); + for (const userId of userIds) { + for (const user of ctx.db.chatUser.userId.filter(userId)) { + out.add(user.identity.toHexString()); + } + } + return out; +} + +export function registerChatViews(spacetimedb: SpacetimeDb) { + const authUserViewRow = t.object('ChatAuthUser', { + userId: t.string(), + email: t.string(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + }); + const rateLimitStatusRow = t.object('ChatRateLimitStatus', { + scope: t.string(), + limit: t.u32(), + used: t.u32(), + remaining: t.u32(), + resetAt: t.timestamp(), + }); + + const myServers = spacetimedb.view( + { name: 'my_servers', public: true }, + t.array(server.rowType), + ctx => { + const ids = myServerIds(ctx); + const out = []; + for (const serverId of ids) { + const row = ctx.db.server.id.find(serverId); + if (row) out.push(row); + } + return out; + } + ); + + const myServerMembers = spacetimedb.view( + { name: 'my_server_members', public: true }, + t.array(serverMember.rowType), + ctx => { + const out = []; + for (const serverId of myServerIds(ctx)) { + for (const row of ctx.db.serverMember.serverId.filter(serverId)) + out.push(row); + } + return out; + } + ); + + const myChatUsers = spacetimedb.view( + { name: 'my_chat_users', public: true }, + t.array(chatUser.rowType), + ctx => { + const out = []; + for (const userId of myVisibleUserIds(ctx)) { + for (const row of ctx.db.chatUser.userId.filter(userId)) out.push(row); + } + return out; + } + ); + + const myPresenceEntries = spacetimedb.view( + { name: 'my_presence_entries', public: true }, + t.array(presenceEntry.rowType), + ctx => { + const roomIds = myRoomIds(ctx); + const visibleSubjects = myVisibleIdentitySubjects( + ctx, + myVisibleUserIds(ctx, myServerIds(ctx), roomIds) + ); + const out = []; + for (const subject of visibleSubjects) { + for (const entry of ctx.db.presenceEntry.subject.filter(subject)) { + if (entry.scope === PRESENCE_SCOPE_GLOBAL) out.push(entry); + } + } + for (const roomId of roomIds) { + for (const entry of ctx.db.presenceEntry.scope.filter( + typingScope(roomId) + )) { + if (visibleSubjects.has(entry.subject)) out.push(entry); + } + } + return out; + } + ); + + const myRooms = spacetimedb.view( + { name: 'my_rooms', public: true }, + t.array(room.rowType), + ctx => { + const out = []; + for (const roomId of myRoomIds(ctx)) { + const row = ctx.db.room.id.find(roomId); + if (row) out.push(row); + } + return out; + } + ); + + const myRoomMembers = spacetimedb.view( + { name: 'my_room_members', public: true }, + t.array(roomMember.rowType), + ctx => { + const out = []; + for (const roomId of myRoomIds(ctx)) { + for (const row of ctx.db.roomMember.roomId.filter(roomId)) + out.push(row); + } + return out; + } + ); + + const myRoomMessages = spacetimedb.view( + { name: 'my_room_messages', public: true }, + t.array(message.rowType), + ctx => { + const out = []; + for (const roomId of myRoomIds(ctx)) { + for (const row of ctx.db.message.roomId.filter(roomId)) out.push(row); + } + return out; + } + ); + + const myRoomMessageReactions = spacetimedb.view( + { name: 'my_room_message_reactions', public: true }, + t.array(messageReaction.rowType), + ctx => { + const rooms = myRoomIds(ctx); + const out = []; + for (const messageId of myMessageIds(ctx, rooms)) { + for (const row of ctx.db.messageReaction.messageId.filter(messageId)) + out.push(row); + } + return out; + } + ); + + const myMessageThreads = spacetimedb.view( + { name: 'my_message_threads', public: true }, + t.array(messageThread.rowType), + ctx => { + const out = []; + for (const roomId of myRoomIds(ctx)) { + for (const row of ctx.db.messageThread.roomId.filter(roomId)) + out.push(row); + } + return out; + } + ); + + const myThreadMessages = spacetimedb.view( + { name: 'my_thread_messages', public: true }, + t.array(threadMessage.rowType), + ctx => { + const rooms = myRoomIds(ctx); + const out = []; + for (const threadId of myThreadIds(ctx, rooms)) { + for (const row of ctx.db.threadMessage.threadId.filter(threadId)) + out.push(row); + } + return out; + } + ); + + const myRoomAttachments = spacetimedb.view( + { name: 'my_room_attachments', public: true }, + t.array(attachmentViewRow), + ctx => { + const rooms = myRoomIds(ctx); + const out = []; + for (const messageId of myMessageIds(ctx, rooms)) { + for (const attachment of ctx.db.attachment.messageId.filter( + messageId + )) { + const file = ctx.db.files.file.id.find(attachment.fileId); + if (!file) continue; + out.push({ + id: attachment.id, + messageId: attachment.messageId, + fileId: attachment.fileId, + ownerUserId: attachment.ownerUserId, + ordinal: attachment.ordinal, + filename: attachment.filename, + path: file.path, + mimeType: file.mimeType, + size: file.size, + sha256Hex: file.sha256Hex, + visibility: file.visibility, + createdAt: attachment.createdAt, + updatedAt: file.updatedAt, + }); + } + } + return out; + } + ); + + const myRoomReadCursors = spacetimedb.view( + { name: 'my_room_read_cursors', public: true }, + t.array(roomReadCursor.rowType), + ctx => [...ctx.db.roomReadCursor.identity.filter(ctx.sender)] + ); + + const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUserViewRow), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + const row = ctx.db.auth.authUser.userId.find(binding.userId); + return row ? [row] : []; + } + ); + + const myRateLimitStatus = spacetimedb.view( + { name: 'my_rate_limit_status', public: true }, + t.array(rateLimitStatusRow), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + const out = []; + for (const limit of [ + RATE_LIMIT_SEND, + RATE_LIMIT_TYPING, + RATE_LIMIT_ROOM_WRITE, + RATE_LIMIT_REACTION, + RATE_LIMIT_PROFILE, + ]) { + const row = ctx.db.rateLimit.rateLimitBucket.key.find( + `${limit.scope}:user:${binding.userId}` + ); + if (!row) continue; + out.push({ + scope: limit.scope, + limit: limit.limit, + used: row.count, + remaining: Math.max(0, limit.limit - row.count), + resetAt: row.expiresAt, + }); + } + return out; + } + ); + + return { + myServers, + myServerMembers, + myChatUsers, + myPresenceEntries, + myRooms, + myRoomMembers, + myRoomMessages, + myRoomMessageReactions, + myMessageThreads, + myThreadMessages, + myRoomAttachments, + myRoomReadCursors, + myAuthUser, + myRateLimitStatus, + }; +} diff --git a/spacetime-presence-ts/example/spacetimedb/tsconfig.json b/spacetime-presence-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..f004a6cbc79 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-presence-ts/example/src/app.ts b/spacetime-presence-ts/example/src/app.ts new file mode 100644 index 00000000000..3959a2e2ed0 --- /dev/null +++ b/spacetime-presence-ts/example/src/app.ts @@ -0,0 +1,748 @@ +import { + DbConnection, + type ErrorContext, + type EventContext, +} from './codegen/app/index.ts'; +import type { + PresenceEntry, + Server, + ChatAuthUser as AuthUserRow, + ChatRateLimitStatus, + MessageThread, + ThreadMessage, +} from './codegen/app/types.ts'; + +interface AttachmentInput { + mimeType: string; + filename: string | undefined; + bytes: Uint8Array; +} + +declare global { + interface Window { + chat?: { + setDisplayName: (displayName: string) => Promise; + setStatus: ( + status: 'online' | 'away' | 'dnd' | 'invisible' + ) => Promise; + createServer: (name: string) => Promise; + renameServer: (serverId: bigint, name: string) => Promise; + deleteServer: (serverId: bigint) => Promise; + joinServer: (serverId: bigint) => Promise; + leaveServer: (serverId: bigint) => Promise; + setActiveServer: (serverId: bigint | null) => void; + createRoom: ( + serverId: bigint, + name: string, + isPrivate: boolean, + category?: string + ) => Promise; + joinRoom: (roomId: bigint) => Promise; + leaveRoom: (roomId: bigint) => Promise; + sendMessage: ( + roomId: bigint, + content: string, + replyToMessageId?: bigint, + attachments?: AttachmentInput[] + ) => Promise; + sendThreadMessage: ( + rootMessageId: bigint, + content: string + ) => Promise; + getAttachmentFile: ( + fileId: bigint + ) => Promise<{ filename?: string; mimeType: string; bytes: Uint8Array }>; + editMessage: (messageId: bigint, content: string) => Promise; + deleteMessage: (messageId: bigint) => Promise; + editThreadMessage: ( + threadMessageId: bigint, + content: string + ) => Promise; + deleteThreadMessage: (threadMessageId: bigint) => Promise; + renameRoom: (roomId: bigint, name: string) => Promise; + setRoomCategory: (roomId: bigint, category?: string) => Promise; + setRoomPrivacy: (roomId: bigint, isPrivate: boolean) => Promise; + deleteRoom: (roomId: bigint) => Promise; + startTyping: (roomId: bigint) => Promise; + stopTyping: (roomId: bigint) => Promise; + markRoomRead: (roomId: bigint) => Promise; + toggleReaction: (messageId: bigint, emoji: string) => Promise; + pinMessage: (messageId: bigint) => Promise; + unpinMessage: (messageId: bigint) => Promise; + searchMessages: (roomId: bigint, query: string) => Promise; + setActiveRoom: (roomId: bigint | null) => void; + heartbeat: () => Promise; + signup: (args: { + email: string; + password: string; + name?: string; + }) => Promise; + login: (args: { email: string; password: string }) => Promise; + logout: () => Promise; + oauthStart: (provider: 'google' | 'github') => void; + forgotPassword: (email: string) => Promise; + requestEmailVerify: () => Promise; + whoami: () => Promise<{ + userId: string | undefined; + senderIdentityHex: string; + }>; + setProfile: (args: { name?: string; image?: string }) => Promise; + }; + } +} + +interface ServerConfig { + stdbUri: string; + appDatabase: string; +} + +interface AuthUser { + userId: string; + email: string; + emailVerified: boolean; + name?: string; + image?: string; +} + +interface AuthRefreshResponse { + user: AuthUser; + token: string; + sessionExpiresAt: number; +} + +interface AuthMeResponse { + user: AuthUser; + sessionExpiresAt: number; +} + +const PRESENCE_SCOPE_GLOBAL = 'chat.global'; +const PRESENCE_SCOPE_TYPING_PREFIX = 'chat.typing:'; +const HEARTBEAT_INTERVAL_MS = 15_000; +const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; + +let config: ServerConfig | null = null; +let conn: DbConnection | null = null; +let meHex = ''; +let activeServerId: bigint | null = null; +let activeRoomId: bigint | null = null; +let heartbeatTimer: ReturnType | null = null; +let reconnectTimer: ReturnType | null = null; +let reconnectAttempt = 0; +let authUser: AuthUser | null = null; +let sessionExpiresAt: number | undefined; + +function normalizeError(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +function emitConn( + state: 'connecting' | 'connected' | 'error', + detail?: string +): void { + window.dispatchEvent( + new CustomEvent('chat:conn', { detail: { state, detail } }) + ); +} + +function emitAuth(): void { + window.dispatchEvent( + new CustomEvent('chat:auth', { + detail: { + user: authUser, + sessionExpiresAt, + senderIdentityHex: meHex, + }, + }) + ); +} + +function emitData(): void { + if (!conn) { + window.dispatchEvent( + new CustomEvent('chat:data', { + detail: { + meHex, + activeServerId, + activeRoomId, + servers: [], + serverMembers: [], + rooms: [], + users: [], + members: [], + messages: [], + reactions: [], + attachments: [], + threads: [], + threadMessages: [], + cursors: [], + presence: [], + rateLimitStatus: [], + authenticated: Boolean(authUser), + }, + }) + ); + return; + } + const c = conn; + const serverRows = [...c.db.myServers.iter()].sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + const roomRows = [...c.db.myRooms.iter()].sort((a, b) => { + if (a.name === 'general') return -1; + if (b.name === 'general') return 1; + return a.name.localeCompare(b.name); + }); + const userRows = [...c.db.myChatUsers.iter()].sort((a, b) => + a.displayName.localeCompare(b.displayName) + ); + const messageRows = [...c.db.myRoomMessages.iter()].sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? -1 : av > bv ? 1 : 0; + }); + const threadRows = [...c.db.myMessageThreads.iter()].sort( + (a: MessageThread, b: MessageThread) => { + const av = a.updatedAt.microsSinceUnixEpoch as bigint; + const bv = b.updatedAt.microsSinceUnixEpoch as bigint; + return av < bv ? 1 : av > bv ? -1 : 0; + } + ); + const threadMessageRows = [...c.db.myThreadMessages.iter()].sort( + (a: ThreadMessage, b: ThreadMessage) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? -1 : av > bv ? 1 : 0; + } + ); + window.dispatchEvent( + new CustomEvent('chat:data', { + detail: { + meHex, + activeServerId, + activeRoomId, + servers: serverRows, + serverMembers: [...c.db.myServerMembers.iter()], + rooms: roomRows, + users: userRows, + members: [...c.db.myRoomMembers.iter()], + messages: messageRows, + reactions: [...c.db.myRoomMessageReactions.iter()], + attachments: [...c.db.myRoomAttachments.iter()].sort( + (a, b) => a.ordinal - b.ordinal + ), + threads: threadRows, + threadMessages: threadMessageRows, + cursors: [...c.db.myRoomReadCursors.iter()], + presence: [...c.db.myPresenceEntries.iter()], + rateLimitStatus: [ + ...c.db.myRateLimitStatus.iter(), + ] as ChatRateLimitStatus[], + authenticated: Boolean(authUser), + }, + }) + ); +} + +async function callJson(path: string, body?: unknown): Promise { + const r = await fetch(path, { + method: body !== undefined ? 'POST' : 'GET', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'same-origin', + }); + let data: unknown = null; + try { + data = await r.json(); + } catch { + /* empty or non-JSON response */ + } + if (!r.ok) { + const error = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${r.status}`; + throw new Error(error); + } + return data as T; +} + +async function loadConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + return (await r.json()) as ServerConfig; +} + +function requireConn(): DbConnection { + if (!conn) throw new Error('chat.disconnected'); + return conn; +} + +function clearHeartbeat(): void { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } +} + +function scheduleHeartbeat(): void { + clearHeartbeat(); + if (!authUser) return; + heartbeatTimer = setInterval(() => { + if (!conn || !authUser) return; + try { + void conn.reducers.heartbeat({}).catch(() => undefined); + } catch { + // A later heartbeat retries after transient connection failures. + } + }, HEARTBEAT_INTERVAL_MS); +} + +function scheduleReconnect(): void { + if (reconnectTimer != null) return; + const delay = + RECONNECT_DELAYS_MS[ + Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1) + ]; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + reconnectAttempt++; + run().catch(err => { + emitConn('error', normalizeError(err)); + scheduleReconnect(); + }); + }, delay); +} + +const STDB_TOKEN_KEY = 'chat:stdb_token'; +const AUTH_TOKEN_KEY = 'chat:auth_token'; + +function loadStdbToken(): string | undefined { + try { + return localStorage.getItem(STDB_TOKEN_KEY) ?? undefined; + } catch { + return undefined; + } +} + +function saveStdbToken(token: string): void { + try { + localStorage.setItem(STDB_TOKEN_KEY, token); + } catch { + /* Storage can be unavailable. */ + } +} + +function saveAuthToken(token: string): void { + try { + localStorage.setItem(AUTH_TOKEN_KEY, token); + } catch { + /* Storage can be unavailable. */ + } +} + +function clearAuthToken(): void { + try { + localStorage.removeItem(AUTH_TOKEN_KEY); + } catch { + /* Storage can be unavailable. */ + } +} + +function connectStdb(cfg: ServerConfig): Promise { + return new Promise((resolve, reject) => { + const priorToken = loadStdbToken(); + DbConnection.builder() + .withUri(cfg.stdbUri) + .withDatabaseName(cfg.appDatabase) + .withToken(priorToken) + .onConnect((c, _identity, token) => { + if (token) saveStdbToken(token); + resolve(c); + }) + .onDisconnect((_ctx, err) => { + conn = null; + clearHeartbeat(); + emitConn('error', err?.message ?? 'disconnected'); + scheduleReconnect(); + }) + .onConnectError((_ctx, err) => reject(err)) + .build(); + }); +} + +function wireSubscriptions(c: DbConnection): void { + c.subscriptionBuilder() + .onApplied(() => emitData()) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe([ + 'SELECT * FROM my_chat_users', + 'SELECT * FROM my_servers', + 'SELECT * FROM my_server_members', + 'SELECT * FROM my_presence_entries', + 'SELECT * FROM my_rooms', + 'SELECT * FROM my_room_members', + 'SELECT * FROM my_room_messages', + 'SELECT * FROM my_room_message_reactions', + 'SELECT * FROM my_room_attachments', + 'SELECT * FROM my_message_threads', + 'SELECT * FROM my_thread_messages', + 'SELECT * FROM my_room_read_cursors', + 'SELECT * FROM my_auth_user', + 'SELECT * FROM my_rate_limit_status', + ]); + + const reRender = () => emitData(); + const tables = [ + c.db.myChatUsers, + c.db.myRooms, + c.db.myRoomMembers, + c.db.myRoomMessages, + c.db.myRoomMessageReactions, + c.db.myRoomAttachments, + c.db.myServerMembers, + c.db.myMessageThreads, + c.db.myThreadMessages, + c.db.myRoomReadCursors, + c.db.myPresenceEntries, + c.db.myRateLimitStatus, + ]; + for (const t of tables) { + t.onInsert(reRender); + t.onUpdate(reRender); + t.onDelete(reRender); + } + + c.db.myServers.onInsert(reRender); + c.db.myServers.onUpdate(reRender); + c.db.myServers.onDelete((_ctx: EventContext, row: Server) => { + if (activeServerId === row.id) { + activeServerId = null; + activeRoomId = null; + } + emitData(); + }); + + const syncUserFromRow = (row: AuthUserRow) => { + if (!authUser || row.userId !== authUser.userId) return; + authUser = { + userId: row.userId, + email: row.email, + emailVerified: row.emailVerified, + name: row.name ?? undefined, + image: row.image ?? undefined, + }; + emitAuth(); + }; + c.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) => + syncUserFromRow(row) + ); + c.db.myAuthUser.onUpdate( + (_ctx: EventContext, _old: AuthUserRow, neu: AuthUserRow) => + syncUserFromRow(neu) + ); + c.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => { + if (!authUser || row.userId !== authUser.userId) return; + authUser = null; + sessionExpiresAt = undefined; + emitAuth(); + emitData(); + }); +} + +async function bindSession( + sessionToken: string, + refreshedUser?: AuthUser, + exp?: number +): Promise { + saveAuthToken(sessionToken); + const c = requireConn(); + await c.reducers.linkConnection({ sessionToken }); + const me = await c.procedures.whoami({}); + meHex = me.senderIdentityHex; + if (!refreshedUser) { + const meRes = await callJson('/auth/me'); + refreshedUser = meRes.user; + exp = meRes.sessionExpiresAt; + } + authUser = refreshedUser; + sessionExpiresAt = exp; + await c.reducers.heartbeat({}); + emitAuth(); + emitData(); + scheduleHeartbeat(); +} + +async function restoreSession(): Promise { + try { + const refreshed = await callJson( + '/auth/session/refresh', + {} + ); + await bindSession( + refreshed.token, + refreshed.user, + refreshed.sessionExpiresAt + ); + return true; + } catch { + authUser = null; + sessionExpiresAt = undefined; + clearAuthToken(); + emitAuth(); + clearHeartbeat(); + return false; + } +} + +function installApi(): void { + window.chat = { + setDisplayName: (displayName: string) => { + return requireConn().reducers.setDisplayName({ displayName }); + }, + setStatus: status => { + // UI passes lowercase strings; map to ChatUserStatus enum tags. + const tag = (status.charAt(0).toUpperCase() + status.slice(1)) as + | 'Online' + | 'Away' + | 'Dnd' + | 'Invisible'; + return requireConn().reducers.setStatus({ status: { tag } }); + }, + createServer: (name: string) => { + return requireConn().reducers.createServer({ name }); + }, + renameServer: (serverId: bigint, name: string) => { + return requireConn().reducers.renameServer({ serverId, name }); + }, + deleteServer: (serverId: bigint) => { + return requireConn().reducers.deleteServer({ serverId }); + }, + joinServer: (serverId: bigint) => { + return requireConn().reducers.joinServer({ serverId }); + }, + leaveServer: (serverId: bigint) => { + return requireConn().reducers.leaveServer({ serverId }); + }, + setActiveServer: (serverId: bigint | null) => { + activeServerId = serverId; + activeRoomId = null; + emitData(); + }, + createRoom: ( + serverId: bigint, + name: string, + isPrivate: boolean, + category?: string + ) => { + return requireConn().reducers.createRoom({ + serverId, + name, + isPrivate, + category, + }); + }, + joinRoom: (roomId: bigint) => { + return requireConn().reducers.joinRoom({ roomId }); + }, + leaveRoom: (roomId: bigint) => { + return requireConn().reducers.leaveRoom({ roomId }); + }, + sendMessage: ( + roomId: bigint, + content: string, + replyToMessageId?: bigint, + atts?: AttachmentInput[] + ) => { + return requireConn().reducers.sendMessage({ + roomId, + content, + replyToMessageId, + attachments: atts ?? [], + }); + }, + sendThreadMessage: (rootMessageId: bigint, content: string) => { + return requireConn().reducers.sendThreadMessage({ + rootMessageId, + content, + }); + }, + getAttachmentFile: async (fileId: bigint) => { + return await requireConn().procedures.getAttachmentFile({ fileId }); + }, + editMessage: (messageId: bigint, content: string) => { + return requireConn().reducers.editMessage({ messageId, content }); + }, + deleteMessage: (messageId: bigint) => { + return requireConn().reducers.deleteMessage({ messageId }); + }, + editThreadMessage: (threadMessageId: bigint, content: string) => { + return requireConn().reducers.editThreadMessage({ + threadMessageId, + content, + }); + }, + deleteThreadMessage: (threadMessageId: bigint) => { + return requireConn().reducers.deleteThreadMessage({ threadMessageId }); + }, + renameRoom: (roomId: bigint, name: string) => { + return requireConn().reducers.renameRoom({ roomId, name }); + }, + setRoomCategory: (roomId: bigint, category?: string) => { + return requireConn().reducers.setRoomCategory({ roomId, category }); + }, + setRoomPrivacy: (roomId: bigint, isPrivate: boolean) => { + return requireConn().reducers.setRoomPrivacy({ roomId, isPrivate }); + }, + deleteRoom: (roomId: bigint) => { + return requireConn().reducers.deleteRoom({ roomId }); + }, + startTyping: (roomId: bigint) => { + return requireConn().reducers.startTyping({ roomId }); + }, + stopTyping: (roomId: bigint) => { + return requireConn().reducers.stopTyping({ roomId }); + }, + markRoomRead: (roomId: bigint) => { + return requireConn().reducers.markRoomRead({ roomId }); + }, + toggleReaction: (messageId: bigint, emoji: string) => { + return requireConn().reducers.toggleReaction({ messageId, emoji }); + }, + pinMessage: (messageId: bigint) => { + return requireConn().reducers.pinMessage({ messageId }); + }, + unpinMessage: (messageId: bigint) => { + return requireConn().reducers.unpinMessage({ messageId }); + }, + searchMessages: async (roomId: bigint, query: string) => { + return await requireConn().procedures.searchMessages({ roomId, query }); + }, + setActiveRoom: (roomId: bigint | null) => { + activeRoomId = roomId; + emitData(); + }, + heartbeat: () => { + return requireConn().reducers.heartbeat({}); + }, + signup: async args => { + const r = await callJson<{ token: string }>('/auth/password/signup', { + email: args.email, + password: args.password, + name: args.name, + }); + await bindSession(r.token); + }, + login: async args => { + const r = await callJson<{ token: string }>('/auth/password/login', { + email: args.email, + password: args.password, + }); + await bindSession(r.token); + }, + logout: async () => { + const c = conn; + if (c) { + try { + await c.reducers.unlinkConnection({}); + } catch { + /* best-effort disconnect cleanup */ + } + } + await callJson('/auth/logout', {}); + authUser = null; + sessionExpiresAt = undefined; + clearAuthToken(); + clearHeartbeat(); + emitAuth(); + emitData(); + }, + oauthStart: provider => { + window.location.href = `/auth/${provider}/start?redirectTo=/`; + }, + forgotPassword: async email => { + await callJson('/auth/password/forgot', { email }); + }, + requestEmailVerify: async () => { + await callJson('/auth/email/verify-request', {}); + }, + whoami: async () => { + const r = await requireConn().procedures.whoami({}); + meHex = r.senderIdentityHex; + emitAuth(); + return { + userId: r.userId, + senderIdentityHex: r.senderIdentityHex, + }; + }, + setProfile: args => { + return requireConn().reducers.updateProfile({ + name: args.name, + image: args.image, + }); + }, + }; +} + +function typingScopeForRoom(roomId: bigint): string { + return `${PRESENCE_SCOPE_TYPING_PREFIX}${roomId.toString()}`; +} + +function derivePresenceSnapshot() { + if (!conn) + return { + global: [] as PresenceEntry[], + typingByRoom: {} as Record, + }; + const entries = [...conn.db.myPresenceEntries.iter()]; + const global = entries.filter(row => row.scope === PRESENCE_SCOPE_GLOBAL); + const typingByRoom: Record = {}; + for (const row of entries) { + if (!row.scope.startsWith(PRESENCE_SCOPE_TYPING_PREFIX)) continue; + const roomId = row.scope.slice(PRESENCE_SCOPE_TYPING_PREFIX.length); + if (!typingByRoom[roomId]) typingByRoom[roomId] = []; + typingByRoom[roomId].push(row.subject); + } + return { global, typingByRoom }; +} + +async function initializeIdentity(c: DbConnection): Promise { + const me = await c.procedures.whoami({}); + meHex = me.senderIdentityHex; + const snap = derivePresenceSnapshot(); + window.dispatchEvent( + new CustomEvent('chat:me', { + detail: { + meHex, + globalPresence: snap.global, + typingByRoom: snap.typingByRoom, + typingScopeForRoom, + }, + }) + ); + emitAuth(); +} + +async function run(): Promise { + emitConn('connecting'); + if (!config) config = await loadConfig(); + + const c = await connectStdb(config); + conn = c; + reconnectAttempt = 0; + emitConn('connected'); + wireSubscriptions(c); + installApi(); + await initializeIdentity(c); + await restoreSession(); + window.dispatchEvent(new CustomEvent('chat:ready')); +} + +run().catch(err => { + emitConn('error', normalizeError(err)); + scheduleReconnect(); +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/link_connection_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/my_auth_user_table.ts b/spacetime-presence-ts/example/src/codegen/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/types.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/revoke_session_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/types.ts b/spacetime-presence-ts/example/src/codegen/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/types.ts @@ -0,0 +1,137 @@ +// 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 const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/update_profile_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/whoami_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/auth/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/create_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/create_room_reducer.ts new file mode 100644 index 00000000000..1382e2f73fb --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/create_room_reducer.ts @@ -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 { + serverId: __t.u64(), + name: __t.string(), + isPrivate: __t.bool(), + category: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/create_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/create_server_reducer.ts new file mode 100644 index 00000000000..ce493ee8574 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/create_server_reducer.ts @@ -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 { + name: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/delete_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/delete_message_reducer.ts new file mode 100644 index 00000000000..104809a301c --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/delete_message_reducer.ts @@ -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 { + messageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/delete_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/delete_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/delete_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/delete_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/delete_server_reducer.ts new file mode 100644 index 00000000000..8cee31ec781 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/delete_server_reducer.ts @@ -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 { + serverId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/delete_thread_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/delete_thread_message_reducer.ts new file mode 100644 index 00000000000..fa89973d643 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/delete_thread_message_reducer.ts @@ -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 { + threadMessageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/edit_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/edit_message_reducer.ts new file mode 100644 index 00000000000..57eaa7d8f74 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/edit_message_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + messageId: __t.u64(), + content: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/edit_thread_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/edit_thread_message_reducer.ts new file mode 100644 index 00000000000..fe1bd145fa1 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/edit_thread_message_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + threadMessageId: __t.u64(), + content: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/files/types.ts b/spacetime-presence-ts/example/src/codegen/app/files/types.ts new file mode 100644 index 00000000000..a8336b9566f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/files/types.ts @@ -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"; + +export const File = __t.object("File", { + id: __t.u64(), + ownerPathKey: __t.string(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const FileBlob = __t.object("FileBlob", { + fileId: __t.u64(), + bytes: __t.byteArray(), +}); +export type FileBlob = __Infer; + diff --git a/spacetime-presence-ts/example/src/codegen/app/get_attachment_file_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/get_attachment_file_procedure.ts new file mode 100644 index 00000000000..f365f3373db --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/get_attachment_file_procedure.ts @@ -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"; + +import { + AttachmentFileResult, +} from "./types"; + +export const params = { + fileId: __t.u64(), +}; +export const returnType = AttachmentFileResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/get_auth_public_key_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/heartbeat_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/heartbeat_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/heartbeat_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-presence-ts/example/src/codegen/app/index.ts b/spacetime-presence-ts/example/src/codegen/app/index.ts new file mode 100644 index 00000000000..603a3478aac --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/index.ts @@ -0,0 +1,488 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 CreateRoomReducer from "./create_room_reducer"; +import CreateServerReducer from "./create_server_reducer"; +import DeleteMessageReducer from "./delete_message_reducer"; +import DeleteRoomReducer from "./delete_room_reducer"; +import DeleteServerReducer from "./delete_server_reducer"; +import DeleteThreadMessageReducer from "./delete_thread_message_reducer"; +import EditMessageReducer from "./edit_message_reducer"; +import EditThreadMessageReducer from "./edit_thread_message_reducer"; +import HeartbeatReducer from "./heartbeat_reducer"; +import JoinRoomReducer from "./join_room_reducer"; +import JoinServerReducer from "./join_server_reducer"; +import LeaveRoomReducer from "./leave_room_reducer"; +import LeaveServerReducer from "./leave_server_reducer"; +import LinkConnectionReducer from "./link_connection_reducer"; +import MarkRoomReadReducer from "./mark_room_read_reducer"; +import PinMessageReducer from "./pin_message_reducer"; +import RenameRoomReducer from "./rename_room_reducer"; +import RenameServerReducer from "./rename_server_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SendMessageReducer from "./send_message_reducer"; +import SendThreadMessageReducer from "./send_thread_message_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import SetDisplayNameReducer from "./set_display_name_reducer"; +import SetRoomCategoryReducer from "./set_room_category_reducer"; +import SetRoomPrivacyReducer from "./set_room_privacy_reducer"; +import SetStatusReducer from "./set_status_reducer"; +import StartTypingReducer from "./start_typing_reducer"; +import StopTypingReducer from "./stop_typing_reducer"; +import ToggleReactionReducer from "./toggle_reaction_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UnpinMessageReducer from "./unpin_message_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; + +// Import all procedure arg schemas +import * as GetAttachmentFileProcedure from "./get_attachment_file_procedure"; +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as SearchMessagesProcedure from "./search_messages_procedure"; +import * as WhoamiProcedure from "./whoami_procedure"; + +// Import all table schema definitions +import MyAuthUserRow from "./my_auth_user_table"; +import MyChatUsersRow from "./my_chat_users_table"; +import MyMessageThreadsRow from "./my_message_threads_table"; +import MyPresenceEntriesRow from "./my_presence_entries_table"; +import MyRateLimitStatusRow from "./my_rate_limit_status_table"; +import MyRoomAttachmentsRow from "./my_room_attachments_table"; +import MyRoomMembersRow from "./my_room_members_table"; +import MyRoomMessageReactionsRow from "./my_room_message_reactions_table"; +import MyRoomMessagesRow from "./my_room_messages_table"; +import MyRoomReadCursorsRow from "./my_room_read_cursors_table"; +import MyRoomsRow from "./my_rooms_table"; +import MyServerMembersRow from "./my_server_members_table"; +import MyServersRow from "./my_servers_table"; +import MyThreadMessagesRow from "./my_thread_messages_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import RateLimit_RateLimitConfigRow from "./rateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; +import RateLimit_AdminRateLimitBucketsRow from "./rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; +import RateLimit_AddRateLimitAdminReducer from "./rateLimit/add_rate_limit_admin_reducer"; +import RateLimit_ResetBucketsReducer from "./rateLimit/reset_buckets_reducer"; +import RateLimit_UpdateConfigReducer from "./rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; +import * as RateLimit_ConsumeProcedure from "./rateLimit/consume_procedure"; +import * as RateLimit_RunSweepProcedure from "./rateLimit/run_sweep_procedure"; + +/** 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({ + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myChatUsers: __table({ + name: 'my_chat_users', + indexes: [ + ], + constraints: [ + ], + }, MyChatUsersRow), + myMessageThreads: __table({ + name: 'my_message_threads', + indexes: [ + ], + constraints: [ + ], + }, MyMessageThreadsRow), + myPresenceEntries: __table({ + name: 'my_presence_entries', + indexes: [ + ], + constraints: [ + ], + }, MyPresenceEntriesRow), + myRateLimitStatus: __table({ + name: 'my_rate_limit_status', + indexes: [ + ], + constraints: [ + ], + }, MyRateLimitStatusRow), + myRoomAttachments: __table({ + name: 'my_room_attachments', + indexes: [ + ], + constraints: [ + ], + }, MyRoomAttachmentsRow), + myRoomMembers: __table({ + name: 'my_room_members', + indexes: [ + ], + constraints: [ + ], + }, MyRoomMembersRow), + myRoomMessageReactions: __table({ + name: 'my_room_message_reactions', + indexes: [ + ], + constraints: [ + ], + }, MyRoomMessageReactionsRow), + myRoomMessages: __table({ + name: 'my_room_messages', + indexes: [ + ], + constraints: [ + ], + }, MyRoomMessagesRow), + myRoomReadCursors: __table({ + name: 'my_room_read_cursors', + indexes: [ + ], + constraints: [ + ], + }, MyRoomReadCursorsRow), + myRooms: __table({ + name: 'my_rooms', + indexes: [ + ], + constraints: [ + ], + }, MyRoomsRow), + myServerMembers: __table({ + name: 'my_server_members', + indexes: [ + ], + constraints: [ + ], + }, MyServerMembersRow), + myServers: __table({ + name: 'my_servers', + indexes: [ + ], + constraints: [ + ], + }, MyServersRow), + myThreadMessages: __table({ + name: 'my_thread_messages', + indexes: [ + ], + constraints: [ + ], + }, MyThreadMessagesRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "rateLimit.rate_limit_config": __table({ + name: 'rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, RateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), + "rateLimit.admin_rate_limit_buckets": __table({ + name: 'rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, RateLimit_AdminRateLimitBucketsRow), +}); + +/** 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( + __reducerSchema("create_room", CreateRoomReducer), + __reducerSchema("create_server", CreateServerReducer), + __reducerSchema("delete_message", DeleteMessageReducer), + __reducerSchema("delete_room", DeleteRoomReducer), + __reducerSchema("delete_server", DeleteServerReducer), + __reducerSchema("delete_thread_message", DeleteThreadMessageReducer), + __reducerSchema("edit_message", EditMessageReducer), + __reducerSchema("edit_thread_message", EditThreadMessageReducer), + __reducerSchema("heartbeat", HeartbeatReducer), + __reducerSchema("join_room", JoinRoomReducer), + __reducerSchema("join_server", JoinServerReducer), + __reducerSchema("leave_room", LeaveRoomReducer), + __reducerSchema("leave_server", LeaveServerReducer), + __reducerSchema("link_connection", LinkConnectionReducer), + __reducerSchema("mark_room_read", MarkRoomReadReducer), + __reducerSchema("pin_message", PinMessageReducer), + __reducerSchema("rename_room", RenameRoomReducer), + __reducerSchema("rename_server", RenameServerReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("send_message", SendMessageReducer), + __reducerSchema("send_thread_message", SendThreadMessageReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("set_display_name", SetDisplayNameReducer), + __reducerSchema("set_room_category", SetRoomCategoryReducer), + __reducerSchema("set_room_privacy", SetRoomPrivacyReducer), + __reducerSchema("set_status", SetStatusReducer), + __reducerSchema("start_typing", StartTypingReducer), + __reducerSchema("stop_typing", StopTypingReducer), + __reducerSchema("toggle_reaction", ToggleReactionReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("unpin_message", UnpinMessageReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), + __reducerSchema("rateLimit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer), + __reducerSchema("rateLimit.reset_buckets", RateLimit_ResetBucketsReducer), + __reducerSchema("rateLimit.update_config", RateLimit_UpdateConfigReducer), +); + +/** 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("get_attachment_file", GetAttachmentFileProcedure.params, GetAttachmentFileProcedure.returnType), + __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("search_messages", SearchMessagesProcedure.params, SearchMessagesProcedure.returnType), + __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), + __procedureSchema("rateLimit.consume", RateLimit_ConsumeProcedure.params, RateLimit_ConsumeProcedure.returnType), + __procedureSchema("rateLimit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + myAuthUser: __qb.myAuthUser, + myChatUsers: __qb.myChatUsers, + myMessageThreads: __qb.myMessageThreads, + myPresenceEntries: __qb.myPresenceEntries, + myRateLimitStatus: __qb.myRateLimitStatus, + myRoomAttachments: __qb.myRoomAttachments, + myRoomMembers: __qb.myRoomMembers, + myRoomMessageReactions: __qb.myRoomMessageReactions, + myRoomMessages: __qb.myRoomMessages, + myRoomReadCursors: __qb.myRoomReadCursors, + myRooms: __qb.myRooms, + myServerMembers: __qb.myServerMembers, + myServers: __qb.myServers, + myThreadMessages: __qb.myThreadMessages, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, + rateLimit: { + rateLimitConfig: __qb["rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["rateLimit.admin_rate_limit_buckets"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + createRoom: __reducerAccessors.createRoom, + createServer: __reducerAccessors.createServer, + deleteMessage: __reducerAccessors.deleteMessage, + deleteRoom: __reducerAccessors.deleteRoom, + deleteServer: __reducerAccessors.deleteServer, + deleteThreadMessage: __reducerAccessors.deleteThreadMessage, + editMessage: __reducerAccessors.editMessage, + editThreadMessage: __reducerAccessors.editThreadMessage, + heartbeat: __reducerAccessors.heartbeat, + joinRoom: __reducerAccessors.joinRoom, + joinServer: __reducerAccessors.joinServer, + leaveRoom: __reducerAccessors.leaveRoom, + leaveServer: __reducerAccessors.leaveServer, + linkConnection: __reducerAccessors.linkConnection, + markRoomRead: __reducerAccessors.markRoomRead, + pinMessage: __reducerAccessors.pinMessage, + renameRoom: __reducerAccessors.renameRoom, + renameServer: __reducerAccessors.renameServer, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + sendMessage: __reducerAccessors.sendMessage, + sendThreadMessage: __reducerAccessors.sendThreadMessage, + setAuthConfig: __reducerAccessors.setAuthConfig, + setDisplayName: __reducerAccessors.setDisplayName, + setRoomCategory: __reducerAccessors.setRoomCategory, + setRoomPrivacy: __reducerAccessors.setRoomPrivacy, + setStatus: __reducerAccessors.setStatus, + startTyping: __reducerAccessors.startTyping, + stopTyping: __reducerAccessors.stopTyping, + toggleReaction: __reducerAccessors.toggleReaction, + unlinkConnection: __reducerAccessors.unlinkConnection, + unpinMessage: __reducerAccessors.unpinMessage, + updateProfile: __reducerAccessors.updateProfile, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, + rateLimit: { + addRateLimitAdmin: __reducerAccessors["rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["rateLimit.updateConfig"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + getAttachmentFile: __procedureAccessors.getAttachmentFile, + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + listMySessions: __procedureAccessors.listMySessions, + searchMessages: __procedureAccessors.searchMessages, + whoami: __procedureAccessors.whoami, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, + rateLimit: { + consume: __procedureAccessors["rateLimit.consume"], + runSweep: __procedureAccessors["rateLimit.runSweep"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-presence-ts/example/src/codegen/app/join_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/join_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/join_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/join_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/join_server_reducer.ts new file mode 100644 index 00000000000..8cee31ec781 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/join_server_reducer.ts @@ -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 { + serverId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/leave_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/leave_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/leave_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/leave_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/leave_server_reducer.ts new file mode 100644 index 00000000000..8cee31ec781 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/leave_server_reducer.ts @@ -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 { + serverId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/link_connection_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/list_my_sessions_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/mark_room_read_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/mark_room_read_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/mark_room_read_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/my_auth_user_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_chat_users_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_chat_users_table.ts new file mode 100644 index 00000000000..10a1254c677 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_chat_users_table.ts @@ -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 { + ChatUserStatus, +} from "./types"; + + +export default __t.row({ + identity: __t.identity().primaryKey(), + userId: __t.string().name("user_id"), + displayName: __t.string().name("display_name"), + get status() { + return ChatUserStatus; + }, + createdAt: __t.timestamp().name("created_at"), + lastActiveAt: __t.timestamp().name("last_active_at"), + lastMessageAt: __t.timestamp().name("last_message_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_message_threads_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_message_threads_table.ts new file mode 100644 index 00000000000..5d3160c8709 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_message_threads_table.ts @@ -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().primaryKey(), + rootMessageId: __t.u64().name("root_message_id"), + roomId: __t.u64().name("room_id"), + createdBy: __t.identity().name("created_by"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_presence_entries_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_presence_entries_table.ts new file mode 100644 index 00000000000..70af5d56d15 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_presence_entries_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()).name("payload_json"), + joinedAt: __t.timestamp().name("joined_at"), + lastSeenAt: __t.timestamp().name("last_seen_at"), + expiresAt: __t.timestamp().name("expires_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_rate_limit_status_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_rate_limit_status_table.ts new file mode 100644 index 00000000000..00e9df8e608 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_rate_limit_status_table.ts @@ -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({ + scope: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.timestamp().name("reset_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_attachments_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_attachments_table.ts new file mode 100644 index 00000000000..8aa2b599cea --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_room_attachments_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64(), + messageId: __t.u64().name("message_id"), + fileId: __t.u64().name("file_id"), + ownerUserId: __t.string().name("owner_user_id"), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + path: __t.string(), + mimeType: __t.string().name("mime_type"), + size: __t.u64(), + sha256Hex: __t.string().name("sha_256_hex"), + visibility: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_members_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_members_table.ts new file mode 100644 index 00000000000..5f1878a592e --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_room_members_table.ts @@ -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.u64().primaryKey(), + roomId: __t.u64().name("room_id"), + userId: __t.string().name("user_id"), + role: __t.string(), + joinedAt: __t.timestamp().name("joined_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_message_reactions_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_message_reactions_table.ts new file mode 100644 index 00000000000..2d86b514fed --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_room_message_reactions_table.ts @@ -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.u64().primaryKey(), + messageId: __t.u64().name("message_id"), + identity: __t.identity(), + emoji: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_messages_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_messages_table.ts new file mode 100644 index 00000000000..be6f0a662b9 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_room_messages_table.ts @@ -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({ + id: __t.u64().primaryKey(), + roomId: __t.u64().name("room_id"), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp().name("created_at"), + editedAt: __t.option(__t.timestamp()).name("edited_at"), + replyToMessageId: __t.option(__t.u64()).name("reply_to_message_id"), + pinnedAt: __t.option(__t.timestamp()).name("pinned_at"), + pinnedBy: __t.option(__t.identity()).name("pinned_by"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_read_cursors_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_read_cursors_table.ts new file mode 100644 index 00000000000..d52cd7f1251 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_room_read_cursors_table.ts @@ -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.u64().primaryKey(), + roomId: __t.u64().name("room_id"), + identity: __t.identity(), + lastReadMessageId: __t.u64().name("last_read_message_id"), + lastReadAt: __t.timestamp().name("last_read_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_rooms_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_rooms_table.ts new file mode 100644 index 00000000000..a2a98c957a1 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_rooms_table.ts @@ -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({ + id: __t.u64().primaryKey(), + serverId: __t.u64().name("server_id"), + name: __t.string(), + category: __t.option(__t.string()), + createdByUserId: __t.string().name("created_by_user_id"), + createdAt: __t.timestamp().name("created_at"), + isPrivate: __t.bool().name("is_private"), + activityLabel: __t.string().name("activity_label"), + activityScore: __t.u32().name("activity_score"), + lastActivityAt: __t.option(__t.timestamp()).name("last_activity_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_server_members_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_server_members_table.ts new file mode 100644 index 00000000000..2cf02263ab3 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_server_members_table.ts @@ -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.u64().primaryKey(), + serverId: __t.u64().name("server_id"), + userId: __t.string().name("user_id"), + role: __t.string(), + joinedAt: __t.timestamp().name("joined_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_servers_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_servers_table.ts new file mode 100644 index 00000000000..bb9731a93c9 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_servers_table.ts @@ -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({ + id: __t.u64().primaryKey(), + name: __t.string(), + createdByUserId: __t.string().name("created_by_user_id"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_thread_messages_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_thread_messages_table.ts new file mode 100644 index 00000000000..07d885a44b7 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/my_thread_messages_table.ts @@ -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().primaryKey(), + threadId: __t.u64().name("thread_id"), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp().name("created_at"), + editedAt: __t.option(__t.timestamp()).name("edited_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/pin_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/pin_message_reducer.ts new file mode 100644 index 00000000000..104809a301c --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/pin_message_reducer.ts @@ -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 { + messageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/consume_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/types.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rename_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rename_room_reducer.ts new file mode 100644 index 00000000000..8d0f5f1d2de --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rename_room_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + roomId: __t.u64(), + name: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rename_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rename_server_reducer.ts new file mode 100644 index 00000000000..71d58e8cf73 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/rename_server_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + serverId: __t.u64(), + name: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/revoke_my_session_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/revoke_session_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/search_messages_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/search_messages_procedure.ts new file mode 100644 index 00000000000..5226a56b306 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/search_messages_procedure.ts @@ -0,0 +1,21 @@ +// 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 { + Message, +} from "./types"; + +export const params = { + roomId: __t.u64(), + query: __t.string(), +}; +export const returnType = __t.array(Message) \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/send_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/send_message_reducer.ts new file mode 100644 index 00000000000..37fa347fa30 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/send_message_reducer.ts @@ -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 { + AttachmentInput, +} from "./types"; + +export default { + roomId: __t.u64(), + content: __t.string(), + replyToMessageId: __t.option(__t.u64()), + get attachments() { + return __t.array(AttachmentInput); + }, +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/send_thread_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/send_thread_message_reducer.ts new file mode 100644 index 00000000000..f846302477c --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/send_thread_message_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + rootMessageId: __t.u64(), + content: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_auth_config_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_display_name_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_display_name_reducer.ts new file mode 100644 index 00000000000..547493ef073 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/set_display_name_reducer.ts @@ -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 { + displayName: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_room_category_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_room_category_reducer.ts new file mode 100644 index 00000000000..9feeae5be80 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/set_room_category_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + roomId: __t.u64(), + category: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_room_privacy_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_room_privacy_reducer.ts new file mode 100644 index 00000000000..15b49b76a1e --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/set_room_privacy_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + roomId: __t.u64(), + isPrivate: __t.bool(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_status_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_status_reducer.ts new file mode 100644 index 00000000000..15a4e2758f8 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/set_status_reducer.ts @@ -0,0 +1,21 @@ +// 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 { + ChatUserStatus, +} from "./types"; + +export default { + get status() { + return ChatUserStatus; + }, +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/start_typing_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/start_typing_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/start_typing_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/stop_typing_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/stop_typing_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/stop_typing_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/toggle_reaction_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/toggle_reaction_reducer.ts new file mode 100644 index 00000000000..bf62b01c66f --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/toggle_reaction_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + messageId: __t.u64(), + emoji: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/types.ts b/spacetime-presence-ts/example/src/codegen/app/types.ts new file mode 100644 index 00000000000..a77ec4af952 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/types.ts @@ -0,0 +1,296 @@ +// 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 const Attachment = __t.object("Attachment", { + id: __t.u64(), + messageId: __t.u64(), + fileId: __t.u64(), + ownerUserId: __t.string(), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type Attachment = __Infer; + +export const AttachmentFileResult = __t.object("AttachmentFileResult", { + filename: __t.option(__t.string()), + mimeType: __t.string(), + bytes: __t.byteArray(), +}); +export type AttachmentFileResult = __Infer; + +export const AttachmentInput = __t.object("AttachmentInput", { + mimeType: __t.string(), + filename: __t.option(__t.string()), + bytes: __t.byteArray(), +}); +export type AttachmentInput = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const ChatAuthUser = __t.object("ChatAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ChatAuthUser = __Infer; + +export const ChatRateLimitStatus = __t.object("ChatRateLimitStatus", { + scope: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.timestamp(), +}); +export type ChatRateLimitStatus = __Infer; + +export const ChatSweepTick = __t.object("ChatSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type ChatSweepTick = __Infer; + +export const ChatUser = __t.object("ChatUser", { + identity: __t.identity(), + userId: __t.string(), + displayName: __t.string(), + get status() { + return ChatUserStatus; + }, + createdAt: __t.timestamp(), + lastActiveAt: __t.timestamp(), + lastMessageAt: __t.timestamp(), +}); +export type ChatUser = __Infer; + +// The tagged union or sum type for the algebraic type `ChatUserStatus`. +export const ChatUserStatus = __t.enum("ChatUserStatus", { + Online: __t.unit(), + Away: __t.unit(), + Dnd: __t.unit(), + Invisible: __t.unit(), +}); +export type ChatUserStatus = __Infer; + +export const Message = __t.object("Message", { + id: __t.u64(), + roomId: __t.u64(), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp(), + editedAt: __t.option(__t.timestamp()), + replyToMessageId: __t.option(__t.u64()), + pinnedAt: __t.option(__t.timestamp()), + pinnedBy: __t.option(__t.identity()), +}); +export type Message = __Infer; + +export const MessageReaction = __t.object("MessageReaction", { + id: __t.u64(), + messageId: __t.u64(), + identity: __t.identity(), + emoji: __t.string(), + createdAt: __t.timestamp(), +}); +export type MessageReaction = __Infer; + +export const MessageThread = __t.object("MessageThread", { + id: __t.u64(), + rootMessageId: __t.u64(), + roomId: __t.u64(), + createdBy: __t.identity(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type MessageThread = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyChatUsers = __t.object("MyChatUsers", {}); +export type MyChatUsers = __Infer; + +export const MyMessageThreads = __t.object("MyMessageThreads", {}); +export type MyMessageThreads = __Infer; + +export const MyPresenceEntries = __t.object("MyPresenceEntries", {}); +export type MyPresenceEntries = __Infer; + +export const MyRateLimitStatus = __t.object("MyRateLimitStatus", {}); +export type MyRateLimitStatus = __Infer; + +export const MyRoomAttachments = __t.object("MyRoomAttachments", {}); +export type MyRoomAttachments = __Infer; + +export const MyRoomMembers = __t.object("MyRoomMembers", {}); +export type MyRoomMembers = __Infer; + +export const MyRoomMessageReactions = __t.object("MyRoomMessageReactions", {}); +export type MyRoomMessageReactions = __Infer; + +export const MyRoomMessages = __t.object("MyRoomMessages", {}); +export type MyRoomMessages = __Infer; + +export const MyRoomReadCursors = __t.object("MyRoomReadCursors", {}); +export type MyRoomReadCursors = __Infer; + +export const MyRooms = __t.object("MyRooms", {}); +export type MyRooms = __Infer; + +export const MyServerMembers = __t.object("MyServerMembers", {}); +export type MyServerMembers = __Infer; + +export const MyServers = __t.object("MyServers", {}); +export type MyServers = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const MyThreadMessages = __t.object("MyThreadMessages", {}); +export type MyThreadMessages = __Infer; + +export const PresenceConfig = __t.object("PresenceConfig", { + singleton: __t.bool(), + defaultTtlSeconds: __t.u32(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type PresenceConfig = __Infer; + +export const PresenceEntry = __t.object("PresenceEntry", { + key: __t.string(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()), + joinedAt: __t.timestamp(), + lastSeenAt: __t.timestamp(), + expiresAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type PresenceEntry = __Infer; + +export const Room = __t.object("Room", { + id: __t.u64(), + serverId: __t.u64(), + name: __t.string(), + category: __t.option(__t.string()), + createdByUserId: __t.string(), + createdAt: __t.timestamp(), + isPrivate: __t.bool(), + activityLabel: __t.string(), + activityScore: __t.u32(), + lastActivityAt: __t.option(__t.timestamp()), +}); +export type Room = __Infer; + +export const RoomActivityEvent = __t.object("RoomActivityEvent", { + id: __t.u64(), + roomId: __t.u64(), + createdAt: __t.timestamp(), +}); +export type RoomActivityEvent = __Infer; + +export const RoomAttachment = __t.object("RoomAttachment", { + id: __t.u64(), + messageId: __t.u64(), + fileId: __t.u64(), + ownerUserId: __t.string(), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + path: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type RoomAttachment = __Infer; + +export const RoomMember = __t.object("RoomMember", { + id: __t.u64(), + roomId: __t.u64(), + userId: __t.string(), + role: __t.string(), + joinedAt: __t.timestamp(), +}); +export type RoomMember = __Infer; + +export const RoomReadCursor = __t.object("RoomReadCursor", { + id: __t.u64(), + roomId: __t.u64(), + identity: __t.identity(), + lastReadMessageId: __t.u64(), + lastReadAt: __t.timestamp(), +}); +export type RoomReadCursor = __Infer; + +export const Server = __t.object("Server", { + id: __t.u64(), + name: __t.string(), + createdByUserId: __t.string(), + createdAt: __t.timestamp(), +}); +export type Server = __Infer; + +export const ServerMember = __t.object("ServerMember", { + id: __t.u64(), + serverId: __t.u64(), + userId: __t.string(), + role: __t.string(), + joinedAt: __t.timestamp(), +}); +export type ServerMember = __Infer; + +export const ThreadMessage = __t.object("ThreadMessage", { + id: __t.u64(), + threadId: __t.u64(), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp(), + editedAt: __t.option(__t.timestamp()), +}); +export type ThreadMessage = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), + userDisplayName: __t.option(__t.string()), + userStatus: __t.option(__t.string()), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-presence-ts/example/src/codegen/app/types/procedures.ts b/spacetime-presence-ts/example/src/codegen/app/types/procedures.ts new file mode 100644 index 00000000000..f70528b40b1 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/types/procedures.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as GetAttachmentFileProcedure from "../get_attachment_file_procedure"; +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as SearchMessagesProcedure from "../search_messages_procedure"; +import * as WhoamiProcedure from "../whoami_procedure"; + +export type GetAttachmentFileArgs = __Infer; +export type GetAttachmentFileResult = __Infer; +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type SearchMessagesArgs = __Infer; +export type SearchMessagesResult = __Infer; +export type WhoamiArgs = __Infer; +export type WhoamiResult = __Infer; + diff --git a/spacetime-presence-ts/example/src/codegen/app/types/reducers.ts b/spacetime-presence-ts/example/src/codegen/app/types/reducers.ts new file mode 100644 index 00000000000..16775962995 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/types/reducers.ts @@ -0,0 +1,76 @@ +// 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 +import CreateRoomReducer from "../create_room_reducer"; +import CreateServerReducer from "../create_server_reducer"; +import DeleteMessageReducer from "../delete_message_reducer"; +import DeleteRoomReducer from "../delete_room_reducer"; +import DeleteServerReducer from "../delete_server_reducer"; +import DeleteThreadMessageReducer from "../delete_thread_message_reducer"; +import EditMessageReducer from "../edit_message_reducer"; +import EditThreadMessageReducer from "../edit_thread_message_reducer"; +import HeartbeatReducer from "../heartbeat_reducer"; +import JoinRoomReducer from "../join_room_reducer"; +import JoinServerReducer from "../join_server_reducer"; +import LeaveRoomReducer from "../leave_room_reducer"; +import LeaveServerReducer from "../leave_server_reducer"; +import LinkConnectionReducer from "../link_connection_reducer"; +import MarkRoomReadReducer from "../mark_room_read_reducer"; +import PinMessageReducer from "../pin_message_reducer"; +import RenameRoomReducer from "../rename_room_reducer"; +import RenameServerReducer from "../rename_server_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SendMessageReducer from "../send_message_reducer"; +import SendThreadMessageReducer from "../send_thread_message_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import SetDisplayNameReducer from "../set_display_name_reducer"; +import SetRoomCategoryReducer from "../set_room_category_reducer"; +import SetRoomPrivacyReducer from "../set_room_privacy_reducer"; +import SetStatusReducer from "../set_status_reducer"; +import StartTypingReducer from "../start_typing_reducer"; +import StopTypingReducer from "../stop_typing_reducer"; +import ToggleReactionReducer from "../toggle_reaction_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UnpinMessageReducer from "../unpin_message_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; + +export type CreateRoomParams = __Infer; +export type CreateServerParams = __Infer; +export type DeleteMessageParams = __Infer; +export type DeleteRoomParams = __Infer; +export type DeleteServerParams = __Infer; +export type DeleteThreadMessageParams = __Infer; +export type EditMessageParams = __Infer; +export type EditThreadMessageParams = __Infer; +export type HeartbeatParams = __Infer; +export type JoinRoomParams = __Infer; +export type JoinServerParams = __Infer; +export type LeaveRoomParams = __Infer; +export type LeaveServerParams = __Infer; +export type LinkConnectionParams = __Infer; +export type MarkRoomReadParams = __Infer; +export type PinMessageParams = __Infer; +export type RenameRoomParams = __Infer; +export type RenameServerParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SendMessageParams = __Infer; +export type SendThreadMessageParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type SetDisplayNameParams = __Infer; +export type SetRoomCategoryParams = __Infer; +export type SetRoomPrivacyParams = __Infer; +export type SetStatusParams = __Infer; +export type StartTypingParams = __Infer; +export type StopTypingParams = __Infer; +export type ToggleReactionParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UnpinMessageParams = __Infer; +export type UpdateProfileParams = __Infer; + diff --git a/spacetime-presence-ts/example/src/codegen/app/unlink_connection_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-presence-ts/example/src/codegen/app/unpin_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/unpin_message_reducer.ts new file mode 100644 index 00000000000..104809a301c --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/unpin_message_reducer.ts @@ -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 { + messageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/update_profile_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/codegen/app/whoami_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-presence-ts/example/src/codegen/app/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-presence-ts/example/tsconfig.json b/spacetime-presence-ts/example/tsconfig.json new file mode 100644 index 00000000000..eee538c9afe --- /dev/null +++ b/spacetime-presence-ts/example/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-presence-ts/package.json b/spacetime-presence-ts/package.json new file mode 100644 index 00000000000..5f0707335d3 --- /dev/null +++ b/spacetime-presence-ts/package.json @@ -0,0 +1,68 @@ +{ + "name": "@spacetimedb/presence", + "description": "Presence, heartbeat, activity, and expiration helpers for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./tables": { + "types": "./src/tables.ts", + "default": "./src/tables.ts" + }, + "./presence": { + "types": "./src/presence.ts", + "default": "./src/presence.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-presence-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-presence-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "presence", + "realtime", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-presence-ts/scripts/test.ts b/spacetime-presence-ts/scripts/test.ts new file mode 100644 index 00000000000..cb9b0aceb95 --- /dev/null +++ b/spacetime-presence-ts/scripts/test.ts @@ -0,0 +1,166 @@ +import { Timestamp } from 'spacetimedb'; +import { + buildPresenceKey, + removePresence, + sweepPresence, + touchPresence, + upsertPresence, + type PresenceEntryRow, +} from '../src/presence.ts'; + +let pass = 0; +let fail = 0; + +function assert(cond: boolean, name: string, detail = ''): void { + if (cond) { + pass++; + process.stdout.write(` ok ${name}\n`); + } else { + fail++; + process.stdout.write( + ` FAIL ${name}${detail ? `\n ${detail}` : ''}\n` + ); + } +} + +function makeTx(nowMicros = 0n) { + const rows = new Map(); + const tx = { + timestamp: new Timestamp(nowMicros), + db: { + presenceEntry: { + key: { + find: (key: string) => rows.get(key), + update: (row: PresenceEntryRow) => rows.set(row.key, row), + }, + insert: (row: PresenceEntryRow) => rows.set(row.key, row), + delete: (row: PresenceEntryRow) => rows.delete(row.key), + expiresAt: { + filter: function* () { + yield* [...rows.values()].sort((a, b) => + a.expiresAt.microsSinceUnixEpoch < + b.expiresAt.microsSinceUnixEpoch + ? -1 + : 1 + ); + }, + }, + }, + }, + rows, + }; + return tx; +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'fresh-a', + ttlSeconds: 100, + }); + upsertPresence(tx, { + scope: 'room:1', + subject: 'fresh-b', + ttlSeconds: 100, + }); + upsertPresence(tx, { + scope: 'room:1', + subject: 'expired', + ttlSeconds: 1, + }); + tx.timestamp = new Timestamp(2_000_000n); + const deleted = sweepPresence(tx, tx.db.presenceEntry.expiresAt.filter(), 2); + assert(deleted === 1, 'sweep reaches expired rows beyond fresh inserts'); + assert( + ![...tx.rows.values()].some(row => row.subject === 'expired'), + 'indexed sweep removes the expired row' + ); +} + +process.stdout.write('\npresence submodule\n'); + +assert( + buildPresenceKey('room::one', 'user') !== + buildPresenceKey('room', 'one::user'), + 'compound keys cannot collide through delimiters' +); + +{ + const tx = makeTx(); + const row = upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + status: 'online', + ttlSeconds: 30, + }); + assert(row.scope === 'room:1', 'inserts row'); + assert(tx.rows.size === 1, 'row count 1 after insert'); +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + status: 'away', + activity: 'editing', + payloadJson: '{"cursor":4}', + ttlSeconds: 30, + }); + tx.timestamp = new Timestamp(10_000_000n); + const row = touchPresence(tx, 'room:1', 'user:alice', 30); + assert( + row.status === 'away' && + row.activity === 'editing' && + row.payloadJson === '{"cursor":4}', + 'touch preserves presence metadata' + ); +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + status: 'online', + ttlSeconds: 30, + }); + tx.timestamp = new Timestamp(10_000_000n); + const row = upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + status: 'away', + ttlSeconds: 30, + }); + assert(row.status === 'away', 'upsert updates status'); + assert(tx.rows.size === 1, 'upsert keeps one row'); +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + ttlSeconds: 1, + }); + tx.timestamp = new Timestamp(2_000_000n); + const deleted = sweepPresence(tx, tx.db.presenceEntry.expiresAt.filter()); + assert(deleted === 1, 'sweep removes expired row'); + assert(tx.rows.size === 0, 'rows empty after sweep'); +} + +{ + const tx = makeTx(); + upsertPresence(tx, { + scope: 'room:1', + subject: 'user:alice', + ttlSeconds: 10, + }); + const removed = removePresence(tx, 'room:1', 'user:alice'); + assert(removed, 'removePresence returns true for existing row'); + assert(tx.rows.size === 0, 'removePresence deletes row'); +} + +process.stdout.write(`\n${pass} passed, ${fail} failed\n`); +process.exit(fail === 0 ? 0 : 1); diff --git a/spacetime-presence-ts/spacetimedb/package.json b/spacetime-presence-ts/spacetimedb/package.json new file mode 100644 index 00000000000..c1b056b0161 --- /dev/null +++ b/spacetime-presence-ts/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-presence-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-presence", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-presence" + }, + "dependencies": { + "@spacetimedb/presence": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-presence-ts/spacetimedb/src/index.ts b/spacetime-presence-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..ddd02a77d65 --- /dev/null +++ b/spacetime-presence-ts/spacetimedb/src/index.ts @@ -0,0 +1,2 @@ +export { default } from '../../src/mounted/index'; +export * from '../../src/mounted/index'; diff --git a/spacetime-presence-ts/spacetimedb/tsconfig.json b/spacetime-presence-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..8d8f9b03455 --- /dev/null +++ b/spacetime-presence-ts/spacetimedb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/spacetime-presence-ts/src/index.ts b/spacetime-presence-ts/src/index.ts new file mode 100644 index 00000000000..3cfc18e30c2 --- /dev/null +++ b/spacetime-presence-ts/src/index.ts @@ -0,0 +1,30 @@ +export { + presenceEntryRow, + presenceConfigRow, + presenceSweepTickRow, + createPresenceEntryTable, + createPresenceConfigTable, + presenceEntryTable, + presenceConfigTable, + presenceTables, +} from './tables'; + +export { + DEFAULT_PRESENCE_TTL_SECONDS, + DEFAULT_PRESENCE_SWEEP_BATCH, + DEFAULT_PRESENCE_STATUS, + buildPresenceKey, + installPresenceConfig, + upsertPresence, + touchPresence, + removePresence, + sweepPresence, + resolvePresenceSweepBatch, + runPresenceSweep, + type PresenceConfigCtxLike, + type PresenceEntryRow, + type PresenceSweepCtxLike, + type PresenceTxLike, + type PresenceInstallOpts, + type PresenceUpsertOpts, +} from './presence'; diff --git a/spacetime-presence-ts/src/mounted/index.ts b/spacetime-presence-ts/src/mounted/index.ts new file mode 100644 index 00000000000..3710963b6ca --- /dev/null +++ b/spacetime-presence-ts/src/mounted/index.ts @@ -0,0 +1,250 @@ +import { Timestamp, type Identity } from 'spacetimedb'; +import { installPresence } from './install'; +import { + schema, + table, + t, + Range, + SenderError, + type InferSchema, + type ReducerCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { + DEFAULT_PRESENCE_SWEEP_BATCH, + DEFAULT_PRESENCE_STATUS, + installPresenceConfig, + removePresence, + runPresenceSweep, + sweepPresence, + upsertPresence, +} from '../index'; + +const presenceEntry = table( + { name: 'presence_entry', public: true }, + { + key: t.string().primaryKey(), + scope: t.string().index(), + subject: t.string().index(), + status: t.string().index(), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + joinedAt: t.timestamp().index(), + lastSeenAt: t.timestamp().index(), + expiresAt: t.timestamp().index(), + updatedAt: t.timestamp(), + } +); + +const presenceConfig = table( + { name: 'presence_config', public: true }, + { + singleton: t.bool().primaryKey(), + defaultTtlSeconds: t.u32(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), + } +); + +const presenceAdminIdentity = table( + { name: 'presence_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +const presenceSweepTick = table( + { name: 'presence_sweep_tick', scheduled: (): any => presence_sweep }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + presenceEntry, + presenceConfig, + presenceAdminIdentity, + presenceSweepTick, +}); +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx; + +const heartbeatResult = t.object('PresenceHeartbeatResult', { + scope: t.string(), + subject: t.string(), + status: t.string(), + expiresAt: t.timestamp(), +}); + +const DEFAULT_SCOPE = 'presence.global'; + +function takeRows(rows: Iterable, limit = 1000): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +function identityHex(identity: Identity): string { + return identity.toHexString(); +} + +// Subject is always the sender's Identity, never a caller-supplied value. +function buildSubject(ctx: { sender: Identity }): string { + return identityHex(ctx.sender); +} + +function sanitizeScope(scope: string | undefined): string { + const out = (scope ?? DEFAULT_SCOPE).trim(); + if (out.length === 0) throw new SenderError('presence.invalid_scope'); + return out; +} + +function isAdmin(ctx: ViewCtx): boolean { + return ctx.db.presenceAdminIdentity.identity.find(ctx.sender) != null; +} + +function requireAdmin(ctx: Tx): void { + if (ctx.db.presenceAdminIdentity.identity.find(ctx.sender) == null) { + throw new SenderError('presence.not_authorized'); + } +} + +function toU32(name: string, value: number): number { + if (!Number.isInteger(value) || value <= 0 || value > 0xffff_ffff) { + throw new SenderError(`presence.invalid_${name}`); + } + return value; +} + +// Fresh publishes seed the publishing owner as admin, install config, and start the expiry sweeper. +export const init = spacetimedb.init(ctx => { + installPresence(ctx); +}); + +export const heartbeat = spacetimedb.procedure( + { + scope: t.option(t.string()), + status: t.option(t.string()), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + ttlSeconds: t.option(t.u32()), + }, + heartbeatResult, + (ctx, args) => { + const scope = sanitizeScope(args.scope); + const subject = buildSubject(ctx); + const ttlSeconds = + args.ttlSeconds === undefined + ? undefined + : toU32('ttl_seconds', Number(args.ttlSeconds)); + + let out: { + scope: string; + subject: string; + status: string; + expiresAt: Timestamp; + } | null = null; + ctx.withTx(tx => { + const row = upsertPresence(tx, { + scope, + subject, + status: args.status ?? DEFAULT_PRESENCE_STATUS, + activity: args.activity, + payloadJson: args.payloadJson, + ttlSeconds, + }); + out = { + scope: row.scope, + subject: row.subject, + status: row.status, + expiresAt: row.expiresAt, + }; + }); + if (!out) throw new SenderError('presence.heartbeat_tx_failed'); + return out; + } +); + +export const clear_presence = spacetimedb.reducer( + { scope: t.option(t.string()) }, + (ctx, args) => { + const scope = sanitizeScope(args.scope); + const subject = buildSubject(ctx); + const tx: Tx = ctx; + removePresence(tx, scope, subject); + } +); + +export const run_sweep = spacetimedb.procedure( + { maxRows: t.option(t.u32()) }, + t.u32(), + (ctx, args) => { + const maxRows = + args.maxRows === undefined + ? undefined + : toU32('sweep_batch', Number(args.maxRows)); + let deleted = 0; + ctx.withTx(tx => { + deleted = sweepPresence( + tx, + tx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: tx.timestamp }) + ), + maxRows ?? DEFAULT_PRESENCE_SWEEP_BATCH + ); + }); + return deleted; + } +); + +export const add_presence_admin = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + if (ctx.db.presenceAdminIdentity.identity.find(args.identity) == null) { + ctx.db.presenceAdminIdentity.insert({ + identity: args.identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + } +); + +export const update_config = spacetimedb.reducer( + { defaultTtlSeconds: t.u32(), sweepBatch: t.u32() }, + (ctx, args) => { + requireAdmin(ctx); + installPresenceConfig(ctx, { + defaultTtlSeconds: toU32( + 'default_ttl_seconds', + Number(args.defaultTtlSeconds) + ), + sweepBatch: toU32('sweep_batch', Number(args.sweepBatch)), + }); + } +); + +export const presenceEntriesAdmin = spacetimedb.view( + { name: 'presence_entries_admin', public: true }, + t.array(presenceEntry.rowType), + ctx => (isAdmin(ctx) ? takeRows(ctx.db.presenceEntry.iter()) : []) +); + +export const presence_sweep = spacetimedb.reducer( + { arg: presenceSweepTick.rowType }, + (ctx, _args) => { + runPresenceSweep( + ctx, + ctx.db.presenceEntry.expiresAt.filter( + new Range(undefined, { tag: 'included', value: ctx.timestamp }) + ) + ); + } +); diff --git a/spacetime-presence-ts/src/mounted/install.ts b/spacetime-presence-ts/src/mounted/install.ts new file mode 100644 index 00000000000..61b4280e5e2 --- /dev/null +++ b/spacetime-presence-ts/src/mounted/install.ts @@ -0,0 +1,33 @@ +import { ScheduleAt } from 'spacetimedb'; +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import { + DEFAULT_PRESENCE_SWEEP_BATCH, + DEFAULT_PRESENCE_TTL_SECONDS, + installPresenceConfig, +} from '@spacetimedb/presence'; +import type spacetimedb from './index'; + +const ONE_SECOND_MICROS = 1_000_000n; +const SWEEP_INTERVAL_SECONDS = 10n; + +type Schema = InferSchema; +type InstallCtx = ReducerCtx; + +export function installPresence(ctx: InstallCtx) { + if (ctx.db.presenceAdminIdentity.identity.find(ctx.sender) == null) { + ctx.db.presenceAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + installPresenceConfig(ctx, { + defaultTtlSeconds: DEFAULT_PRESENCE_TTL_SECONDS, + sweepBatch: DEFAULT_PRESENCE_SWEEP_BATCH, + }); + ctx.db.presenceSweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval( + SWEEP_INTERVAL_SECONDS * ONE_SECOND_MICROS + ), + }); +} diff --git a/spacetime-presence-ts/src/presence.ts b/spacetime-presence-ts/src/presence.ts new file mode 100644 index 00000000000..19780612bd7 --- /dev/null +++ b/spacetime-presence-ts/src/presence.ts @@ -0,0 +1,263 @@ +import { Timestamp } from 'spacetimedb'; + +const ONE_SECOND_MICROS = 1_000_000n; +const U32_MAX = 0xffff_ffff; + +export const DEFAULT_PRESENCE_TTL_SECONDS = 30; +export const DEFAULT_PRESENCE_SWEEP_BATCH = 500; +export const DEFAULT_PRESENCE_STATUS = 'online'; +const MAX_SCOPE_LENGTH = 128; +const MAX_SUBJECT_LENGTH = 256; +const MAX_STATUS_LENGTH = 64; +const MAX_ACTIVITY_LENGTH = 256; +const MAX_PAYLOAD_LENGTH = 64 * 1024; +const MAX_TTL_SECONDS = 3600; + +export interface PresenceEntryRow { + key: string; + scope: string; + subject: string; + status: string; + activity: string | undefined; + payloadJson: string | undefined; + joinedAt: Timestamp; + lastSeenAt: Timestamp; + expiresAt: Timestamp; + updatedAt: Timestamp; +} + +interface PresenceConfigRow { + singleton: boolean; + defaultTtlSeconds: number; + sweepBatch: number; + updatedAt: Timestamp; +} + +export interface PresenceTxLike { + timestamp: Timestamp; + db: { + presenceEntry: { + key: { + find(key: string): PresenceEntryRow | null | undefined; + update(row: PresenceEntryRow): void; + }; + insert(row: PresenceEntryRow): void; + delete(row: PresenceEntryRow): void; + }; + }; +} + +export interface PresenceConfigCtxLike { + timestamp: Timestamp; + db: { + presenceConfig: { + singleton: { + find(key: boolean): PresenceConfigRow | null | undefined; + update(row: PresenceConfigRow): void; + }; + insert(row: PresenceConfigRow): void; + }; + }; +} + +export interface PresenceSweepCtxLike extends PresenceTxLike { + db: PresenceTxLike['db'] & { + presenceConfig: { + singleton: { + find(key: boolean): PresenceConfigRow | null | undefined; + }; + }; + }; +} + +export interface PresenceUpsertOpts { + scope: string; + subject: string; + status?: string; + activity?: string; + payloadJson?: string; + ttlSeconds?: number; +} + +export interface PresenceInstallOpts { + defaultTtlSeconds?: number; + sweepBatch?: number; +} + +function assertPositiveU32(name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0 || value > U32_MAX) { + throw new Error(`presence.invalid_${name}`); + } +} + +function sanitize(name: string, value: string): string { + const out = value.trim(); + if (out.length === 0) throw new Error(`presence.invalid_${name}`); + const maxLength = name === 'scope' ? MAX_SCOPE_LENGTH : MAX_SUBJECT_LENGTH; + if (out.length > maxLength) throw new Error(`presence.invalid_${name}`); + return out; +} + +function plusSeconds(ts: Timestamp, seconds: number): Timestamp { + return new Timestamp( + (ts.microsSinceUnixEpoch as bigint) + BigInt(seconds) * ONE_SECOND_MICROS + ); +} + +export function buildPresenceKey(scope: string, subject: string): string { + const normalizedScope = sanitize('scope', scope); + const normalizedSubject = sanitize('subject', subject); + return `${normalizedScope.length}:${normalizedScope}${normalizedSubject.length}:${normalizedSubject}`; +} + +export function installPresenceConfig( + ctx: PresenceConfigCtxLike, + opts?: PresenceInstallOpts +): void { + const defaultTtlSeconds = + opts?.defaultTtlSeconds ?? DEFAULT_PRESENCE_TTL_SECONDS; + const sweepBatch = opts?.sweepBatch ?? DEFAULT_PRESENCE_SWEEP_BATCH; + assertPositiveU32('default_ttl_seconds', defaultTtlSeconds); + assertPositiveU32('sweep_batch', sweepBatch); + + const existing = ctx.db.presenceConfig.singleton.find(true); + if (!existing) { + ctx.db.presenceConfig.insert({ + singleton: true, + defaultTtlSeconds, + sweepBatch, + updatedAt: ctx.timestamp, + }); + return; + } +} + +export function upsertPresence( + tx: PresenceTxLike, + opts: PresenceUpsertOpts +): PresenceEntryRow { + const scope = sanitize('scope', opts.scope); + const subject = sanitize('subject', opts.subject); + const key = buildPresenceKey(scope, subject); + const ttlSeconds = opts.ttlSeconds ?? DEFAULT_PRESENCE_TTL_SECONDS; + assertPositiveU32('ttl_seconds', ttlSeconds); + if (ttlSeconds > MAX_TTL_SECONDS) + throw new Error('presence.invalid_ttl_seconds'); + + const status = (opts.status ?? DEFAULT_PRESENCE_STATUS).trim(); + if (status.length === 0 || status.length > MAX_STATUS_LENGTH) { + throw new Error('presence.invalid_status'); + } + const activity = opts.activity?.trim() || undefined; + if ((activity?.length ?? 0) > MAX_ACTIVITY_LENGTH) { + throw new Error('presence.invalid_activity'); + } + const payloadJson = opts.payloadJson?.trim() || undefined; + if ((payloadJson?.length ?? 0) > MAX_PAYLOAD_LENGTH) { + throw new Error('presence.invalid_payload'); + } + + const now = tx.timestamp; + const expiresAt = plusSeconds(now, ttlSeconds); + const existing = tx.db.presenceEntry.key.find(key); + + if (!existing) { + const inserted: PresenceEntryRow = { + key, + scope, + subject, + status, + activity, + payloadJson, + joinedAt: now, + lastSeenAt: now, + expiresAt, + updatedAt: now, + }; + tx.db.presenceEntry.insert(inserted); + return inserted; + } + + const updated: PresenceEntryRow = { + ...existing, + scope, + subject, + status, + activity, + payloadJson, + lastSeenAt: now, + expiresAt, + updatedAt: now, + }; + tx.db.presenceEntry.key.update(updated); + return updated; +} + +export function touchPresence( + tx: PresenceTxLike, + scope: string, + subject: string, + ttlSeconds = DEFAULT_PRESENCE_TTL_SECONDS +): PresenceEntryRow { + const key = buildPresenceKey(scope, subject); + const existing = tx.db.presenceEntry.key.find(key); + if (!existing) return upsertPresence(tx, { scope, subject, ttlSeconds }); + assertPositiveU32('ttl_seconds', ttlSeconds); + if (ttlSeconds > MAX_TTL_SECONDS) + throw new Error('presence.invalid_ttl_seconds'); + const now = tx.timestamp; + const updated = { + ...existing, + lastSeenAt: now, + expiresAt: plusSeconds(now, ttlSeconds), + updatedAt: now, + }; + tx.db.presenceEntry.key.update(updated); + return updated; +} + +export function removePresence( + tx: PresenceTxLike, + scope: string, + subject: string +): boolean { + const key = buildPresenceKey(scope, subject); + const existing = tx.db.presenceEntry.key.find(key); + if (!existing) return false; + tx.db.presenceEntry.delete(existing); + return true; +} + +export function sweepPresence( + tx: PresenceTxLike, + expiredRows: Iterable, + maxRows = DEFAULT_PRESENCE_SWEEP_BATCH +): number { + assertPositiveU32('sweep_batch', maxRows); + const nowMicros = tx.timestamp.microsSinceUnixEpoch as bigint; + let deleted = 0; + for (const row of expiredRows) { + if (deleted >= maxRows) break; + if ((row.expiresAt.microsSinceUnixEpoch as bigint) > nowMicros) break; + tx.db.presenceEntry.delete(row); + deleted++; + } + return deleted; +} + +export function resolvePresenceSweepBatch( + ctx: PresenceSweepCtxLike, + fallback = DEFAULT_PRESENCE_SWEEP_BATCH +): number { + const cfg = ctx.db.presenceConfig.singleton.find(true); + const value = Number(cfg?.sweepBatch ?? fallback); + return value > 0 ? value : fallback; +} + +export function runPresenceSweep( + ctx: PresenceSweepCtxLike, + expiredRows: Iterable +): number { + const batch = resolvePresenceSweepBatch(ctx); + return sweepPresence(ctx, expiredRows, batch); +} diff --git a/spacetime-presence-ts/src/submodule.ts b/spacetime-presence-ts/src/submodule.ts new file mode 100644 index 00000000000..193195aae6a --- /dev/null +++ b/spacetime-presence-ts/src/submodule.ts @@ -0,0 +1,11 @@ +export { default } from './mounted/index'; +export { installPresence } from './mounted/install'; +export { + add_presence_admin, + clear_presence, + heartbeat, + presenceEntriesAdmin, + presence_sweep, + run_sweep, + update_config, +} from './mounted/index'; diff --git a/spacetime-presence-ts/src/tables.ts b/spacetime-presence-ts/src/tables.ts new file mode 100644 index 00000000000..09b9275435d --- /dev/null +++ b/spacetime-presence-ts/src/tables.ts @@ -0,0 +1,60 @@ +import { table, t } from 'spacetimedb/server'; + +export const presenceEntryRow = { + key: t.string().primaryKey(), + scope: t.string().index(), + subject: t.string().index(), + status: t.string().index(), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + joinedAt: t.timestamp().index(), + lastSeenAt: t.timestamp().index(), + expiresAt: t.timestamp().index(), + updatedAt: t.timestamp(), +}; + +export const presenceConfigRow = { + singleton: t.bool().primaryKey(), + defaultTtlSeconds: t.u32(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), +}; + +export const presenceSweepTickRow = { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), +}; + +export function createPresenceEntryTable(options?: { + name?: string; + public?: boolean; +}) { + return table( + { + name: options?.name ?? 'presence_entry', + public: options?.public ?? false, + }, + presenceEntryRow + ); +} + +export function createPresenceConfigTable(options?: { + name?: string; + public?: boolean; +}) { + return table( + { + name: options?.name ?? 'presence_config', + public: options?.public ?? false, + }, + presenceConfigRow + ); +} + +export const presenceEntryTable = createPresenceEntryTable(); +export const presenceConfigTable = createPresenceConfigTable(); + +export const presenceTables = { + presenceEntry: presenceEntryTable, + presenceConfig: presenceConfigTable, +}; diff --git a/spacetime-presence-ts/tsconfig.json b/spacetime-presence-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-presence-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-rate-limit-ts/LICENSE.txt b/spacetime-rate-limit-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-rate-limit-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-rate-limit-ts/README.md b/spacetime-rate-limit-ts/README.md new file mode 100644 index 00000000000..c29d077a3a6 --- /dev/null +++ b/spacetime-rate-limit-ts/README.md @@ -0,0 +1,151 @@ +# @spacetimedb/rate-limit + +Fixed-window rate limiter submodule for SpacetimeDB TypeScript modules. + +## Install + +```bash +npm install @spacetimedb/rate-limit spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +This package gives you: + +- a mountable `./submodule` with submodule-owned bucket/config/admin tables +- standalone helper functions for direct host integration +- bounded sweep helpers for expired buckets +- admin-gated procedures for diagnostics and maintenance + +## Usage + +### Integrate into an application + +Mount the namespace, install its scheduled cleanup and admin state, then call +`consume` from the host operation before performing the protected action: + +```ts +import { schema, SenderError, t, table } from 'spacetimedb/server'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; + +const post = table( + { name: 'post', public: true }, + { + id: t.u64().primaryKey().autoInc(), + author: t.identity(), + body: t.string(), + createdAt: t.timestamp(), + } +); + +const spacetimedb = schema({ + rateLimit, + post, +}); + +export const init = spacetimedb.init(ctx => { + rateLimit.installRateLimit(ctx.as.rateLimit); +}); + +export default spacetimedb; +``` + +Host procedures should call the standalone policy helper inside the same +transaction as the protected write. Derive the actor key from trusted request +or session state: + +```ts +export const create_post = spacetimedb.procedure( + { body: t.string() }, + t.unit(), + (ctx, args) => { + ctx.withTx(tx => { + const scope = 'post.create'; + const actor = ctx.sender.toHexString(); + const result = rateLimit.consumeRateLimit(tx.as.rateLimit, { + key: rateLimit.buildRateLimitKey(scope, actor), + scope, + limit: 10, + windowSeconds: 60, + }); + if (!result.allowed) throw new SenderError('rate_limit.blocked'); + const body = args.body.trim(); + if (!body) throw new SenderError('post.empty'); + tx.db.post.insert({ + id: 0n, + author: ctx.sender, + body, + createdAt: ctx.timestamp, + }); + }); + return {}; + } +); +``` + +The generated client calls the product-facing operation: + +```ts +await conn.procedures.createPost({ body: 'Hello' }); +``` + +The submodule owns these tables under the mounted namespace: + +- `rateLimit.rate_limit_bucket` +- `rateLimit.rate_limit_admin_identity` +- `rateLimit.rate_limit_config` +- `rateLimit.rate_limit_sweep_tick` + +It also exposes the public admin view `rateLimit.admin_rate_limit_buckets`. +The view returns at most 1,000 rows and returns an empty set to non-admins. + +## API + +The root package exports lower-level helpers for custom standalone +implementations: + +- `consumeRateLimit` +- `buildRateLimitKey` +- `installRateLimitState` +- `runRateLimitSweep` +- `sweepRateLimits` +- `resolveRateLimitSweepBatch` + +The mounted `consume`, `runSweep`, and `reset_buckets` operations are admin-only. +Application-facing operations should enforce a fixed policy in host code and use +`consumeRateLimit` as shown above. `reset_buckets({ maxRows })` removes +1,000 rows by default and accepts a maximum of 10,000 per call, so destructive +maintenance remains bounded. + +Those helpers expect the same submodule table shape. Namespace-aware modules +use `@spacetimedb/rate-limit/submodule`. + +Package entrypoints: + +- `@spacetimedb/rate-limit/submodule` supplies the mounted namespace, + maintenance operations, and host helpers. +- `@spacetimedb/rate-limit/limit` exports standalone policy functions. +- `@spacetimedb/rate-limit` re-exports the supported helper surface. + +See the +[Powerhouse host module](./example/spacetimedb/) +for per-action policies, caller-visible status, and admin controls. + +## Exported Defaults + +- `DEFAULT_SWEEP_BATCH = 500` +- `DEFAULT_SWEEP_INTERVAL_SECONDS = 30n` + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-rate-limit-ts/example/.env.example b/spacetime-rate-limit-ts/example/.env.example new file mode 100644 index 00000000000..0dd610719b4 --- /dev/null +++ b/spacetime-rate-limit-ts/example/.env.example @@ -0,0 +1,4 @@ +HOST=127.0.0.1 +PORT=8792 +STDB_URI=ws://127.0.0.1:3000 +STDB_APP_DATABASE=spacetime-rate-limit-example diff --git a/spacetime-rate-limit-ts/example/README.md b/spacetime-rate-limit-ts/example/README.md new file mode 100644 index 00000000000..15b2470711b --- /dev/null +++ b/spacetime-rate-limit-ts/example/README.md @@ -0,0 +1,178 @@ +# Powerhouse rate-limit example + +Powerhouse is an arcade-style reactor game built with +[`@spacetimedb/rate-limit`](../). The browser requests actions; SpacetimeDB +owns energy, heat, upgrades, events, and the fixed-window limiter buckets mounted +under the `rateLimit` namespace. + +## What this demonstrates + +- Mounting the Rate Limit component in an application module. +- Deriving server-owned actor keys and fixed gameplay scopes. +- Enforcing limits from procedures with typed allow/deny results. +- Using independent buckets for taps, overcharge, upgrades, and repair. +- Showing caller-specific cooldown status through scoped views. +- Bounded scheduled cleanup and administrator-only maintenance controls. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity for publishing and optional admin grants. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-rate-limit-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open , start the reactor, and tap rapidly enough to fill +the tap bucket and heat meter. + +`build:module:fresh` deletes and recreates only the local +`spacetime-rate-limit-example` database. Use `pnpm run build:module` to preserve current +players, upgrades, and limiter state. + +## Use in your project + +This workspace tests the component source in this repository. Consumer +applications install the published release: + +```bash +npm install @spacetimedb/rate-limit spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +per-action policy and caller-status patterns; the reactor game, upgrades, and +heat model are application code. + +## Configuration + +| Variable | Default | Purpose | +| ------------------- | ------------------------------ | ------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8792` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_APP_DATABASE` | `spacetime-rate-limit-example` | Published database name. | + +The Node process serves static files, `GET /api/health`, and browser-safe +`GET /api/config`. Gameplay calls go directly from the browser to SpacetimeDB. + +## Limiting model + +Each protected action calls +`rateLimit.consumeRateLimit(ctx.as.rateLimit, ...)` with a server-selected scope, +an actor key derived from `ctx.sender`, a limit, a window, and an optional cost. +The returned result includes remaining capacity, reset time, and retry delay. +The mounted `consume` procedure is reserved for administrators; normal gameplay +uses the lower-level helper inside the host procedure's transaction. + +The component implements fixed-window limiting. Application heat and cooldown +mechanics are separate game rules layered over the rate limit, so a request may +be rejected by either system. + +The primary UI subscribes to application-owned views such as `reactor_state`, +`reactor_limit_status`, and `reactor_shop`. Raw bucket and limiter event data is +reserved for administrators. + +## Gameplay + +- Reactor taps generate energy while consuming the `reactor.tap` limit. +- Heat cools according to server time; an overheated reactor rejects more taps + until it recovers. +- Overcharge, repair, and shop installation use separate scopes and windows. +- Installed upgrades change server-owned capabilities and expose matching buttons. +- Recent events explain successful and rejected actions. + +The browser interpolates timers for presentation, but procedure responses and +subscribed server timestamps are authoritative. + +## Administration + +A fresh publish seeds the publisher as the initial mounted Rate Limit +administrator. The debug drawer remains empty and maintenance calls fail for an +ordinary browser identity. To exercise those controls locally, grant the browser +identity from the logged-in owner identity: + +```powershell +spacetime call --server local spacetime-rate-limit-example rateLimit.add_rate_limit_admin 0x +``` + +Admin resets and sweeps are bounded. Do not turn an unbounded delete into an +operator convenience endpoint. + +## Security and deployment boundaries + +- Actor keys are derived from trusted module context, not accepted from the + browser. A client-selected actor key would allow trivial limit evasion. +- A limit must guard the authoritative operation in the same server-side flow; + disabling a browser button is only presentation. +- Combine rate limiting with authentication, authorization, quotas, billing + controls, and network-level defenses. +- Stored browser tokens are development credentials and must not be logged or + committed. +- The admin event view returns at most 1,000 raw limiter events. +- The included Express process is a local static server. Production needs TLS, + explicit binding, origin policy, and supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test: + +1. Confirm normal taps consume capacity and expose decreasing remaining counts. +2. Exceed each action limit and verify the denied operation makes no game-state + change. +3. Wait through a reset boundary and confirm the next action is accepted. +4. Verify two browser identities have independent player and bucket state. +5. Confirm a non-admin cannot view raw events, reset demo state, change config, or + trigger privileged maintenance. +6. Grant an admin identity and verify bounded sweep/reset behavior. + +## Troubleshooting + +- **Actions fail immediately:** inspect both limiter status and reactor heat; they + are independent rejection paths. +- **Debug data is empty:** grant the connected browser identity mounted Rate Limit + administrator access. +- **State is stale:** confirm `STDB_URI` targets the database published by + the `local` server registration. +- **An identity fails after reset:** reload once so the client can replace a + rejected development token. + +## Important files + +- `spacetimedb/src/index.ts` - component mount, reactor rules, scoped views, and + bounded maintenance operations. +- `src/app.ts` - connection, procedures, subscriptions, and UI bridge. +- `server.ts` - static development server and browser-safe configuration. +- `public/index.html` - Powerhouse interface. +- `public/ui.js` - reactor rendering and interaction handling. +- `public/styles.css` - Powerhouse presentation. diff --git a/spacetime-rate-limit-ts/example/package.json b/spacetime-rate-limit-ts/example/package.json new file mode 100644 index 00000000000..28329b27278 --- /dev/null +++ b/spacetime-rate-limit-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-rate-limit-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "test:unit": "tsx scripts/test-reactor-rules.ts", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-rate-limit-ts/example/public/assets/brand.svg b/spacetime-rate-limit-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-rate-limit-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-rate-limit-ts/example/public/assets/logo.svg b/spacetime-rate-limit-ts/example/public/assets/logo.svg new file mode 100644 index 00000000000..adaf36cbf9c --- /dev/null +++ b/spacetime-rate-limit-ts/example/public/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/spacetime-rate-limit-ts/example/public/index.html b/spacetime-rate-limit-ts/example/public/index.html new file mode 100644 index 00000000000..17da917c786 --- /dev/null +++ b/spacetime-rate-limit-ts/example/public/index.html @@ -0,0 +1,174 @@ + + + + + + + + + Powerhouse + + + +
          +
          +
          + SpacetimeDB +
          +

          Powerhouse

          + Tap the core for energy, buy upgrades, and outproduce the crew. + Don't let it overheat. +
          +
          +
          + +
          +
          +
          + + + + 0 +
          + +
          + +
          +
          +
          + +
          +
          +
          + Core Heat + 0% · + stable +
          +
          +
          +
          +
          + Tap Charges + 8 / 8 · 6s window +
          +
          +
          +
          +
          + + +
          + +
          + Built on + SpacetimeDB +
          +
          + + + + + diff --git a/spacetime-rate-limit-ts/example/public/styles.css b/spacetime-rate-limit-ts/example/public/styles.css new file mode 100644 index 00000000000..778fdee22cb --- /dev/null +++ b/spacetime-rate-limit-ts/example/public/styles.css @@ -0,0 +1,1310 @@ +:root { + color-scheme: dark; + --demo-panel-height: auto; + --bg: #061013; + --panel: #0b181d; + --panel-2: #0e2028; + --line: #173846; + --line-strong: #24586d; + --text: #f1f7fb; + --muted: #8da8b8; + --accent: #22c7b8; + --accent-2: #ffce5c; + --danger: #ff6a66; + --ok: #52df8f; + --button: #b9c5d4; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + min-height: 100dvh; + background: + radial-gradient( + circle at 55% 20%, + rgba(34, 199, 184, 0.09), + transparent 32rem + ), + radial-gradient( + circle at 20% 88%, + rgba(255, 206, 92, 0.07), + transparent 30rem + ), + var(--bg); + color: var(--text); +} + +button, +input { + font: inherit; +} + +button { + border: 1px solid var(--line-strong); + background: #0b1c23; + color: var(--text); + border-radius: 8px; + min-height: 38px; + padding: 0 14px; + font-weight: 800; + cursor: pointer; +} + +button.primary { + background: var(--button); + border-color: var(--button); + color: #020609; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 16px 0 18px; + display: grid; + grid-template-rows: auto auto auto; + gap: 14px; + align-content: start; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(11, 24, 29, 0.92); + box-shadow: 0 18px 55px rgba(0, 0, 0, 0.24); +} + +.app-title { + display: flex; + align-items: center; + gap: 10px; + font-weight: 900; +} + +.app-title h1 { + margin: 0; + font-size: 18px; + line-height: 1.1; +} + +.app-title-text { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.app-sub { + color: var(--muted); + font-weight: 600; + font-size: 12px; + line-height: 1.3; +} + +.app-mark { + height: 24px; + width: auto; + display: block; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 800; +} + +.brand img { + width: 28px; + height: 28px; +} + +.page-foot { + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + margin-top: 0; + padding-top: 12px; + border-top: 1px solid rgba(23, 56, 70, 0.4); +} + +.page-foot .by { + color: #7e9cad; + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.page-foot img { + height: 28px; + width: auto; + opacity: 0.9; +} + +.layout { + display: grid; + grid-template-columns: minmax(420px, 0.95fr) minmax(360px, 1fr); + gap: 14px; + min-height: 0; + margin-top: 0; + align-items: start; +} + +.panel { + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(11, 24, 29, 0.94); +} + +.arena { + height: 640px; + min-height: 0; + padding: 16px; + display: grid; + grid-template-rows: auto minmax(360px, 1fr) auto; + gap: 12px; +} + +.energy-banner { + border: 1px solid rgba(34, 199, 184, 0.26); + border-radius: 8px; + padding: 10px 14px; + background: + linear-gradient(135deg, rgba(34, 199, 184, 0.11), rgba(255, 206, 92, 0.05)), + #08141a; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + animation: reactor-hum 3.4s ease-in-out infinite; +} + +@keyframes reactor-hum { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(34, 199, 184, 0); + } + 50% { + box-shadow: 0 0 20px 0 rgba(34, 199, 184, 0.1); + } +} + +.energy-banner.gain strong { + animation: energy-pop 0.34s ease; +} + +@keyframes energy-pop { + 0% { + transform: scale(1); + text-shadow: 0 0 15px rgba(255, 206, 92, 0.14); + } + 35% { + transform: scale(1.045); + text-shadow: 0 0 16px rgba(255, 206, 92, 0.34); + } + 100% { + transform: scale(1); + text-shadow: 0 0 15px rgba(255, 206, 92, 0.14); + } +} + +.energy-banner.gain .energy-icon { + animation: bolt-flicker 0.34s ease; +} + +@keyframes bolt-flicker { + 0% { + filter: drop-shadow(0 0 12px rgba(255, 206, 92, 0.28)); + } + 35% { + filter: drop-shadow(0 0 15px rgba(255, 206, 92, 0.45)) brightness(1.18); + } + 100% { + filter: drop-shadow(0 0 12px rgba(255, 206, 92, 0.28)); + } +} + +.energy-icon { + width: 40px; + height: 40px; + color: var(--accent-2); + filter: drop-shadow(0 0 12px rgba(255, 206, 92, 0.28)); +} + +.energy-icon .icon-bolt { + fill: currentColor; +} + +.color-dot { + width: 22px; + height: 22px; + min-height: 0; + padding: 0; + border-radius: 999px; + border: 2px solid rgba(241, 247, 251, 0.72); + background: var(--player-color, var(--accent)); + box-shadow: + 0 0 0 3px rgba(6, 16, 19, 0.95), + 0 0 18px var(--player-color, var(--accent)); +} + +.color-palette { + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 5; + display: none; + grid-template-columns: repeat(4, 22px); + gap: 8px; + padding: 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: #071318; + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.34); +} + +.color-palette.open { + display: grid; +} + +.color-swatch { + width: 22px; + height: 22px; + min-height: 0; + padding: 0; + border-radius: 999px; + border: 1px solid rgba(241, 247, 251, 0.28); + background: var(--swatch); +} + +.energy-banner strong { + display: block; + font-size: clamp(28px, 4vw, 42px); + line-height: 0.95; + letter-spacing: 0; + text-align: right; + transform-origin: right center; + text-shadow: 0 0 15px rgba(255, 206, 92, 0.14); +} + +.reactor-wrap { + position: relative; + display: grid; + place-items: center; + text-align: center; + min-height: 0; + padding: 0; + isolation: isolate; +} + +.reactor-wrap::before, +.reactor-wrap::after { + content: ''; + position: absolute; + border-radius: 999px; + pointer-events: none; + transition: + opacity 220ms ease, + transform 220ms ease, + border-color 220ms ease, + box-shadow 220ms ease; + z-index: 0; +} + +.reactor-wrap::before { + width: min(400px, 72%); + aspect-ratio: 1; + background: radial-gradient( + circle, + rgba(34, 199, 184, 0.18), + rgba(34, 199, 184, 0.06) 38%, + transparent 68% + ); + filter: blur(22px); + opacity: 0.58; + transform: scale(0.94); +} + +.reactor-wrap::after { + width: min(330px, 60%); + aspect-ratio: 1; + border: 1px solid rgba(34, 199, 184, 0.28); + box-shadow: + 0 0 38px rgba(34, 199, 184, 0.12), + inset 0 0 46px rgba(34, 199, 184, 0.08); + opacity: 0.72; +} + +.reactor-wrap.warm::before { + background: radial-gradient( + circle, + rgba(255, 206, 92, 0.2), + rgba(34, 199, 184, 0.08) 38%, + transparent 70% + ); + opacity: 0.72; + transform: scale(1); +} + +.reactor-wrap.warm::after { + border-color: rgba(255, 206, 92, 0.32); + box-shadow: + 0 0 44px rgba(255, 206, 92, 0.16), + inset 0 0 48px rgba(34, 199, 184, 0.08); +} + +.reactor-wrap.critical::before { + background: radial-gradient( + circle, + rgba(255, 106, 102, 0.24), + rgba(255, 206, 92, 0.12) 36%, + transparent 70% + ); + opacity: 0.86; + transform: scale(1.06); +} + +.reactor-wrap.critical::after { + border-color: rgba(255, 106, 102, 0.42); + box-shadow: + 0 0 54px rgba(255, 106, 102, 0.22), + inset 0 0 54px rgba(255, 106, 102, 0.08); +} + +.pop-layer { + position: absolute; + inset: 0; + pointer-events: none; + overflow: visible; + z-index: 3; +} + +.energy-pop { + position: absolute; + left: 50%; + top: 36%; + color: var(--pop-color, var(--accent-2)); + font-weight: 900; + font-size: 24px; + line-height: 1; + padding: 6px 11px; + border: 1px solid + color-mix(in srgb, var(--pop-color, var(--accent-2)) 58%, white 6%); + border-radius: 999px; + background: rgba(4, 13, 17, 0.76); + box-shadow: + 0 0 0 3px rgba(4, 13, 17, 0.56), + 0 0 28px + color-mix(in srgb, var(--pop-color, var(--accent-2)) 58%, transparent); + text-shadow: 0 2px 16px + color-mix(in srgb, var(--pop-color, var(--accent-2)) 65%, transparent); + transform: translate(-50%, -50%); + animation: energyPop 900ms ease-out forwards; + will-change: transform, opacity; +} + +.energy-pop.cooling { + color: var(--pop-color, var(--ok)); + text-shadow: 0 2px 16px rgba(82, 223, 143, 0.45); +} + +.energy-pop.blocked { + color: var(--danger); + border-color: rgba(255, 106, 102, 0.55); + text-shadow: 0 2px 16px rgba(255, 106, 102, 0.45); +} + +.energy-pop.surge-pop { + color: var(--pop-color, var(--accent-2)); + font-size: 22px; + text-shadow: + 0 2px 18px rgba(255, 206, 92, 0.6), + 0 0 34px rgba(255, 106, 102, 0.32); +} + +@keyframes energyPop { + 0% { + opacity: 0; + transform: translate(-50%, -20%) scale(0.82); + } + 18% { + opacity: 1; + transform: translate(calc(-50% + var(--pop-x)), -58%) scale(1.08); + } + 100% { + opacity: 0; + transform: translate(calc(-50% + var(--pop-x)), -150%) scale(0.92); + } +} + +.reactor-button { + width: min(250px, 52vw); + aspect-ratio: 1; + border-radius: 999px; + border: 1px solid rgba(34, 199, 184, 0.45); + background: + radial-gradient( + circle at 50% 48%, + rgba(255, 255, 255, 0.95) 0 3%, + transparent 4% + ), + radial-gradient( + circle at 50% 50%, + #22c7b8 0 13%, + #116d73 14% 34%, + #0b2630 35% 58%, + #071218 59% 100% + ); + box-shadow: + 0 0 0 14px rgba(34, 199, 184, 0.05), + 0 0 80px rgba(34, 199, 184, 0.2), + inset 0 0 50px rgba(255, 255, 255, 0.08); + color: var(--text); + display: grid; + place-items: center; + padding: 0; + transition: + transform 120ms ease, + box-shadow 160ms ease, + filter 160ms ease; + z-index: 1; +} + +.reactor-button:not(:disabled):active { + transform: scale(0.985); +} + +.reactor-button.hot { + border-color: rgba(255, 106, 102, 0.75); + background: + radial-gradient( + circle at 50% 48%, + rgba(255, 255, 255, 0.95) 0 3%, + transparent 4% + ), + radial-gradient( + circle at 50% 50%, + #ffce5c 0 13%, + #b44538 14% 34%, + #311217 35% 58%, + #071218 59% 100% + ); + box-shadow: + 0 0 0 14px rgba(255, 106, 102, 0.06), + 0 0 90px rgba(255, 106, 102, 0.22), + inset 0 0 50px rgba(255, 255, 255, 0.08); +} + +.reactor-copy { + display: grid; + gap: 6px; + padding: 0; +} + +.reactor-copy b { + font-size: clamp(24px, 5vw, 42px); + line-height: 1; + text-shadow: + 0 1px 3px rgba(0, 0, 0, 0.65), + 0 0 26px rgba(2, 10, 14, 0.88); +} + +.reactor-copy span { + color: #d8f5fb; + font: + 800 12px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.08em; + text-transform: uppercase; + text-shadow: + 0 1px 3px rgba(0, 0, 0, 0.7), + 0 0 16px rgba(2, 10, 14, 0.82); +} + +.meter { + display: grid; + gap: 6px; + margin: 0 auto; + max-width: 520px; +} + +.meter + .meter { + margin-top: 10px; +} + +.meter-head { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 13px; +} + +.track { + height: 14px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 999px; + background: #061014; +} + +.fill { + height: 100%; + width: 0%; + background: linear-gradient( + 90deg, + var(--accent), + var(--accent-2), + var(--danger) + ); + transition: width 80ms linear; +} + +.charge-segments { + display: grid; + grid-template-columns: repeat(var(--charge-limit, 8), minmax(0, 1fr)); + gap: 4px; + padding: 3px; + border: 1px solid var(--line); + border-radius: 8px; + background: #061014; +} + +.charge-segment { + height: 12px; + border-radius: 4px; + background: #0b1c23; + border: 1px solid rgba(174, 232, 255, 0.1); +} + +.charge-segment.active { + border-color: rgba(174, 232, 255, 0.35); + background: linear-gradient(180deg, #5ed7ff, #168fc2); + box-shadow: 0 0 10px rgba(94, 215, 255, 0.2); +} + +.systems-dock { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 2; +} + +.system-control { + position: absolute; + width: 128px; + aspect-ratio: 1; + pointer-events: auto; +} + +.system-control:nth-child(1) { + left: calc(50% + 150px); + top: calc(50% - 92px); + transform: translate(-50%, -50%); +} + +.system-control:nth-child(2) { + left: calc(50% - 150px); + top: calc(50% + 92px); + transform: translate(-50%, -50%); +} + +.system-control button { + width: 100%; + height: 100%; + border-radius: 999px; + display: grid; + gap: 4px; + place-items: center; + line-height: 1.15; + padding: 13px; + background: + radial-gradient( + circle at 50% 43%, + rgba(241, 247, 251, 0.1) 0 19%, + transparent 20% + ), + radial-gradient(circle at 50% 50%, #102c35 0 48%, #071419 49% 100%); + color: rgba(241, 247, 251, 0.92); + box-shadow: + inset 0 0 0 1px rgba(205, 245, 255, 0.08), + 0 16px 34px rgba(0, 0, 0, 0.34); + transition: + transform 120ms ease, + filter 160ms ease, + box-shadow 180ms ease; +} + +.system-control button:not(:disabled):active { + transform: scale(0.96); + filter: brightness(1.18); +} + +.system-control button > span { + color: var(--text); + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.74); +} + +.system-control button > span:first-child { + font-size: 15px; + align-self: end; +} + +.system-control button > .button-sub { + align-self: start; + max-width: 102px; + text-align: center; +} + +.system-control button.coolant { + color: #74f0ac; + border-color: rgba(82, 223, 143, 0.62); + box-shadow: + inset 0 0 0 1px rgba(82, 223, 143, 0.13), + 0 16px 34px rgba(0, 0, 0, 0.34), + 0 0 24px rgba(82, 223, 143, 0.1); +} + +.system-control button.surge { + color: #ffdc72; + border-color: rgba(255, 206, 92, 0.68); + box-shadow: + inset 0 0 0 1px rgba(255, 206, 92, 0.14), + 0 16px 34px rgba(0, 0, 0, 0.34), + 0 0 24px rgba(255, 206, 92, 0.12); +} + +.system-control button:disabled { + opacity: 0.52; + filter: saturate(0.45); +} + +.system-control button.fired { + animation: systemFire 520ms ease-out; +} + +@keyframes systemFire { + 0% { + transform: scale(1); + filter: brightness(1); + } + 28% { + transform: scale(1.12); + filter: brightness(1.48); + } + 100% { + transform: scale(1); + filter: brightness(1); + } +} + +.button-sub { + color: rgba(2, 6, 9, 0.68); + font: + 800 10px/1.2 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +button:not(.primary) .button-sub { + color: var(--muted); +} + +.cooldown { + min-height: 18px; + color: var(--muted); + font: + 700 11px/1.4 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.cooldown.blocked { + color: var(--danger); +} + +.side { + height: 640px; + min-height: 0; +} + +.box { + padding: 16px; +} + +.box h2 { + margin: 0; + font-size: 18px; +} + +.sub { + color: var(--muted); + margin-top: 4px; + font-size: 13px; +} + +.console-panel { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 14px; +} + +.tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0; + padding: 16px 16px 0; + border-bottom: 1px solid var(--line); +} + +.tab { + min-height: 36px; + padding: 0 4px 10px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + box-shadow: none; +} + +.tab[aria-selected='true'] { + background: transparent; + color: var(--text); +} + +.tab[aria-selected='true']::after { + content: ''; + position: absolute; + left: 18px; + right: 18px; + bottom: -1px; + height: 2px; + border-radius: 999px 999px 0 0; + background: var(--accent); + box-shadow: 0 0 14px rgba(34, 199, 184, 0.35); +} + +.tab:not([aria-selected='true']):hover { + color: #c7dbe4; +} + +.tab-count { + color: var(--muted); + font: + 800 11px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.tab[aria-selected='true'] .tab-count { + color: var(--accent-2); +} + +.tab-content { + min-height: 0; + padding: 0 16px 16px; + overflow: hidden; + display: grid; +} + +.tab-pane { + display: none; + height: 100%; + min-height: 0; +} + +.tab-pane.active { + display: grid; + align-content: start; + overflow: auto; +} + +.pane-empty { + align-self: center; + display: grid; + justify-items: center; + align-content: center; + gap: 5px; + min-height: 160px; + padding: 24px 16px; + text-align: center; +} + +.pane-empty b { + color: #c7dbe4; + font-size: 14px; +} + +.pane-empty span { + color: var(--muted); + font-size: 12px; + max-width: 240px; +} + +.pane-spinner { + width: 22px; + height: 22px; + margin-bottom: 6px; + border-radius: 999px; + border: 2px solid var(--line); + border-top-color: var(--accent); + animation: pane-spin 0.8s linear infinite; +} + +@keyframes pane-spin { + to { + transform: rotate(360deg); + } +} + +.shop-list { + display: grid; + gap: 8px; +} + +.shop-item { + border: 1px solid var(--line); + border-radius: 8px; + padding: 11px 12px; + background: #08141a; + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +.shop-item.locked { + opacity: 0.55; +} + +.shop-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 8px; + border: 1px solid + color-mix(in srgb, var(--lane, var(--line-strong)) 36%, var(--line)); + background: color-mix(in srgb, var(--lane, var(--accent)) 9%, #071318); + color: var(--lane, var(--accent)); +} + +.shop-icon svg { + width: 20px; + height: 20px; +} + +.shop-head { + min-width: 0; +} + +.shop-title { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.shop-title b { + font-size: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.shop-level { + flex: none; + color: var(--lane, var(--accent)); + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 999px; + border: 1px solid + color-mix(in srgb, var(--lane, var(--accent)) 38%, transparent); + background: color-mix(in srgb, var(--lane, var(--accent)) 11%, transparent); +} + +.shop-effect { + display: block; + margin-top: 3px; + color: color-mix(in srgb, var(--lane, var(--accent)) 74%, var(--text)); + font: + 700 12px/1.3 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.shop-desc { + margin: 3px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.shop-cost { + margin-top: 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 9px; + align-items: center; +} + +.shop-cost-track { + height: 5px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 999px; + background: #061014; +} + +.shop-cost-fill { + height: 100%; + width: 0%; + background: color-mix(in srgb, var(--lane, var(--accent)) 52%, #0a1620); + transition: + width 120ms linear, + background 200ms ease, + box-shadow 200ms ease; +} + +.shop-item.affordable .shop-cost-fill { + background: var(--lane, var(--accent)); + box-shadow: 0 0 9px + color-mix(in srgb, var(--lane, var(--accent)) 70%, transparent); +} + +.shop-cost-text { + color: var(--muted); + font: + 700 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.shop-item.affordable .shop-cost-text { + color: color-mix(in srgb, var(--lane, var(--accent)) 60%, var(--text)); +} + +.shop-buy-row { + display: grid; + justify-items: end; + align-items: center; +} + +.shop-buy-row button { + min-height: 32px; + width: 96px; + padding: 0 12px; + white-space: nowrap; + transition: transform 110ms ease; +} + +.shop-buy-row button:not(:disabled):hover { + transform: translateY(-1px); +} + +.shop-buy-row button:not(:disabled):active { + transform: translateY(0); +} + +.player-list, +.activity-list { + display: grid; + gap: 8px; + align-content: start; +} + +.player-row { + border: 1px solid var(--line); + border-radius: 8px; + background: #08141a; + padding: 10px 12px; +} + +.activity-row { + min-height: 30px; + display: grid; + grid-template-columns: 54px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 5px 0; + border-bottom: 1px solid rgba(23, 56, 70, 0.72); + border-left: 2px solid var(--player-color, transparent); + padding-left: 8px; +} + +.activity-row:last-child { + border-bottom: 0; +} + +.player-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + position: relative; +} + +.player-row.current { + border-color: rgba(34, 199, 184, 0.34); +} + +.player-color { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--player-color, var(--accent)); + box-shadow: 0 0 14px var(--player-color, var(--accent)); +} + +.player-color-wrap { + position: relative; + display: grid; + place-items: center; +} + +.player-color-wrap .color-dot { + width: 10px; + height: 10px; + min-height: 10px; + border-width: 1px; + position: relative; + box-shadow: 0 0 14px var(--player-color, var(--accent)); +} + +.player-color-wrap .color-dot::after { + content: ''; + position: absolute; + inset: -8px; + border-radius: 999px; +} + +.player-palette { + left: -8px; + top: calc(100% + 10px); +} + +.player-meta { + display: flex; + align-items: baseline; + gap: 7px; + min-width: 0; +} + +.self-pill { + color: var(--accent-2); + font: + 800 9px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.player-row b, +.activity-row b { + display: block; + font-size: 13px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-row span, +.activity-row span { + color: var(--muted); + font: + 700 11px/1.4 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +.activity-row time { + color: var(--muted); + font: + 700 11px/1.4 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + white-space: nowrap; +} + +.event-chip { + display: block; + width: 48px; + min-width: 48px; + max-width: 48px; + padding: 4px 0; + border: 1px solid var(--line); + border-radius: 7px; + text-align: center; + color: var(--muted); + background: #071318; + font: + 800 10px/1 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + letter-spacing: 0.03em; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.event-chip.tap { + border-color: rgba(34, 199, 184, 0.42); + color: #9ef4ea; + background: rgba(34, 199, 184, 0.08); +} + +.event-chip.surge { + border-color: rgba(255, 206, 92, 0.46); + color: #ffdf86; + background: rgba(255, 206, 92, 0.08); +} + +.event-chip.repair { + border-color: rgba(82, 223, 143, 0.44); + color: #a9f5c8; + background: rgba(82, 223, 143, 0.08); +} + +.event-chip.upgrade { + border-color: rgba(174, 232, 255, 0.4); + color: #c9efff; + background: rgba(174, 232, 255, 0.07); +} + +.event-chip.blocked { + border-color: rgba(255, 106, 102, 0.52); + color: #ffaaa7; + background: rgba(255, 106, 102, 0.08); +} + +.player-score { + color: var(--accent-2); + white-space: nowrap; + font: + 800 12px/1.4 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} + +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + } + .arena, + .side { + height: auto; + } +} + +@media (max-width: 560px) { + .shell { + width: min(100vw - 16px, 1320px); + } + .topbar { + align-items: flex-start; + flex-direction: column; + } + .reactor-wrap { + min-height: 420px; + } + .console-panel { + min-height: 0; + } + .tabs { + grid-template-columns: 1fr; + } + .system-control:nth-child(1), + .system-control:nth-child(2) { + position: absolute; + left: 50%; + right: auto; + top: auto; + bottom: auto; + transform: translateX(-50%); + } + .system-control:nth-child(1) { + top: 0; + } + .system-control:nth-child(2) { + bottom: 0; + } +} diff --git a/spacetime-rate-limit-ts/example/public/ui.js b/spacetime-rate-limit-ts/example/public/ui.js new file mode 100644 index 00000000000..16e5aa8d461 --- /dev/null +++ b/spacetime-rate-limit-ts/example/public/ui.js @@ -0,0 +1,763 @@ +const state = { + conn: 'connecting', + reactor: null, + events: [], + players: [], + statuses: [], + shop: [], + activeTab: 'shop', + currentIdentityHex: null, + colorPaletteOpen: false, +}; + +const $ = id => document.getElementById(id); +const fmt = new Intl.NumberFormat(); +const COOLANT_UNLOCK_LEVEL = 2; +const SURGE_UNLOCK_LEVEL = 2; +const PLAYER_COLORS = [ + '#22c7b8', + '#ffce5c', + '#52df8f', + '#ff6a66', + '#aee8ff', + '#d28cff', + '#ff9f6e', + '#8ddf65', +]; + +// Upgrade lanes reuse the colors the reactor already assigns to each system: +// amber = power/surge, green = cooling/coolant, cyan = tap charges. +const UPGRADE_LANES = { + power: { + color: 'var(--accent-2)', + count: 'powerUpgradeCount', + icon: '', + }, + cooling: { + color: 'var(--ok)', + count: 'coolingUpgradeCount', + icon: '', + }, + capacity: { + color: '#aee8ff', + count: 'capacityUpgradeCount', + icon: '', + }, + charges: { + color: '#5ed7ff', + count: 'chargeUpgradeCount', + icon: '', + }, + bay: { + color: 'var(--muted)', + count: 'bayUpgradeCount', + icon: '', + }, +}; + +function upgradeLaneColor(id) { + return UPGRADE_LANES[id]?.color ?? 'var(--accent)'; +} + +function upgradeLevel(id) { + const lane = UPGRADE_LANES[id]; + if (!lane) return 0; + return Number(state.reactor?.[lane.count] ?? 0); +} + +function costPercent(energy, cost) { + if (cost <= 0n) return 100; + if (energy >= cost) return 100; + return Number((energy * 100n) / cost); +} + +function microsToMs(ts) { + if (!ts || ts.microsSinceUnixEpoch == null) return 0; + return Number(ts.microsSinceUnixEpoch / 1000n); +} + +function eventTime(ts) { + const ms = microsToMs(ts); + return ms + ? new Date(ms).toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }) + : ''; +} + +function identityHex(identity) { + if (!identity) return ''; + if (typeof identity.toHexString === 'function') return identity.toHexString(); + return String(identity); +} + +function secondsUntil(ts) { + const ms = microsToMs(ts); + if (!ms) return 0; + return Math.max(0, Math.ceil((ms - Date.now()) / 1000)); +} + +function secondsUntilPrecise(ts) { + const ms = microsToMs(ts); + if (!ms) return 0; + return Math.max(0, (ms - Date.now()) / 1000); +} + +function formatSeconds(seconds) { + if (seconds <= 0) return 'ready'; + return `${seconds.toFixed(1)}s`; +} + +function effectiveReactor(reactor) { + if (!reactor) return null; + const elapsed = Math.max( + 0, + (Date.now() - microsToMs(reactor.updatedAt)) / 1000 + ); + const cooling = Number(reactor.coolingPerSecond ?? 4); + const capacity = Number(reactor.heatCapacity ?? 100); + const heat = Math.max(0, Number(reactor.heat ?? 0) - elapsed * cooling); + return { + ...reactor, + heat, + heatCapacity: capacity, + coolingPerSecond: cooling, + overheated: Boolean(reactor.overheated) && heat > capacity * 0.45, + }; +} + +function coolingText(reactor) { + if (!reactor || reactor.heat <= 0) return 'stable'; + if (reactor.overheated) { + const recoverAt = reactor.heatCapacity * 0.45; + const seconds = + Math.max(0, reactor.heat - recoverAt) / reactor.coolingPerSecond; + return seconds > 0 ? `Cooling ${formatSeconds(seconds)}` : 'safe'; + } + return `cooling ${formatSeconds(reactor.heat / reactor.coolingPerSecond)}`; +} + +function statusByScope(scope) { + return state.statuses.find(row => row.scope === scope); +} + +function effectiveStatus(scope) { + const row = statusByScope(scope); + if (!row) return null; + const seconds = secondsUntilPrecise(row.resetAt); + if (seconds <= 0) { + return { + ...row, + used: 0, + remaining: row.limit, + }; + } + return row; +} + +function isCooling(scope) { + const row = effectiveStatus(scope); + if (!row || row.remaining > 0) return false; + return secondsUntil(row.resetAt) > 0; +} + +function cooldownText(scope) { + const row = effectiveStatus(scope); + if (!row) return ''; + const seconds = secondsUntilPrecise(row.resetAt); + if (row.remaining <= 0 && seconds > 0) { + return scope === 'reactor.tap' + ? `Resets in ${formatSeconds(seconds)}` + : `Ready in ${formatSeconds(seconds)}`; + } + return `${row.remaining}/${row.limit} ready`; +} + +function cooldownButtonText(scope) { + const row = effectiveStatus(scope); + const seconds = secondsUntilPrecise(row?.resetAt); + return seconds > 0 ? formatSeconds(seconds) : 'Wait'; +} + +function tapChargeInfo(scope) { + const row = effectiveStatus(scope); + if (!row) return 'waiting'; + const seconds = secondsUntilPrecise(row.resetAt); + const reset = + seconds > 0 + ? `resets in ${formatSeconds(seconds)}` + : `${row.windowSeconds}s window`; + return `${row.remaining} / ${row.limit} · ${reset}`; +} + +function renderChargeSegments(scope) { + const row = effectiveStatus(scope); + const node = $('tapChargeSegments'); + if (!node) return; + const limit = row?.limit ?? 8; + const remaining = row?.remaining ?? 0; + node.style.setProperty('--charge-limit', String(Math.max(1, limit))); + node.innerHTML = Array.from( + { length: limit }, + (_value, index) => + `` + ).join(''); +} + +function hasCoolantFlush(reactor) { + return Number(reactor?.coolingUpgradeCount ?? 0) >= COOLANT_UNLOCK_LEVEL; +} + +function hasSurgeBurst(reactor) { + return Number(reactor?.powerUpgradeCount ?? 0) >= SURGE_UNLOCK_LEVEL; +} + +function romanLevel(value) { + const numerals = [ + [10, 'X'], + [9, 'IX'], + [5, 'V'], + [4, 'IV'], + [1, 'I'], + ]; + let n = Math.max(1, Math.min(20, Math.trunc(Number(value) || 1))); + let out = ''; + for (const [amount, label] of numerals) { + while (n >= amount) { + out += label; + n -= amount; + } + } + return out; +} + +function surgeLevel(reactor) { + return romanLevel(reactor?.reactorLevel ?? 1); +} + +function coolantLevel(reactor) { + return romanLevel(Math.max(1, Number(reactor?.coolingUpgradeCount ?? 0))); +} + +function surgePreview(reactor) { + const level = reactor?.reactorLevel ?? 1; + const heat = Math.max(28, Math.floor((reactor?.heatCapacity ?? 100) * 0.25)); + return `+${fmt.format(level * 10)} energy
          +${heat} heat`; +} + +function coolantAmount(reactor) { + return Math.max(65, Math.floor((reactor?.heatCapacity ?? 100) * 0.55)); +} + +function coolantPreview(reactor) { + return `Vent -${coolantAmount(reactor)} heat`; +} + +function spawnReactorPop(text, kind = '', color = '') { + const layer = $('popLayer'); + const pop = document.createElement('div'); + pop.className = `energy-pop ${kind}`; + pop.textContent = text; + pop.style.setProperty( + '--pop-x', + `${Math.round((Math.random() - 0.5) * 90)}px` + ); + if (color) pop.style.setProperty('--pop-color', color); + layer.appendChild(pop); + window.setTimeout(() => pop.remove(), 850); +} + +function spawnEventPop(event) { + if (!event.allowed) { + if (event.scope === 'reactor.tap') + spawnReactorPop('WAIT', 'blocked', event.actorColor); + return; + } + if (event.energyDelta > 0n) { + const prefix = + event.kind === 'overcharge' || event.kind === 'overcharge_overheated' + ? 'SURGE +' + : '+'; + const kind = + event.kind === 'overcharge' || event.kind === 'overcharge_overheated' + ? 'surge-pop' + : ''; + spawnReactorPop( + `${prefix}${fmt.format(event.energyDelta)}`, + kind, + event.actorColor + ); + return; + } + if (event.kind === 'repair') { + spawnReactorPop('VENT', 'cooling', event.actorColor); + } +} + +function setBusy(button, busy) { + button.dataset.busy = busy ? 'true' : 'false'; +} + +function flashButton(button, allowed) { + button.classList.remove('fired'); + void button.offsetWidth; + if (allowed) button.classList.add('fired'); + window.setTimeout(() => button.classList.remove('fired'), 460); +} + +async function callAction(button, fn) { + if (button.dataset.busy === 'true') return; + if (!window.reactor) throw new Error('reactor.not_ready'); + setBusy(button, true); + try { + const result = await fn(); + flashButton(button, result.allowed); + render(); + } catch (err) { + console.error('reactor action failed', err); + render(); + } finally { + setBusy(button, false); + } +} + +let energyShown = 0; +let energyTarget = 0; +let energyReady = false; +let energyTween = null; +let energyGainTimer = null; + +function pulseEnergyGain() { + const banner = document.querySelector('.energy-banner'); + if (!banner) return; + banner.classList.remove('gain'); + void banner.offsetWidth; + banner.classList.add('gain'); + window.clearTimeout(energyGainTimer); + energyGainTimer = window.setTimeout( + () => banner.classList.remove('gain'), + 460 + ); +} + +function writeEnergy() { + $('energy').textContent = fmt.format(Math.round(energyShown)); +} + +function startEnergyTween() { + if (energyTween != null) return; + energyTween = window.setInterval(() => { + const diff = energyTarget - energyShown; + if (Math.abs(diff) < 0.5) { + energyShown = energyTarget; + writeEnergy(); + window.clearInterval(energyTween); + energyTween = null; + return; + } + energyShown += diff * 0.25; + writeEnergy(); + }, 16); +} + +function setEnergy(value) { + const next = Number(value); + if (!energyReady) { + energyReady = true; + energyShown = next; + energyTarget = next; + writeEnergy(); + return; + } + if (next > energyTarget) pulseEnergyGain(); + energyTarget = next; + if (energyShown !== energyTarget) startEnergyTween(); +} + +function renderStats() { + const reactor = effectiveReactor(state.reactor); + const energy = reactor?.energy ?? 0n; + const level = reactor?.reactorLevel ?? 1; + const capacity = reactor?.heatCapacity ?? 100; + const heat = reactor?.heat ?? 0; + const heatPct = capacity <= 0 ? 0 : (heat / capacity) * 100; + const overheated = Boolean(reactor?.overheated); + const tapCooling = isCooling('reactor.tap'); + + if (reactor) setEnergy(energy); + $('heatText').textContent = `${heat.toFixed(1)} / ${capacity}`; + $('coolText').textContent = coolingText(reactor); + $('heatFill').style.width = `${Math.max(0, Math.min(100, heatPct))}%`; + $('tapChargeInfo').textContent = tapChargeInfo('reactor.tap'); + renderChargeSegments('reactor.tap'); + + $('reactorWrap').classList.toggle('warm', heatPct >= 35 && heatPct < 70); + $('reactorWrap').classList.toggle('critical', overheated || heatPct >= 70); + $('tapBtn').classList.toggle( + 'hot', + overheated || heatPct >= 75 || tapCooling + ); + $('tapBtn').disabled = state.conn !== 'connected' || overheated || tapCooling; + $('reactorLabel').textContent = overheated + ? 'Overheated' + : tapCooling + ? 'No Charges' + : 'Tap'; + $('reactorHint').textContent = overheated + ? coolingText(reactor) + : tapCooling + ? cooldownText('reactor.tap') + : `+${level} energy · +${reactor?.tapHeatGain ?? 13} heat`; +} + +function paneEmpty(title, sub) { + if (state.conn !== 'connected') { + const connecting = + state.conn === 'error' + ? ['Reconnecting', 'Lost the link to SpacetimeDB.'] + : ['Connecting', 'Linking up with SpacetimeDB.']; + return `
          ${connecting[0]}${connecting[1]}
          `; + } + return `
          ${title}${sub}
          `; +} + +function renderShop() { + const reactor = effectiveReactor(state.reactor); + const energy = reactor?.energy ?? 0n; + const upgradeCooling = isCooling('reactor.upgrade'); + const rows = state.shop.filter(item => item.available); + $('shopList').innerHTML = + rows.length === 0 + ? paneEmpty('Loading upgrades', 'Fetching the reactor shop.') + : rows + .map(item => { + const affordable = energy >= item.cost; + const disabled = + state.conn !== 'connected' || upgradeCooling || !affordable; + const buttonText = upgradeCooling + ? cooldownButtonText('reactor.upgrade') + : 'Install'; + const lane = UPGRADE_LANES[item.id]; + const level = upgradeLevel(item.id); + return ` +
          + +
          +
          ${item.name}${level > 0 ? `Lv ${level}` : ''}
          + ${item.effect} +

          ${item.description}

          +
          +
          + ${fmt.format(energy)} / ${fmt.format(item.cost)} +
          +
          +
          + +
          +
          + `; + }) + .join(''); +} + +function renderPlayers() { + const rows = state.players ?? []; + $('playerCount').textContent = rows.length.toString(); + $('playerList').innerHTML = + rows.length === 0 + ? paneEmpty('No crew yet', 'Open another tab to bring a pilot online.') + : rows + .map(player => { + const isCurrent = + identityHex(player.identity) === state.currentIdentityHex; + const colorControl = isCurrent + ? `
          + +
          + ${PLAYER_COLORS.map( + swatch => + `` + ).join('')} +
          +
          ` + : ''; + return ` +
          + ${colorControl} +
          +
          ${player.displayName}${isCurrent ? 'You' : ''}
          + ${player.taps} taps · ${player.surges} surges · ${player.coolantUses} vents +
          +
          ${fmt.format(player.contributedEnergy)}
          +
          + `; + }) + .join(''); +} + +function activityChip(event) { + if (!event.allowed) return { className: 'blocked', label: 'Wait' }; + if (event.kind === 'tap' || event.kind === 'tap_overheated') + return { className: 'tap', label: 'Tap' }; + if (event.kind === 'overcharge' || event.kind === 'overcharge_overheated') + return { className: 'surge', label: 'Surge' }; + if (event.kind === 'repair') return { className: 'repair', label: 'Vent' }; + if (event.kind === 'upgrade') return { className: 'upgrade', label: 'Shop' }; + return { className: '', label: 'Room' }; +} + +function renderActivity() { + const rows = (state.events ?? []).slice(0, 18); + $('activityCount').textContent = rows.length.toString(); + $('activityList').innerHTML = + rows.length === 0 + ? paneEmpty('No activity yet', 'Tap the core to start the feed.') + : rows + .map(event => { + const delta = + event.energyDelta === 0n + ? '' + : ` ${event.energyDelta > 0n ? '+' : ''}${event.energyDelta.toString()} energy`; + const chip = activityChip(event); + return ` +
          + ${chip.label} + ${event.actorName}${delta} + +
          + `; + }) + .join(''); +} + +function renderTabs() { + $('shopCount').textContent = state.shop.length.toString(); + for (const tab of document.querySelectorAll('[data-tab]')) { + const active = tab.dataset.tab === state.activeTab; + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + } + for (const pane of document.querySelectorAll('[data-pane]')) { + pane.classList.toggle('active', pane.dataset.pane === state.activeTab); + } +} + +function renderSystems() { + const reactor = effectiveReactor(state.reactor); + const heat = reactor?.heat ?? 0; + const overheated = Boolean(reactor?.overheated); + const systems = []; + const coolantUnlocked = hasCoolantFlush(reactor); + const surgeUnlocked = hasSurgeBurst(reactor); + + const repairCooling = isCooling('reactor.repair'); + const useful = overheated || heat >= 35; + if (coolantUnlocked) { + systems.push(` +
          + +
          ${repairCooling ? cooldownText('reactor.repair') : ''}
          +
          + `); + } + + const surgeCooling = isCooling('reactor.overcharge'); + if (surgeUnlocked) { + systems.push(` +
          + +
          ${surgeCooling ? cooldownText('reactor.overcharge') : ''}
          +
          + `); + } + + $('systemDock').innerHTML = systems.join(''); +} + +function updateShopTimers() { + const reactor = effectiveReactor(state.reactor); + const energy = reactor?.energy ?? 0n; + const upgradeCooling = isCooling('reactor.upgrade'); + + for (const item of state.shop.filter(row => row.available)) { + const button = document.querySelector( + `.upgrade-btn[data-upgrade-id="${item.id}"]` + ); + if (button instanceof HTMLButtonElement) { + const affordable = energy >= item.cost; + button.disabled = + state.conn !== 'connected' || upgradeCooling || !affordable; + button.textContent = upgradeCooling + ? cooldownButtonText('reactor.upgrade') + : 'Install'; + + const row = button.closest('.shop-item'); + if (row instanceof HTMLElement) { + row.classList.toggle('affordable', affordable); + const fill = row.querySelector('.shop-cost-fill'); + if (fill instanceof HTMLElement) + fill.style.width = `${costPercent(energy, item.cost)}%`; + const costText = row.querySelector('.shop-cost-text'); + if (costText) + costText.textContent = `${fmt.format(energy)} / ${fmt.format(item.cost)}`; + } + } + } +} + +function updateSystemTimers() { + const reactor = effectiveReactor(state.reactor); + const heat = reactor?.heat ?? 0; + const overheated = Boolean(reactor?.overheated); + + const repairBtn = $('repairBtn'); + if (repairBtn) { + const cooling = isCooling('reactor.repair'); + const useful = overheated || heat >= 35; + repairBtn.disabled = state.conn !== 'connected' || cooling || !useful; + const hint = $('repairHint'); + if (hint) + hint.textContent = cooling + ? cooldownText('reactor.repair') + : coolantPreview(reactor); + const cooldown = $('repairCooldown'); + if (cooldown) { + cooldown.textContent = cooling ? cooldownText('reactor.repair') : ''; + cooldown.classList.toggle('blocked', cooling); + } + } + + const surgeBtn = $('overchargeBtn'); + if (surgeBtn) { + const cooling = isCooling('reactor.overcharge'); + surgeBtn.disabled = state.conn !== 'connected' || overheated || cooling; + const hint = $('overchargeHint'); + if (hint) + hint.innerHTML = overheated + ? 'Core cooling' + : cooling + ? cooldownText('reactor.overcharge') + : surgePreview(reactor); + const cooldown = $('overchargeCooldown'); + if (cooldown) { + cooldown.textContent = cooling ? cooldownText('reactor.overcharge') : ''; + cooldown.classList.toggle('blocked', cooling); + } + } +} + +function render() { + renderStats(); + renderTabs(); + renderShop(); + renderSystems(); + renderPlayers(); + renderActivity(); +} + +function renderLive() { + renderStats(); + updateShopTimers(); + updateSystemTimers(); +} + +function animationLoop() { + renderLive(); + requestAnimationFrame(animationLoop); +} + +window.addEventListener('reactor:connState', ev => { + state.conn = ev.detail.state; + if (ev.detail.detail) { + console.warn('reactor connection state', ev.detail); + } + render(); +}); + +window.addEventListener('reactor:data', ev => { + state.reactor = ev.detail.state; + state.events = ev.detail.events ?? []; + state.players = ev.detail.players ?? []; + state.statuses = ev.detail.statuses ?? []; + state.shop = ev.detail.shop ?? []; + state.currentIdentityHex = ev.detail.currentIdentityHex ?? null; + render(); +}); + +window.addEventListener('reactor:eventInserted', ev => { + spawnEventPop(ev.detail.event); +}); + +window.addEventListener('reactor:ready', () => { + render(); +}); + +$('tapBtn').addEventListener('click', () => + callAction($('tapBtn'), () => window.reactor.tap()) +); +$('playerList').addEventListener('click', ev => { + const target = ev.target instanceof Element ? ev.target : null; + if (!target) return; + + if (target.closest('#crewColorDot')) { + state.colorPaletteOpen = !state.colorPaletteOpen; + renderPlayers(); + return; + } + + const button = target.closest('[data-color]'); + if (!(button instanceof HTMLButtonElement)) return; + const color = button.dataset.color; + if (!color || !window.reactor) return; + state.colorPaletteOpen = false; + window.reactor + .setPlayerColor(color) + .catch(err => console.error('setPlayerColor failed', err)); + renderPlayers(); +}); +document.addEventListener('pointerdown', ev => { + if (!state.colorPaletteOpen) return; + const target = ev.target instanceof Element ? ev.target : null; + if (target?.closest('#crewColorDot, #crewColorPalette')) return; + state.colorPaletteOpen = false; + renderPlayers(); +}); +document.addEventListener('keydown', ev => { + if (ev.key !== 'Escape' || !state.colorPaletteOpen) return; + state.colorPaletteOpen = false; + renderPlayers(); +}); +$('shopList').addEventListener('click', ev => { + const button = + ev.target instanceof Element ? ev.target.closest('.upgrade-btn') : null; + if (button instanceof HTMLButtonElement) { + const upgradeId = button.dataset.upgradeId; + if (upgradeId) + callAction(button, () => window.reactor.buyUpgrade(upgradeId)); + } +}); +document.querySelector('.tabs').addEventListener('click', ev => { + const button = + ev.target instanceof Element ? ev.target.closest('[data-tab]') : null; + if (!(button instanceof HTMLButtonElement)) return; + state.activeTab = button.dataset.tab; + renderTabs(); +}); +$('systemDock').addEventListener('click', ev => { + const button = + ev.target instanceof Element ? ev.target.closest('button') : null; + if (!(button instanceof HTMLButtonElement)) return; + if (button.id === 'repairBtn') + callAction(button, () => window.reactor.repair()); + if (button.id === 'overchargeBtn') + callAction(button, () => window.reactor.overcharge()); +}); +render(); +requestAnimationFrame(animationLoop); diff --git a/spacetime-rate-limit-ts/example/scripts/test-reactor-rules.ts b/spacetime-rate-limit-ts/example/scripts/test-reactor-rules.ts new file mode 100644 index 00000000000..5da0ad814e9 --- /dev/null +++ b/spacetime-rate-limit-ts/example/scripts/test-reactor-rules.ts @@ -0,0 +1,44 @@ +import * as assert from 'node:assert/strict'; +import { + COOLANT_UNLOCK_LEVEL, + SURGE_UNLOCK_LEVEL, + hasCoolantFlush, + hasSurgeBurst, + roomTuning, + tapLimitForState, + upgradeOffer, + upgradeWindowForState, + type UpgradeState, +} from '../spacetimedb/src/reactor-rules'; + +const state: UpgradeState = { + powerUpgradeCount: 0, + coolingUpgradeCount: 0, + capacityUpgradeCount: 0, + chargeUpgradeCount: 0, + bayUpgradeCount: 0, +}; + +assert.deepEqual(roomTuning(state, 1), { + heatCapacity: 100, + coolingPerSecond: 4, + tapHeatGain: 12, +}); +assert.deepEqual(roomTuning(state, 3), { + heatCapacity: 250, + coolingPerSecond: 12, + tapHeatGain: 12, +}); +assert.equal(upgradeOffer(state, 'power').cost, 12n); +assert.equal(tapLimitForState({ chargeUpgradeCount: 2 }), 12); +assert.equal(upgradeWindowForState({ bayUpgradeCount: 99 }), 5); +assert.equal( + hasCoolantFlush({ coolingUpgradeCount: COOLANT_UNLOCK_LEVEL }), + true +); +assert.equal( + hasSurgeBurst({ powerUpgradeCount: SURGE_UNLOCK_LEVEL - 1 }), + false +); + +console.log('reactor rules tests passed'); diff --git a/spacetime-rate-limit-ts/example/server.ts b/spacetime-rate-limit-ts/example/server.ts new file mode 100644 index 00000000000..ca0344ce1bd --- /dev/null +++ b/spacetime-rate-limit-ts/example/server.ts @@ -0,0 +1,40 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PORT = Number.parseInt(process.env.PORT ?? '8792', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_APP_DB = + process.env.STDB_APP_DATABASE ?? 'spacetime-rate-limit-example'; + +const app = express(); +app.use(express.json({ limit: '256kb' })); +app.use( + express.static(path.join(__dirname, 'public'), { + etag: false, + lastModified: false, + setHeaders(res) { + res.setHeader('Cache-Control', 'no-store'); + }, + }) +); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, app: STDB_APP_DB }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ stdbUri: STDB_URI, appDatabase: STDB_APP_DB }); +}); + +app.listen(PORT, HOST, () => { + console.log(`Rate-limit test app running at http://${HOST}:${PORT}`); + console.log(` STDB -> ${STDB_URI} (${STDB_APP_DB})`); +}); diff --git a/spacetime-rate-limit-ts/example/spacetimedb/package.json b/spacetime-rate-limit-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..e699cda6b13 --- /dev/null +++ b/spacetime-rate-limit-ts/example/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-rate-limit-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-rate-limit-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-rate-limit-example" + }, + "dependencies": { + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts b/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..08f54b2ed44 --- /dev/null +++ b/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,1167 @@ +import { + schema, + table, + t, + Range, + SenderError, + type InferSchema, + type ReducerCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { + COOLANT_UNLOCK_LEVEL, + PLAYER_COLORS, + POWER_PER_UPGRADE, + SURGE_UNLOCK_LEVEL, + TAP_LIMIT, + hasCoolantFlush, + hasSurgeBurst, + overheatRecoverAt, + playerColor, + playerName, + roomTuning, + tapLimitForState, + upgradeOffer, + upgradeWindowForState, + type UpgradeLane, +} from './reactor-rules'; + +const ONE_SECOND_MICROS = 1_000_000n; + +const TAP_SCOPE = 'reactor.tap'; +const OVERCHARGE_SCOPE = 'reactor.overcharge'; +const UPGRADE_SCOPE = 'reactor.upgrade'; +const REPAIR_SCOPE = 'reactor.repair'; +const SHARED_REACTOR_KEY = 'room'; + +const TAP_WINDOW_SECONDS = 6; +const OVERCHARGE_LIMIT = 3; +const OVERCHARGE_WINDOW_SECONDS = 25; +const UPGRADE_LIMIT = 2; +const REPAIR_LIMIT = 1; +const REPAIR_WINDOW_SECONDS = 18; + +const DEFAULT_RETAIN_EVENTS = 2000; +const DEFAULT_EVENT_PRUNE_BATCH = 500; +const DEFAULT_RETAIN_REACTOR_EVENTS = 80; +const ACTIVE_CREW_WINDOW_SECONDS = 60; +const MAX_CREW_SCALING = 6; +const ADMIN_EVENT_VIEW_LIMIT = 1000; + +import { + rateLimitEvent, + reactorRoomState, + reactorPlayerState, + reactorEvent, +} from './model'; + +const rateLimitDemoConfig = table( + { name: 'rate_limit_demo_config', public: true }, + { + singleton: t.bool().primaryKey(), + retainEvents: t.u32(), + eventPruneBatch: t.u32(), + updatedAt: t.timestamp(), + } +); +const rateLimitDemoSweepTick = table( + { + name: 'rate_limit_demo_sweep_tick', + scheduled: (): any => rate_limit_demo_sweep, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + rateLimit, + rateLimitEvent, + reactorRoomState, + reactorPlayerState, + reactorEvent, + rateLimitDemoConfig, + rateLimitDemoSweepTick, +}); +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx; +type ReactorStateRow = NonNullable< + ReturnType +>; +type ReactorPlayerRow = NonNullable< + ReturnType +>; + +function takeRows(rows: Iterable, limit: number): T[] { + const result: T[] = []; + for (const row of rows) { + if (result.length >= limit) break; + result.push(row); + } + return result; +} + +const reactorLimitStatusRow = t.object('ReactorLimitStatusRow', { + scope: t.string(), + label: t.string(), + limit: t.u32(), + windowSeconds: t.u32(), + used: t.u32(), + remaining: t.u32(), + resetAt: t.option(t.timestamp()), +}); + +const reactorShopItem = t.object('ReactorShopItemRow', { + slot: t.u32(), + id: t.string(), + name: t.string(), + description: t.string(), + effect: t.string(), + cost: t.u64(), + available: t.bool(), +}); + +const reactorPlayer = t.object('ReactorPlayerRow', { + identity: t.identity(), + displayName: t.string(), + color: t.string(), + contributedEnergy: t.u64(), + taps: t.u32(), + surges: t.u32(), + coolantUses: t.u32(), + upgradesBought: t.u32(), + joinedAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +const reactorActionResult = t.object('ReactorActionResult', { + allowed: t.bool(), + action: t.string(), + message: t.string(), + energy: t.u64(), + energyDelta: t.i64(), + retryAfterSeconds: t.u32(), + resetAt: t.timestamp(), +}); + +function isAdmin(ctx: ViewCtx): boolean { + return ( + ctx.db.rateLimit.rateLimitAdminIdentity.identity.find(ctx.sender) != null + ); +} + +function requireAdmin(ctx: Tx): void { + if ( + ctx.as.rateLimit.db.rateLimitAdminIdentity.identity.find(ctx.sender) == null + ) { + throw new SenderError('rate_limit.not_authorized'); + } +} + +function actorKey(ctx: { sender: { toHexString(): string } }): string { + return ctx.sender.toHexString(); +} + +function limitKeyForScope( + scope: string, + ctx: { sender: { toHexString(): string } } +): string { + return scope === UPGRADE_SCOPE ? SHARED_REACTOR_KEY : actorKey(ctx); +} + +function bucketKey(scope: string, key: string): string { + return rateLimit.buildRateLimitKey(scope, key); +} + +function clampU32(value: number, min = 0, max = 0xffff_ffff): number { + return Math.max(min, Math.min(max, Math.trunc(value))); +} + +function requireUpgradeLane(value: string): UpgradeLane { + if ( + value === 'power' || + value === 'cooling' || + value === 'capacity' || + value === 'charges' || + value === 'bay' + ) + return value; + throw new SenderError('reactor.invalid_upgrade'); +} + +function activeCrewCount(tx: Tx): number { + const activeAfter = + tx.timestamp.microsSinceUnixEpoch - + BigInt(ACTIVE_CREW_WINDOW_SECONDS) * ONE_SECOND_MICROS; + let count = 0; + for (const _row of tx.db.reactorPlayerState.updatedAt.filter( + new Range({ tag: 'included', value: new Timestamp(activeAfter) }) + )) + count++; + return clampU32(Math.max(1, count), 1, MAX_CREW_SCALING); +} + +function tunedState(tx: Tx, state: ReactorStateRow): ReactorStateRow { + const tuning = roomTuning(state, activeCrewCount(tx)); + const heat = clampU32(state.heat, 0, tuning.heatCapacity); + return { + ...state, + ...tuning, + heat, + overheated: + state.overheated && heat > Math.floor(tuning.heatCapacity * 0.45), + }; +} + +function requirePlayerColor(color: string): string { + if (PLAYER_COLORS.includes(color)) return color; + throw new SenderError('reactor.invalid_player_color'); +} + +function ensurePlayer(tx: Tx): ReactorPlayerRow { + const existing = tx.db.reactorPlayerState.identity.find(tx.sender); + if (existing) return putPlayer(tx, existing); + const created = { + identity: tx.sender, + displayName: playerName(tx.sender), + color: playerColor(tx.sender), + contributedEnergy: 0n, + taps: 0, + surges: 0, + coolantUses: 0, + upgradesBought: 0, + joinedAt: tx.timestamp, + updatedAt: tx.timestamp, + }; + tx.db.reactorPlayerState.insert(created); + return created; +} + +function putPlayer(tx: Tx, player: ReactorPlayerRow): ReactorPlayerRow { + const next = { ...player, updatedAt: tx.timestamp }; + tx.db.reactorPlayerState.identity.update(next); + return next; +} + +function ensureState(tx: Tx): ReactorStateRow { + ensurePlayer(tx); + const existing = tx.db.reactorRoomState.singleton.find(true); + if (existing) return existing; + const tuningBase = { + coolingUpgradeCount: 0, + capacityUpgradeCount: 0, + }; + const tuning = roomTuning(tuningBase, activeCrewCount(tx)); + const created = { + singleton: true, + energy: 0n, + reactorLevel: 1, + upgradeCount: 0, + powerUpgradeCount: 0, + coolingUpgradeCount: 0, + capacityUpgradeCount: 0, + chargeUpgradeCount: 0, + bayUpgradeCount: 0, + combo: 0, + bestCombo: 0, + heat: 0, + heatCapacity: tuning.heatCapacity, + coolingPerSecond: tuning.coolingPerSecond, + tapHeatGain: tuning.tapHeatGain, + overheated: false, + updatedAt: tx.timestamp, + }; + tx.db.reactorRoomState.insert(created); + return created; +} + +function putState(tx: Tx, state: ReactorStateRow): ReactorStateRow { + const next = { ...state, updatedAt: tx.timestamp }; + tx.db.reactorRoomState.singleton.update(next); + return next; +} + +function cooledState(tx: Tx, state: ReactorStateRow): ReactorStateRow { + const tuned = tunedState(tx, state); + const elapsedSeconds = Number( + (tx.timestamp.microsSinceUnixEpoch - tuned.updatedAt.microsSinceUnixEpoch) / + ONE_SECOND_MICROS + ); + if ( + elapsedSeconds <= 0 && + tuned.heat === state.heat && + tuned.heatCapacity === state.heatCapacity && + tuned.coolingPerSecond === state.coolingPerSecond && + tuned.tapHeatGain === state.tapHeatGain && + tuned.overheated === state.overheated + ) + return state; + + const heat = Math.max( + 0, + tuned.heat - Math.max(0, elapsedSeconds) * tuned.coolingPerSecond + ); + const overheated = tuned.overheated && heat > overheatRecoverAt(tuned); + if ( + heat === state.heat && + overheated === state.overheated && + tuned.heatCapacity === state.heatCapacity && + tuned.coolingPerSecond === state.coolingPerSecond && + tuned.tapHeatGain === state.tapHeatGain + ) + return state; + + return putState(tx, { + ...tuned, + heat, + overheated, + }); +} + +function currentState(tx: Tx): ReactorStateRow { + return cooledState(tx, ensureState(tx)); +} + +function pruneRateLimitEvents( + tx: Tx, + retainEvents: number, + pruneBatch: number +): number { + const total = Number(tx.db.rateLimitEvent.count()); + if (total <= retainEvents) return 0; + + const toDelete = Math.min(pruneBatch, total - retainEvents); + let deleted = 0; + for (const row of tx.db.rateLimitEvent.createdAt.filter(new Range())) { + if (deleted >= toDelete) break; + tx.db.rateLimitEvent.delete(row); + deleted++; + } + return deleted; +} + +function pruneReactorEvents(tx: Tx): void { + const rows = [...tx.db.reactorEvent.iter()].sort((a, b) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + ); + const extra = rows.length - DEFAULT_RETAIN_REACTOR_EVENTS; + if (extra <= 0) return; + for (let i = 0; i < extra; i++) tx.db.reactorEvent.delete(rows[i]); +} + +function toU32(name: string, value: number): number { + if (!Number.isInteger(value) || value <= 0 || value > 0xffff_ffff) { + throw new Error(`rate_limit.invalid_${name}`); + } + return value; +} + +function recordLimitHit( + tx: Tx, + args: { + scope: string; + key: string; + limit: number; + windowSeconds: number; + cost: number; + allowed: boolean; + used: number; + remaining: number; + retryAfterSeconds: number; + resetAt: Tx['timestamp']; + } +) { + tx.db.rateLimitEvent.insert({ + id: 0n, + scope: args.scope, + key: args.key, + allowed: args.allowed, + limit: args.limit, + used: args.used, + remaining: args.remaining, + retryAfterSeconds: args.retryAfterSeconds, + windowSeconds: args.windowSeconds, + cost: args.cost, + resetAt: args.resetAt, + createdAt: tx.timestamp, + }); +} + +function recordReactorEvent( + tx: Tx, + args: { + kind: string; + scope: string; + message: string; + allowed: boolean; + energyDelta?: bigint; + retryAfterSeconds?: number; + } +) { + const player = ensurePlayer(tx); + tx.db.reactorEvent.insert({ + id: 0n, + identity: tx.sender, + actorName: player.displayName, + actorColor: player.color, + kind: args.kind, + scope: args.scope, + message: args.message, + allowed: args.allowed, + energyDelta: args.energyDelta ?? 0n, + retryAfterSeconds: args.retryAfterSeconds ?? 0, + createdAt: tx.timestamp, + }); + pruneReactorEvents(tx); +} + +function consumeAction( + tx: Tx, + scope: string, + actorKey: string, + limit: number, + windowSeconds: number, + cost = 1 +) { + return rateLimit.consumeRateLimit(tx.as.rateLimit, { + key: rateLimit.buildRateLimitKey(scope, actorKey), + scope, + limit, + windowSeconds, + cost, + }); +} + +function emptyActionResult(tx: Tx, action: string, message: string) { + const state = currentState(tx); + return { + allowed: true, + action, + message, + energy: state.energy, + energyDelta: 0n, + retryAfterSeconds: 0, + resetAt: tx.timestamp, + }; +} + +export const init = spacetimedb.init(ctx => { + rateLimit.installRateLimit(ctx.as.rateLimit); + if (ctx.db.rateLimitDemoConfig.singleton.find(true) == null) { + ctx.db.rateLimitDemoConfig.insert({ + singleton: true, + retainEvents: DEFAULT_RETAIN_EVENTS, + eventPruneBatch: DEFAULT_EVENT_PRUNE_BATCH, + updatedAt: ctx.timestamp, + }); + } + if (ctx.db.rateLimitDemoSweepTick.count() === 0n) { + ctx.db.rateLimitDemoSweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(30n * ONE_SECOND_MICROS), + }); + } +}); + +export const reactorState = spacetimedb.view( + { name: 'reactor_state', public: true }, + t.array(reactorRoomState.rowType), + ctx => { + const row = ctx.db.reactorRoomState.singleton.find(true); + return row ? [row] : []; + } +); + +export const reactorEvents = spacetimedb.view( + { name: 'reactor_events', public: true }, + t.array(reactorEvent.rowType), + ctx => + [...ctx.db.reactorEvent.iter()] + .sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)) + .slice(0, DEFAULT_RETAIN_REACTOR_EVENTS) +); + +export const reactorPlayers = spacetimedb.view( + { name: 'reactor_players', public: true }, + t.array(reactorPlayer), + ctx => + [...ctx.db.reactorPlayerState.iter()] + .sort((a, b) => { + if (a.contributedEnergy === b.contributedEnergy) + return a.displayName.localeCompare(b.displayName); + return a.contributedEnergy < b.contributedEnergy ? 1 : -1; + }) + .slice(0, 16) +); + +export const reactorLimitStatus = spacetimedb.view( + { name: 'reactor_limit_status', public: true }, + t.array(reactorLimitStatusRow), + ctx => { + const state = ctx.db.reactorRoomState.singleton.find(true); + const tapLimit = state ? tapLimitForState(state) : TAP_LIMIT; + const upgradeWindowSeconds = upgradeWindowForState(state); + const specs = [ + { + scope: TAP_SCOPE, + label: 'Tap Coils', + limit: tapLimit, + windowSeconds: TAP_WINDOW_SECONDS, + }, + { + scope: OVERCHARGE_SCOPE, + label: 'Overcharger', + limit: OVERCHARGE_LIMIT, + windowSeconds: OVERCHARGE_WINDOW_SECONDS, + }, + { + scope: UPGRADE_SCOPE, + label: 'Upgrade Bay', + limit: UPGRADE_LIMIT, + windowSeconds: upgradeWindowSeconds, + }, + { + scope: REPAIR_SCOPE, + label: 'Repair Drones', + limit: REPAIR_LIMIT, + windowSeconds: REPAIR_WINDOW_SECONDS, + }, + ]; + return specs.map(spec => { + const key = limitKeyForScope(spec.scope, ctx); + const bucket = ctx.db.rateLimit.rateLimitBucket.key.find( + bucketKey(spec.scope, key) + ); + const used = Number(bucket?.count ?? 0); + return { + ...spec, + used, + remaining: clampU32(spec.limit - used), + resetAt: bucket?.expiresAt, + }; + }); + } +); + +export const reactorShop = spacetimedb.view( + { name: 'reactor_shop', public: true }, + t.array(reactorShopItem), + ctx => { + const state = ctx.db.reactorRoomState.singleton.find(true); + const base = state ?? { + powerUpgradeCount: 0, + coolingUpgradeCount: 0, + capacityUpgradeCount: 0, + chargeUpgradeCount: 0, + bayUpgradeCount: 0, + }; + return (['power', 'cooling', 'capacity', 'charges', 'bay'] as const).map( + lane => { + const offer = upgradeOffer(base, lane); + return { + slot: offer.slot, + id: offer.id, + name: offer.name, + description: offer.description, + effect: offer.effect, + cost: offer.cost, + available: true, + }; + } + ); + } +); + +export const rateLimitEventsAdmin = spacetimedb.view( + { name: 'rate_limit_events_admin', public: true }, + t.array(rateLimitEvent.rowType), + ctx => + isAdmin(ctx) + ? takeRows(ctx.db.rateLimitEvent.iter(), ADMIN_EVENT_VIEW_LIMIT) + : [] +); + +export const start_reactor = spacetimedb.procedure( + {}, + reactorActionResult, + ctx => { + let out: ReturnType | null = null; + ctx.withTx(tx => { + ensurePlayer(tx); + out = emptyActionResult(tx, 'start', 'Reactor online.'); + }); + if (!out) throw new Error('reactor.start_tx_failed'); + return out; + } +); + +export const tap_reactor = spacetimedb.procedure( + {}, + reactorActionResult, + ctx => { + const key = actorKey(ctx); + let out: ReturnType | null = null; + ctx.withTx(tx => { + const tapLimit = tapLimitForState(currentState(tx)); + const result = consumeAction( + tx, + TAP_SCOPE, + key, + tapLimit, + TAP_WINDOW_SECONDS + ); + recordLimitHit(tx, { + ...result, + limit: tapLimit, + windowSeconds: TAP_WINDOW_SECONDS, + cost: 1, + }); + const state = currentState(tx); + if (!result.allowed) { + const next = putState(tx, { + ...state, + combo: 0, + }); + const message = `Tap coils cooling for ${result.retryAfterSeconds}s.`; + recordReactorEvent(tx, { + kind: 'tap_rate_limited', + scope: TAP_SCOPE, + message, + allowed: false, + retryAfterSeconds: result.retryAfterSeconds, + }); + out = { + allowed: false, + action: 'tap', + message, + energy: next.energy, + energyDelta: 0n, + retryAfterSeconds: result.retryAfterSeconds, + resetAt: result.resetAt, + }; + return; + } + if (state.overheated) { + const message = 'Core is overheated.'; + recordReactorEvent(tx, { + kind: 'tap_heat_blocked', + scope: TAP_SCOPE, + message, + allowed: false, + }); + out = { + allowed: false, + action: 'tap', + message, + energy: state.energy, + energyDelta: 0n, + retryAfterSeconds: 0, + resetAt: result.resetAt, + }; + return; + } + + const gain = BigInt(state.reactorLevel); + const player = ensurePlayer(tx); + const combo = clampU32(state.combo + 1); + const heat = clampU32( + state.heat + state.tapHeatGain, + 0, + state.heatCapacity + ); + const next = putState(tx, { + ...state, + energy: state.energy + gain, + combo, + bestCombo: Math.max(state.bestCombo, combo), + heat, + overheated: heat >= state.heatCapacity, + }); + putPlayer(tx, { + ...player, + contributedEnergy: player.contributedEnergy + gain, + taps: clampU32(player.taps + 1), + }); + const message = + heat >= state.heatCapacity + ? 'Energy gained, but the core overheated.' + : `+${gain.toString()} energy.`; + recordReactorEvent(tx, { + kind: heat >= state.heatCapacity ? 'tap_overheated' : 'tap', + scope: TAP_SCOPE, + message, + allowed: true, + energyDelta: gain, + }); + out = { + allowed: true, + action: 'tap', + message, + energy: next.energy, + energyDelta: gain, + retryAfterSeconds: 0, + resetAt: result.resetAt, + }; + }); + if (!out) throw new Error('reactor.tap_tx_failed'); + return out; + } +); + +export const overcharge = spacetimedb.procedure( + {}, + reactorActionResult, + ctx => { + let locked: { + allowed: boolean; + action: string; + message: string; + energy: bigint; + energyDelta: bigint; + retryAfterSeconds: number; + resetAt: Tx['timestamp']; + } | null = null; + ctx.withTx(tx => { + const state = currentState(tx); + if (hasSurgeBurst(state)) return; + const message = `Install Plasma Coils level ${SURGE_UNLOCK_LEVEL} to unlock Surge Burst.`; + recordReactorEvent(tx, { + kind: 'system_locked', + scope: OVERCHARGE_SCOPE, + message, + allowed: false, + }); + locked = { + allowed: false, + action: 'overcharge', + message, + energy: state.energy, + energyDelta: 0n, + retryAfterSeconds: 0, + resetAt: tx.timestamp, + }; + }); + if (locked) return locked; + + const key = actorKey(ctx); + let out: ReturnType | null = null; + ctx.withTx(tx => { + const result = consumeAction( + tx, + OVERCHARGE_SCOPE, + key, + OVERCHARGE_LIMIT, + OVERCHARGE_WINDOW_SECONDS + ); + recordLimitHit(tx, { + ...result, + windowSeconds: OVERCHARGE_WINDOW_SECONDS, + cost: 1, + }); + const state = currentState(tx); + if (!result.allowed || state.overheated) { + const message = result.allowed + ? 'Core is still cooling from the last overheat.' + : `Surge vents cooling down for ${result.retryAfterSeconds}s.`; + const next = putState(tx, { + ...state, + combo: 0, + overheated: state.overheated, + }); + recordReactorEvent(tx, { + kind: 'overcharge_blocked', + scope: OVERCHARGE_SCOPE, + message, + allowed: false, + retryAfterSeconds: result.retryAfterSeconds, + }); + out = { + allowed: false, + action: 'overcharge', + message, + energy: next.energy, + energyDelta: 0n, + retryAfterSeconds: result.retryAfterSeconds, + resetAt: result.resetAt, + }; + return; + } + + const gain = BigInt(state.reactorLevel * 10); + const player = ensurePlayer(tx); + const surgeHeat = Math.max(28, Math.floor(state.heatCapacity * 0.25)); + const heat = clampU32(state.heat + surgeHeat, 0, state.heatCapacity); + const combo = clampU32(state.combo + 3); + const next = putState(tx, { + ...state, + energy: state.energy + gain, + combo, + bestCombo: Math.max(state.bestCombo, combo), + heat, + overheated: heat >= state.heatCapacity, + }); + putPlayer(tx, { + ...player, + contributedEnergy: player.contributedEnergy + gain, + surges: clampU32(player.surges + 1), + }); + const message = + heat >= state.heatCapacity + ? `Surge Burst yielded +${gain.toString()} and blew the safeties.` + : `Surge Burst yielded +${gain.toString()} energy.`; + recordReactorEvent(tx, { + kind: + heat >= state.heatCapacity ? 'overcharge_overheated' : 'overcharge', + scope: OVERCHARGE_SCOPE, + message, + allowed: true, + energyDelta: gain, + }); + out = { + allowed: true, + action: 'overcharge', + message, + energy: next.energy, + energyDelta: gain, + retryAfterSeconds: 0, + resetAt: result.resetAt, + }; + }); + if (!out) throw new Error('reactor.overcharge_tx_failed'); + return out; + } +); + +export const buy_upgrade = spacetimedb.procedure( + { upgradeId: t.string() }, + reactorActionResult, + (ctx, args) => { + const lane = requireUpgradeLane(args.upgradeId); + const key = limitKeyForScope(UPGRADE_SCOPE, ctx); + let out: ReturnType | null = null; + ctx.withTx(tx => { + const upgradeWindowSeconds = upgradeWindowForState( + tx.db.reactorRoomState.singleton.find(true) + ); + const result = consumeAction( + tx, + UPGRADE_SCOPE, + key, + UPGRADE_LIMIT, + upgradeWindowSeconds + ); + recordLimitHit(tx, { + ...result, + windowSeconds: upgradeWindowSeconds, + cost: 1, + }); + const state = currentState(tx); + const offer = upgradeOffer(state, lane); + const cost = offer.cost; + if (!result.allowed) { + const message = `Upgrade bay cooling down for ${result.retryAfterSeconds}s.`; + recordReactorEvent(tx, { + kind: 'upgrade_blocked', + scope: UPGRADE_SCOPE, + message, + allowed: false, + retryAfterSeconds: result.retryAfterSeconds, + }); + out = { + allowed: false, + action: 'upgrade', + message, + energy: state.energy, + energyDelta: 0n, + retryAfterSeconds: result.retryAfterSeconds, + resetAt: result.resetAt, + }; + return; + } + if (state.energy < cost) { + const message = `Need ${cost.toString()} energy for ${offer.name}.`; + recordReactorEvent(tx, { + kind: 'upgrade_insufficient_energy', + scope: UPGRADE_SCOPE, + message, + allowed: false, + }); + out = { + allowed: false, + action: 'upgrade', + message, + energy: state.energy, + energyDelta: 0n, + retryAfterSeconds: 0, + resetAt: result.resetAt, + }; + return; + } + + const player = ensurePlayer(tx); + const upgradeCount = clampU32(state.upgradeCount + 1); + const powerUpgradeCount = + lane === 'power' + ? clampU32(state.powerUpgradeCount + 1) + : state.powerUpgradeCount; + const coolingUpgradeCount = + lane === 'cooling' + ? clampU32(state.coolingUpgradeCount + 1) + : state.coolingUpgradeCount; + const capacityUpgradeCount = + lane === 'capacity' + ? clampU32(state.capacityUpgradeCount + 1) + : state.capacityUpgradeCount; + const chargeUpgradeCount = + lane === 'charges' + ? clampU32(state.chargeUpgradeCount + 1) + : state.chargeUpgradeCount; + const bayUpgradeCount = + lane === 'bay' + ? clampU32(state.bayUpgradeCount + 1) + : state.bayUpgradeCount; + const tuned = tunedState(tx, { + ...state, + upgradeCount, + powerUpgradeCount, + coolingUpgradeCount, + capacityUpgradeCount, + chargeUpgradeCount, + bayUpgradeCount, + reactorLevel: clampU32(1 + powerUpgradeCount * POWER_PER_UPGRADE), + }); + const next = putState(tx, { + ...tuned, + energy: state.energy - cost, + heat: Math.max(0, tuned.heat - 18), + overheated: false, + }); + putPlayer(tx, { + ...player, + upgradesBought: clampU32(player.upgradesBought + 1), + }); + const delta = -cost; + const message = `${offer.name} installed. ${offer.effect}.`; + recordReactorEvent(tx, { + kind: 'upgrade', + scope: UPGRADE_SCOPE, + message, + allowed: true, + energyDelta: delta, + }); + out = { + allowed: true, + action: 'upgrade', + message, + energy: next.energy, + energyDelta: delta, + retryAfterSeconds: 0, + resetAt: result.resetAt, + }; + }); + if (!out) throw new Error('reactor.upgrade_tx_failed'); + return out; + } +); + +export const repair_reactor = spacetimedb.procedure( + {}, + reactorActionResult, + ctx => { + let locked: { + allowed: boolean; + action: string; + message: string; + energy: bigint; + energyDelta: bigint; + retryAfterSeconds: number; + resetAt: Tx['timestamp']; + } | null = null; + ctx.withTx(tx => { + const state = currentState(tx); + if (hasCoolantFlush(state)) return; + const message = `Install Thermal Vents level ${COOLANT_UNLOCK_LEVEL} to unlock Coolant Flush.`; + recordReactorEvent(tx, { + kind: 'system_locked', + scope: REPAIR_SCOPE, + message, + allowed: false, + }); + locked = { + allowed: false, + action: 'repair', + message, + energy: state.energy, + energyDelta: 0n, + retryAfterSeconds: 0, + resetAt: tx.timestamp, + }; + }); + if (locked) return locked; + + const key = actorKey(ctx); + let out: ReturnType | null = null; + ctx.withTx(tx => { + const result = consumeAction( + tx, + REPAIR_SCOPE, + key, + REPAIR_LIMIT, + REPAIR_WINDOW_SECONDS + ); + recordLimitHit(tx, { + ...result, + windowSeconds: REPAIR_WINDOW_SECONDS, + cost: 1, + }); + const state = currentState(tx); + if (!result.allowed) { + const message = `Coolant system cooling down for ${result.retryAfterSeconds}s.`; + recordReactorEvent(tx, { + kind: 'repair_blocked', + scope: REPAIR_SCOPE, + message, + allowed: false, + retryAfterSeconds: result.retryAfterSeconds, + }); + out = { + allowed: false, + action: 'repair', + message, + energy: state.energy, + energyDelta: 0n, + retryAfterSeconds: result.retryAfterSeconds, + resetAt: result.resetAt, + }; + return; + } + + const next = putState(tx, { + ...state, + combo: 0, + heat: Math.max( + 0, + state.heat - Math.max(65, Math.floor(state.heatCapacity * 0.55)) + ), + overheated: false, + }); + const player = ensurePlayer(tx); + putPlayer(tx, { + ...player, + coolantUses: clampU32(player.coolantUses + 1), + }); + const message = 'Coolant flushed.'; + recordReactorEvent(tx, { + kind: 'repair', + scope: REPAIR_SCOPE, + message, + allowed: true, + }); + out = { + allowed: true, + action: 'repair', + message, + energy: next.energy, + energyDelta: 0n, + retryAfterSeconds: 0, + resetAt: result.resetAt, + }; + }); + if (!out) throw new Error('reactor.repair_tx_failed'); + return out; + } +); + +export const runSweep = spacetimedb.procedure( + { maxRows: t.option(t.u32()) }, + t.u32(), + (ctx, args) => { + const maxRows = + args.maxRows === undefined + ? undefined + : toU32('sweep_batch', Number(args.maxRows)); + const deleted = rateLimit.runSweep(ctx.as.rateLimit, { maxRows }); + ctx.withTx(tx => { + const demo = tx.db.rateLimitDemoConfig.singleton.find(true); + const retainEvents = Number(demo?.retainEvents ?? DEFAULT_RETAIN_EVENTS); + const pruneBatch = Number( + demo?.eventPruneBatch ?? DEFAULT_EVENT_PRUNE_BATCH + ); + pruneRateLimitEvents(tx, retainEvents, pruneBatch); + }); + return deleted; + } +); + +export const set_player_color = spacetimedb.reducer( + { color: t.string() }, + (ctx, args) => { + const color = requirePlayerColor(args.color); + const player = ensurePlayer(ctx); + putPlayer(ctx, { + ...player, + color, + }); + } +); + +export const resetDemo = spacetimedb.reducer({}, ctx => { + requireAdmin(ctx); + rateLimit.resetBuckets(ctx.as.rateLimit, {}); + for (const row of ctx.db.rateLimitEvent.iter()) + ctx.db.rateLimitEvent.delete(row); + for (const row of ctx.db.reactorEvent.iter()) ctx.db.reactorEvent.delete(row); + for (const row of ctx.db.reactorPlayerState.iter()) + ctx.db.reactorPlayerState.delete(row); + for (const row of ctx.db.reactorRoomState.iter()) + ctx.db.reactorRoomState.delete(row); +}); + +export const updateConfig = spacetimedb.reducer( + { + sweepBatch: t.option(t.u32()), + retainEvents: t.option(t.u32()), + eventPruneBatch: t.option(t.u32()), + }, + (ctx, args) => { + requireAdmin(ctx); + if (args.sweepBatch !== undefined) { + rateLimit.updateConfig(ctx.as.rateLimit, { + sweepBatch: toU32('sweep_batch', Number(args.sweepBatch)), + }); + } + if (args.retainEvents !== undefined || args.eventPruneBatch !== undefined) { + const demo = ctx.db.rateLimitDemoConfig.singleton.find(true); + if (!demo) throw new Error('rate_limit.demo_config_missing'); + ctx.db.rateLimitDemoConfig.singleton.update({ + ...demo, + retainEvents: + args.retainEvents === undefined + ? demo.retainEvents + : toU32('retain_events', Number(args.retainEvents)), + eventPruneBatch: + args.eventPruneBatch === undefined + ? demo.eventPruneBatch + : toU32('event_prune_batch', Number(args.eventPruneBatch)), + updatedAt: ctx.timestamp, + }); + } + } +); + +export const rate_limit_demo_sweep = spacetimedb.reducer( + { arg: rateLimitDemoSweepTick.rowType }, + (ctx, _args) => { + const demo = ctx.db.rateLimitDemoConfig.singleton.find(true); + const retainEvents = Number(demo?.retainEvents ?? DEFAULT_RETAIN_EVENTS); + const pruneBatch = Number( + demo?.eventPruneBatch ?? DEFAULT_EVENT_PRUNE_BATCH + ); + pruneRateLimitEvents(ctx, retainEvents, pruneBatch); + } +); diff --git a/spacetime-rate-limit-ts/example/spacetimedb/src/model.ts b/spacetime-rate-limit-ts/example/spacetimedb/src/model.ts new file mode 100644 index 00000000000..bf9e2bf316c --- /dev/null +++ b/spacetime-rate-limit-ts/example/spacetimedb/src/model.ts @@ -0,0 +1,74 @@ +import { table, t } from 'spacetimedb/server'; + +export const rateLimitEvent = table( + { name: 'rate_limit_event', public: false }, + { + id: t.u64().primaryKey().autoInc(), + scope: t.string().index(), + key: t.string(), + allowed: t.bool().index(), + limit: t.u32(), + used: t.u32(), + remaining: t.u32(), + retryAfterSeconds: t.u32(), + windowSeconds: t.u32(), + cost: t.u32(), + resetAt: t.timestamp(), + createdAt: t.timestamp().index(), + } +); +export const reactorRoomState = table( + { name: 'reactor_room_state', public: false }, + { + singleton: t.bool().primaryKey(), + energy: t.u64(), + reactorLevel: t.u32(), + upgradeCount: t.u32(), + powerUpgradeCount: t.u32(), + coolingUpgradeCount: t.u32(), + capacityUpgradeCount: t.u32(), + chargeUpgradeCount: t.u32(), + bayUpgradeCount: t.u32(), + combo: t.u32(), + bestCombo: t.u32(), + heat: t.u32(), + heatCapacity: t.u32(), + coolingPerSecond: t.u32(), + tapHeatGain: t.u32(), + overheated: t.bool(), + updatedAt: t.timestamp(), + } +); + +export const reactorPlayerState = table( + { name: 'reactor_player_state', public: false }, + { + identity: t.identity().primaryKey(), + displayName: t.string(), + color: t.string(), + contributedEnergy: t.u64(), + taps: t.u32(), + surges: t.u32(), + coolantUses: t.u32(), + upgradesBought: t.u32(), + joinedAt: t.timestamp(), + updatedAt: t.timestamp().index(), + } +); + +export const reactorEvent = table( + { name: 'reactor_event', public: false }, + { + id: t.u64().primaryKey().autoInc(), + identity: t.identity().index(), + actorName: t.string(), + actorColor: t.string(), + kind: t.string().index(), + scope: t.string(), + message: t.string(), + allowed: t.bool().index(), + energyDelta: t.i64(), + retryAfterSeconds: t.u32(), + createdAt: t.timestamp().index(), + } +); diff --git a/spacetime-rate-limit-ts/example/spacetimedb/src/reactor-rules.ts b/spacetime-rate-limit-ts/example/spacetimedb/src/reactor-rules.ts new file mode 100644 index 00000000000..5288a4ae8bc --- /dev/null +++ b/spacetime-rate-limit-ts/example/spacetimedb/src/reactor-rules.ts @@ -0,0 +1,216 @@ +export const TAP_LIMIT = 8; +export const UPGRADE_WINDOW_SECONDS = 20; +export const COOLANT_UNLOCK_LEVEL = 2; +export const SURGE_UNLOCK_LEVEL = 2; + +const BASE_HEAT_CAPACITY = 100; +const BASE_HEAT_COOL_PER_SECOND = 4; +const BASE_TAP_HEAT_GAIN = 12; +const MAX_CREW_SCALING = 6; +const HEAT_CAPACITY_PER_EXTRA_CREW = 75; +const COOLING_PER_EXTRA_CREW = 4; +const POWER_UPGRADE_BASE_COST = 12; +const COOLING_UPGRADE_BASE_COST = 16; +const CAPACITY_UPGRADE_BASE_COST = 14; +const CHARGE_UPGRADE_BASE_COST = 18; +const BAY_UPGRADE_BASE_COST = 20; +const POWER_UPGRADE_COST_STEP = 18; +const COOLING_UPGRADE_COST_STEP = 18; +const CAPACITY_UPGRADE_COST_STEP = 20; +const CHARGE_UPGRADE_COST_STEP = 22; +const BAY_UPGRADE_COST_STEP = 24; +export const POWER_PER_UPGRADE = 1; +const COOLING_PER_UPGRADE = 2; +const CAPACITY_PER_UPGRADE = 30; +const CHARGES_PER_UPGRADE = 2; +const UPGRADE_WINDOW_REDUCTION_SECONDS = 3; +const MIN_UPGRADE_WINDOW_SECONDS = 5; + +export const PLAYER_COLORS = [ + '#22c7b8', + '#ffce5c', + '#52df8f', + '#ff6a66', + '#aee8ff', + '#d28cff', + '#ff9f6e', + '#8ddf65', +]; + +export type UpgradeLane = 'power' | 'cooling' | 'capacity' | 'charges' | 'bay'; + +export interface UpgradeState { + powerUpgradeCount: number; + coolingUpgradeCount: number; + capacityUpgradeCount: number; + chargeUpgradeCount: number; + bayUpgradeCount: number; +} + +export interface UpgradeOffer { + slot: number; + id: UpgradeLane; + name: string; + description: string; + effect: string; + cost: bigint; + level: number; +} + +function upgradeLevel(state: UpgradeState, lane: UpgradeLane): number { + if (lane === 'power') return Number(state.powerUpgradeCount); + if (lane === 'cooling') return Number(state.coolingUpgradeCount); + if (lane === 'charges') return Number(state.chargeUpgradeCount); + if (lane === 'bay') return Number(state.bayUpgradeCount); + return Number(state.capacityUpgradeCount); +} + +function upgradeCost(lane: UpgradeLane, level: number): bigint { + if (lane === 'power') + return BigInt(POWER_UPGRADE_BASE_COST + level * POWER_UPGRADE_COST_STEP); + if (lane === 'cooling') + return BigInt( + COOLING_UPGRADE_BASE_COST + level * COOLING_UPGRADE_COST_STEP + ); + if (lane === 'charges') + return BigInt(CHARGE_UPGRADE_BASE_COST + level * CHARGE_UPGRADE_COST_STEP); + if (lane === 'bay') + return BigInt(BAY_UPGRADE_BASE_COST + level * BAY_UPGRADE_COST_STEP); + return BigInt( + CAPACITY_UPGRADE_BASE_COST + level * CAPACITY_UPGRADE_COST_STEP + ); +} + +export function upgradeWindowForState( + state: Pick | null | undefined +): number { + const level = Number(state?.bayUpgradeCount ?? 0); + return Math.max( + MIN_UPGRADE_WINDOW_SECONDS, + UPGRADE_WINDOW_SECONDS - level * UPGRADE_WINDOW_REDUCTION_SECONDS + ); +} + +export function upgradeOffer( + state: UpgradeState, + lane: UpgradeLane +): UpgradeOffer { + const level = upgradeLevel(state, lane); + if (lane === 'power') { + return { + slot: 0, + id: lane, + name: 'Plasma Coils', + description: + level + 1 === SURGE_UNLOCK_LEVEL + ? 'More tap energy. Unlocks Surge Burst.' + : level >= SURGE_UNLOCK_LEVEL + ? 'More tap energy. Stronger surges.' + : 'More energy per tap.', + effect: `+${POWER_PER_UPGRADE} energy per tap${level + 1 === SURGE_UNLOCK_LEVEL ? ' + unlock Surge Burst' : ''}`, + cost: upgradeCost(lane, level), + level, + }; + } + if (lane === 'cooling') { + return { + slot: 1, + id: lane, + name: 'Thermal Vents', + description: + level + 1 === COOLANT_UNLOCK_LEVEL + ? 'Faster cooling. Unlocks Coolant Flush.' + : level >= COOLANT_UNLOCK_LEVEL + ? 'Faster cooling. Quicker Coolant recharge.' + : 'Faster passive cooling.', + effect: `+${COOLING_PER_UPGRADE} cooling / sec${level + 1 === COOLANT_UNLOCK_LEVEL ? ' + unlock Coolant Flush' : ''}`, + cost: upgradeCost(lane, level), + level, + }; + } + if (lane === 'charges') { + return { + slot: 3, + id: lane, + name: 'Tap Batteries', + description: 'More taps before a recharge.', + effect: `+${CHARGES_PER_UPGRADE} tap charges`, + cost: upgradeCost(lane, level), + level, + }; + } + if (lane === 'bay') { + return { + slot: 4, + id: lane, + name: 'Upgrade Bay', + description: 'Shorter cooldown between installs.', + effect: `-${UPGRADE_WINDOW_REDUCTION_SECONDS}s shop cooldown`, + cost: upgradeCost(lane, level), + level, + }; + } + return { + slot: 2, + id: lane, + name: 'Heat Sinks', + description: 'More heat before the core overheats.', + effect: `+${CAPACITY_PER_UPGRADE} heat capacity`, + cost: upgradeCost(lane, level), + level, + }; +} + +export function tapLimitForState( + state: Pick +): number { + return TAP_LIMIT + Number(state.chargeUpgradeCount) * CHARGES_PER_UPGRADE; +} + +export function hasCoolantFlush( + state: Pick +): boolean { + return state.coolingUpgradeCount >= COOLANT_UNLOCK_LEVEL; +} + +export function hasSurgeBurst( + state: Pick +): boolean { + return state.powerUpgradeCount >= SURGE_UNLOCK_LEVEL; +} + +export function overheatRecoverAt(state: { heatCapacity: number }): number { + return Math.floor(state.heatCapacity * 0.45); +} + +export function roomTuning( + state: Pick, + activeCrew: number +): { heatCapacity: number; coolingPerSecond: number; tapHeatGain: number } { + const crew = Math.max(1, Math.min(MAX_CREW_SCALING, Math.trunc(activeCrew))); + const clampU32 = (value: number) => + Math.max(0, Math.min(0xffff_ffff, Math.trunc(value))); + return { + heatCapacity: clampU32( + BASE_HEAT_CAPACITY + + Number(state.capacityUpgradeCount) * CAPACITY_PER_UPGRADE + + Math.max(0, crew - 1) * HEAT_CAPACITY_PER_EXTRA_CREW + ), + coolingPerSecond: clampU32( + BASE_HEAT_COOL_PER_SECOND + + Number(state.coolingUpgradeCount) * COOLING_PER_UPGRADE + + Math.max(0, crew - 1) * COOLING_PER_EXTRA_CREW + ), + tapHeatGain: BASE_TAP_HEAT_GAIN, + }; +} + +export function playerName(identity: { toHexString(): string }): string { + return `Crewmate ${identity.toHexString().slice(0, 6).toUpperCase()}`; +} + +export function playerColor(identity: { toHexString(): string }): string { + const hex = identity.toHexString().slice(-8); + const index = Number.parseInt(hex, 16) % PLAYER_COLORS.length; + return PLAYER_COLORS[index]!; +} diff --git a/spacetime-rate-limit-ts/example/spacetimedb/tsconfig.json b/spacetime-rate-limit-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..48cc3989f52 --- /dev/null +++ b/spacetime-rate-limit-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/spacetime-rate-limit-ts/example/src/app.ts b/spacetime-rate-limit-ts/example/src/app.ts new file mode 100644 index 00000000000..d1fb33f52b7 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/app.ts @@ -0,0 +1,409 @@ +import { + DbConnection, + type ErrorContext, + type EventContext, +} from './codegen/app/index.ts'; + +type TimestampLike = { microsSinceUnixEpoch: bigint }; + +type ReactorState = { + singleton: boolean; + energy: bigint; + reactorLevel: number; + upgradeCount: number; + powerUpgradeCount: number; + coolingUpgradeCount: number; + capacityUpgradeCount: number; + chargeUpgradeCount: number; + bayUpgradeCount: number; + combo: number; + bestCombo: number; + heat: number; + heatCapacity: number; + coolingPerSecond: number; + tapHeatGain: number; + overheated: boolean; + updatedAt: TimestampLike; +}; + +type ReactorEvent = { + id: bigint; + identity: unknown; + actorName: string; + actorColor: string; + kind: string; + scope: string; + message: string; + allowed: boolean; + energyDelta: bigint; + retryAfterSeconds: number; + createdAt: TimestampLike; +}; + +type ReactorPlayer = { + identity: unknown; + displayName: string; + color: string; + contributedEnergy: bigint; + taps: number; + surges: number; + coolantUses: number; + upgradesBought: number; + joinedAt: TimestampLike; + updatedAt: TimestampLike; +}; + +type ReactorLimitStatus = { + scope: string; + label: string; + limit: number; + windowSeconds: number; + used: number; + remaining: number; + resetAt?: TimestampLike; +}; + +type ReactorShopItem = { + slot: number; + id: string; + name: string; + description: string; + effect: string; + cost: bigint; + available: boolean; +}; + +type RateLimitDemoConfig = { + singleton: boolean; + retainEvents: number; + eventPruneBatch: number; + updatedAt: TimestampLike; +}; + +type ReactorActionResult = { + allowed: boolean; + action: string; + message: string; + energy: bigint; + energyDelta: bigint; + retryAfterSeconds: number; + resetAt: TimestampLike; +}; + +type ReactorActions = { + start: () => Promise; + tap: () => Promise; + overcharge: () => Promise; + buyUpgrade: (upgradeId: string) => Promise; + repair: () => Promise; + setPlayerColor: (color: string) => Promise; + runSweep: (maxRows?: number) => Promise; + resetDemo: () => Promise; + updateConfig: (args: { + sweepBatch?: number; + retainEvents?: number; + eventPruneBatch?: number; + }) => Promise; +}; + +declare global { + interface Window { + reactor?: ReactorActions; + } +} + +interface ServerConfig { + stdbUri: string; + appDatabase: string; +} + +type TableEvents = { + iter(): Iterable; + onInsert(cb: (ctx: EventContext, row: T) => void): void; + onUpdate(cb: (ctx: EventContext, old: T, row: T) => void): void; + onDelete(cb: (ctx: EventContext, row: T) => void): void; +}; + +type SingletonTable = { + singleton: { find(key: boolean): T | null | undefined }; + onInsert(cb: (ctx: EventContext, row: T) => void): void; + onUpdate(cb: (ctx: EventContext, old: T, row: T) => void): void; + onDelete?(cb: (ctx: EventContext, row: T) => void): void; +}; + +type NamespacedDb = DbConnection['db'] & { + reactorState: TableEvents; + reactorEvents: TableEvents; + reactorLimitStatus: TableEvents; + reactorPlayers: TableEvents; + reactorShop: TableEvents; + rateLimitDemoConfig: SingletonTable; +}; + +let currentConn: DbConnection | null = null; +let reconnectTimer: ReturnType | null = null; +let reconnectAttempt = 0; +let serverConfig: ServerConfig | null = null; +let currentIdentityHex: string | null = null; + +const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; +const CONNECT_TIMEOUT_MS = 8000; +const TOKEN_STORAGE_PREFIX = 'reactor-clicker.stdb-token'; + +function broadcastConn( + state: 'connecting' | 'connected' | 'error', + detail?: string +): void { + window.dispatchEvent( + new CustomEvent('reactor:connState', { detail: { state, detail } }) + ); +} + +function broadcastState(): void { + if (!currentConn) { + window.dispatchEvent( + new CustomEvent('reactor:data', { + detail: { + state: null, + events: [], + players: [], + statuses: [], + shop: [], + demoConfig: null, + currentIdentityHex: null, + }, + }) + ); + return; + } + + const db = currentConn.db as NamespacedDb; + window.dispatchEvent( + new CustomEvent('reactor:data', { + detail: { + state: [...db.reactorState.iter()][0] ?? null, + events: [...db.reactorEvents.iter()].sort((a, b) => + a.id < b.id ? 1 : a.id > b.id ? -1 : 0 + ), + players: [...db.reactorPlayers.iter()].sort((a, b) => { + if (a.contributedEnergy === b.contributedEnergy) + return a.displayName.localeCompare(b.displayName); + return a.contributedEnergy < b.contributedEnergy ? 1 : -1; + }), + statuses: [...db.reactorLimitStatus.iter()].sort((a, b) => + a.label.localeCompare(b.label) + ), + shop: [...db.reactorShop.iter()].sort((a, b) => a.slot - b.slot), + demoConfig: db.rateLimitDemoConfig.singleton.find(true) ?? null, + currentIdentityHex, + }, + }) + ); +} + +function broadcastEventInsert(row: ReactorEvent): void { + window.dispatchEvent( + new CustomEvent('reactor:eventInserted', { detail: { event: row } }) + ); +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + return (await r.json()) as ServerConfig; +} + +function isStoredTokenAuthError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return /unauthorized|verify token|websocket-token/i.test(message); +} + +function openConnection( + cfg: ServerConfig, + tokenKey: string, + token?: string +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error('stdb.connect_timeout')); + }, CONNECT_TIMEOUT_MS); + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + + const builder = DbConnection.builder() + .withUri(cfg.stdbUri) + .withDatabaseName(cfg.appDatabase) + .onConnect((conn, identity, nextToken) => + settle(() => { + currentIdentityHex = + typeof (identity as { toHexString?: () => string }).toHexString === + 'function' + ? (identity as { toHexString: () => string }).toHexString() + : String(identity); + window.localStorage.setItem(tokenKey, nextToken); + resolve(conn); + }) + ) + .onDisconnect((_ctx, err) => { + currentConn = null; + window.reactor = undefined; + broadcastConn('error', err?.message ?? 'disconnected'); + scheduleReconnect(); + }) + .onConnectError((_ctx, err) => settle(() => reject(err))); + if (token) builder.withToken(token); + builder.build(); + }); +} + +async function connect(cfg: ServerConfig): Promise { + const tokenKey = `${TOKEN_STORAGE_PREFIX}.${cfg.stdbUri}.${cfg.appDatabase}`; + const token = window.localStorage.getItem(tokenKey) ?? undefined; + try { + return await openConnection(cfg, tokenKey, token); + } catch (err) { + if (token && isStoredTokenAuthError(err)) { + window.localStorage.removeItem(tokenKey); + return openConnection(cfg, tokenKey); + } + throw err; + } +} + +function scheduleReconnect(): void { + if (reconnectTimer != null) return; + const delay = + RECONNECT_DELAYS_MS[ + Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1) + ]; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + reconnectAttempt++; + run().catch(err => { + broadcastConn('error', err instanceof Error ? err.message : String(err)); + scheduleReconnect(); + }); + }, delay); +} + +function wireDataHandlers(conn: DbConnection): void { + const db = conn.db as NamespacedDb; + let subscriptionsApplied = false; + const seenEventIds = new Set(); + + conn + .subscriptionBuilder() + .onApplied(() => { + for (const row of db.reactorEvents.iter()) { + seenEventIds.add(row.id.toString()); + } + subscriptionsApplied = true; + broadcastState(); + }) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe([ + 'SELECT * FROM reactor_state', + 'SELECT * FROM reactor_events', + 'SELECT * FROM reactor_limit_status', + 'SELECT * FROM reactor_players', + 'SELECT * FROM reactor_shop', + 'SELECT * FROM rate_limit_demo_config', + ]); + + db.reactorState.onInsert(() => broadcastState()); + db.reactorState.onUpdate(() => broadcastState()); + db.reactorState.onDelete(() => broadcastState()); + db.reactorEvents.onInsert((_ctx, row) => { + const id = row.id.toString(); + const isNewLiveEvent = subscriptionsApplied && !seenEventIds.has(id); + seenEventIds.add(id); + if (isNewLiveEvent) broadcastEventInsert(row); + broadcastState(); + }); + db.reactorEvents.onUpdate(() => broadcastState()); + db.reactorEvents.onDelete(() => broadcastState()); + db.reactorLimitStatus.onInsert(() => broadcastState()); + db.reactorLimitStatus.onUpdate(() => broadcastState()); + db.reactorLimitStatus.onDelete(() => broadcastState()); + db.reactorPlayers.onInsert(() => broadcastState()); + db.reactorPlayers.onUpdate(() => broadcastState()); + db.reactorPlayers.onDelete(() => broadcastState()); + db.reactorShop.onInsert(() => broadcastState()); + db.reactorShop.onUpdate(() => broadcastState()); + db.reactorShop.onDelete(() => broadcastState()); + db.rateLimitDemoConfig.onInsert(() => broadcastState()); + db.rateLimitDemoConfig.onUpdate(() => broadcastState()); + db.rateLimitDemoConfig.onDelete?.(() => broadcastState()); +} + +function requireConn(): DbConnection { + if (!currentConn) throw new Error('stdb.disconnected'); + return currentConn; +} + +function installReactorActions(): ReactorActions { + const actions: ReactorActions = { + start: async () => requireConn().procedures.startReactor({}), + tap: async () => requireConn().procedures.tapReactor({}), + overcharge: async () => requireConn().procedures.overcharge({}), + buyUpgrade: async (upgradeId: string) => + requireConn().procedures.buyUpgrade({ upgradeId }), + repair: async () => requireConn().procedures.repairReactor({}), + setPlayerColor: async (color: string) => { + requireConn().reducers.setPlayerColor({ color }); + }, + runSweep: async (maxRows?: number) => + requireConn().procedures.runSweep({ maxRows }), + resetDemo: async () => { + requireConn().reducers.resetDemo({}); + }, + updateConfig: async args => { + requireConn().reducers.updateConfig({ + sweepBatch: args.sweepBatch, + retainEvents: args.retainEvents, + eventPruneBatch: args.eventPruneBatch, + }); + }, + }; + window.reactor = actions; + return actions; +} + +async function run(): Promise { + window.reactor = undefined; + broadcastConn('connecting'); + if (!serverConfig) { + serverConfig = await loadServerConfig(); + } + try { + const conn = await connect(serverConfig); + currentConn = conn; + reconnectAttempt = 0; + wireDataHandlers(conn); + const reactor = installReactorActions(); + window.dispatchEvent(new CustomEvent('reactor:ready')); + broadcastConn('connected'); + reactor.start().catch((err: unknown) => { + broadcastConn('error', err instanceof Error ? err.message : String(err)); + }); + } catch (err) { + currentConn = null; + window.reactor = undefined; + throw err; + } +} + +run().catch(err => { + console.error('reactor connection failed', err); + broadcastConn('error', err instanceof Error ? err.message : String(err)); + scheduleReconnect(); +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/buy_upgrade_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/buy_upgrade_procedure.ts new file mode 100644 index 00000000000..709e3a65208 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/buy_upgrade_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { + upgradeId: __t.string(), +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/index.ts b/spacetime-rate-limit-ts/example/src/codegen/app/index.ts new file mode 100644 index 00000000000..e7b95496647 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/index.ts @@ -0,0 +1,257 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 ResetDemoReducer from "./reset_demo_reducer"; +import SetPlayerColorReducer from "./set_player_color_reducer"; +import UpdateConfigReducer from "./update_config_reducer"; + +// Import all procedure arg schemas +import * as BuyUpgradeProcedure from "./buy_upgrade_procedure"; +import * as OverchargeProcedure from "./overcharge_procedure"; +import * as RepairReactorProcedure from "./repair_reactor_procedure"; +import * as RunSweepProcedure from "./run_sweep_procedure"; +import * as StartReactorProcedure from "./start_reactor_procedure"; +import * as TapReactorProcedure from "./tap_reactor_procedure"; + +// Import all table schema definitions +import RateLimitDemoConfigRow from "./rate_limit_demo_config_table"; +import RateLimitEventsAdminRow from "./rate_limit_events_admin_table"; +import ReactorEventsRow from "./reactor_events_table"; +import ReactorLimitStatusRow from "./reactor_limit_status_table"; +import ReactorPlayersRow from "./reactor_players_table"; +import ReactorShopRow from "./reactor_shop_table"; +import ReactorStateRow from "./reactor_state_table"; + +// Import namespace table schema definitions +import RateLimit_RateLimitConfigRow from "./rateLimit/rate_limit_config_table"; +import RateLimit_AdminRateLimitBucketsRow from "./rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import RateLimit_AddRateLimitAdminReducer from "./rateLimit/add_rate_limit_admin_reducer"; +import RateLimit_ResetBucketsReducer from "./rateLimit/reset_buckets_reducer"; +import RateLimit_UpdateConfigReducer from "./rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as RateLimit_ConsumeProcedure from "./rateLimit/consume_procedure"; +import * as RateLimit_RunSweepProcedure from "./rateLimit/run_sweep_procedure"; + +/** 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({ + rateLimitDemoConfig: __table({ + name: 'rate_limit_demo_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_demo_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_demo_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, RateLimitDemoConfigRow), + rateLimitEventsAdmin: __table({ + name: 'rate_limit_events_admin', + indexes: [ + ], + constraints: [ + ], + }, RateLimitEventsAdminRow), + reactorEvents: __table({ + name: 'reactor_events', + indexes: [ + ], + constraints: [ + ], + }, ReactorEventsRow), + reactorLimitStatus: __table({ + name: 'reactor_limit_status', + indexes: [ + ], + constraints: [ + ], + }, ReactorLimitStatusRow), + reactorPlayers: __table({ + name: 'reactor_players', + indexes: [ + ], + constraints: [ + ], + }, ReactorPlayersRow), + reactorShop: __table({ + name: 'reactor_shop', + indexes: [ + ], + constraints: [ + ], + }, ReactorShopRow), + reactorState: __table({ + name: 'reactor_state', + indexes: [ + ], + constraints: [ + ], + }, ReactorStateRow), + "rateLimit.rate_limit_config": __table({ + name: 'rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, RateLimit_RateLimitConfigRow), + "rateLimit.admin_rate_limit_buckets": __table({ + name: 'rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, RateLimit_AdminRateLimitBucketsRow), +}); + +/** 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( + __reducerSchema("reset_demo", ResetDemoReducer), + __reducerSchema("set_player_color", SetPlayerColorReducer), + __reducerSchema("update_config", UpdateConfigReducer), + __reducerSchema("rateLimit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer), + __reducerSchema("rateLimit.reset_buckets", RateLimit_ResetBucketsReducer), + __reducerSchema("rateLimit.update_config", RateLimit_UpdateConfigReducer), +); + +/** 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("buy_upgrade", BuyUpgradeProcedure.params, BuyUpgradeProcedure.returnType), + __procedureSchema("overcharge", OverchargeProcedure.params, OverchargeProcedure.returnType), + __procedureSchema("repair_reactor", RepairReactorProcedure.params, RepairReactorProcedure.returnType), + __procedureSchema("run_sweep", RunSweepProcedure.params, RunSweepProcedure.returnType), + __procedureSchema("start_reactor", StartReactorProcedure.params, StartReactorProcedure.returnType), + __procedureSchema("tap_reactor", TapReactorProcedure.params, TapReactorProcedure.returnType), + __procedureSchema("rateLimit.consume", RateLimit_ConsumeProcedure.params, RateLimit_ConsumeProcedure.returnType), + __procedureSchema("rateLimit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + rateLimitDemoConfig: __qb.rateLimitDemoConfig, + rateLimitEventsAdmin: __qb.rateLimitEventsAdmin, + reactorEvents: __qb.reactorEvents, + reactorLimitStatus: __qb.reactorLimitStatus, + reactorPlayers: __qb.reactorPlayers, + reactorShop: __qb.reactorShop, + reactorState: __qb.reactorState, + rateLimit: { + rateLimitConfig: __qb["rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["rateLimit.admin_rate_limit_buckets"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + resetDemo: __reducerAccessors.resetDemo, + setPlayerColor: __reducerAccessors.setPlayerColor, + updateConfig: __reducerAccessors.updateConfig, + rateLimit: { + addRateLimitAdmin: __reducerAccessors["rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["rateLimit.updateConfig"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + buyUpgrade: __procedureAccessors.buyUpgrade, + overcharge: __procedureAccessors.overcharge, + repairReactor: __procedureAccessors.repairReactor, + runSweep: __procedureAccessors.runSweep, + startReactor: __procedureAccessors.startReactor, + tapReactor: __procedureAccessors.tapReactor, + rateLimit: { + consume: __procedureAccessors["rateLimit.consume"], + runSweep: __procedureAccessors["rateLimit.runSweep"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/overcharge_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/overcharge_procedure.ts new file mode 100644 index 00000000000..d53ce96cf57 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/overcharge_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/consume_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/types.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_demo_config_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_demo_config_table.ts new file mode 100644 index 00000000000..bcb7aa20309 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_demo_config_table.ts @@ -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({ + singleton: __t.bool().primaryKey(), + retainEvents: __t.u32().name("retain_events"), + eventPruneBatch: __t.u32().name("event_prune_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_events_admin_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_events_admin_table.ts new file mode 100644 index 00000000000..d6d861cbb6f --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_events_admin_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + scope: __t.string(), + key: __t.string(), + allowed: __t.bool(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32().name("retry_after_seconds"), + windowSeconds: __t.u32().name("window_seconds"), + cost: __t.u32(), + resetAt: __t.timestamp().name("reset_at"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_events_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_events_table.ts new file mode 100644 index 00000000000..207ec768f97 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_events_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + identity: __t.identity(), + actorName: __t.string().name("actor_name"), + actorColor: __t.string().name("actor_color"), + kind: __t.string(), + scope: __t.string(), + message: __t.string(), + allowed: __t.bool(), + energyDelta: __t.i64().name("energy_delta"), + retryAfterSeconds: __t.u32().name("retry_after_seconds"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_limit_status_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_limit_status_table.ts new file mode 100644 index 00000000000..fc9f0392d1e --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_limit_status_table.ts @@ -0,0 +1,21 @@ +// 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({ + scope: __t.string(), + label: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32().name("window_seconds"), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.option(__t.timestamp()).name("reset_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_players_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_players_table.ts new file mode 100644 index 00000000000..e9c658484b7 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_players_table.ts @@ -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({ + identity: __t.identity(), + displayName: __t.string().name("display_name"), + color: __t.string(), + contributedEnergy: __t.u64().name("contributed_energy"), + taps: __t.u32(), + surges: __t.u32(), + coolantUses: __t.u32().name("coolant_uses"), + upgradesBought: __t.u32().name("upgrades_bought"), + joinedAt: __t.timestamp().name("joined_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_shop_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_shop_table.ts new file mode 100644 index 00000000000..5a0217589e6 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_shop_table.ts @@ -0,0 +1,21 @@ +// 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({ + slot: __t.u32(), + id: __t.string(), + name: __t.string(), + description: __t.string(), + effect: __t.string(), + cost: __t.u64(), + available: __t.bool(), +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_state_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_state_table.ts new file mode 100644 index 00000000000..422838dd710 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_state_table.ts @@ -0,0 +1,31 @@ +// 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({ + singleton: __t.bool().primaryKey(), + energy: __t.u64(), + reactorLevel: __t.u32().name("reactor_level"), + upgradeCount: __t.u32().name("upgrade_count"), + powerUpgradeCount: __t.u32().name("power_upgrade_count"), + coolingUpgradeCount: __t.u32().name("cooling_upgrade_count"), + capacityUpgradeCount: __t.u32().name("capacity_upgrade_count"), + chargeUpgradeCount: __t.u32().name("charge_upgrade_count"), + bayUpgradeCount: __t.u32().name("bay_upgrade_count"), + combo: __t.u32(), + bestCombo: __t.u32().name("best_combo"), + heat: __t.u32(), + heatCapacity: __t.u32().name("heat_capacity"), + coolingPerSecond: __t.u32().name("cooling_per_second"), + tapHeatGain: __t.u32().name("tap_heat_gain"), + overheated: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/repair_reactor_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/repair_reactor_procedure.ts new file mode 100644 index 00000000000..d53ce96cf57 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/repair_reactor_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reset_demo_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reset_demo_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/reset_demo_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/run_sweep_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/set_player_color_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/set_player_color_reducer.ts new file mode 100644 index 00000000000..42ec3238c75 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/set_player_color_reducer.ts @@ -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 { + color: __t.string(), +}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/start_reactor_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/start_reactor_procedure.ts new file mode 100644 index 00000000000..d53ce96cf57 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/start_reactor_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/tap_reactor_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/tap_reactor_procedure.ts new file mode 100644 index 00000000000..d53ce96cf57 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/tap_reactor_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/types.ts b/spacetime-rate-limit-ts/example/src/codegen/app/types.ts new file mode 100644 index 00000000000..fd9c231c367 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/types.ts @@ -0,0 +1,157 @@ +// 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 const RateLimitDemoConfig = __t.object("RateLimitDemoConfig", { + singleton: __t.bool(), + retainEvents: __t.u32(), + eventPruneBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitDemoConfig = __Infer; + +export const RateLimitDemoSweepTick = __t.object("RateLimitDemoSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitDemoSweepTick = __Infer; + +export const RateLimitEvent = __t.object("RateLimitEvent", { + id: __t.u64(), + scope: __t.string(), + key: __t.string(), + allowed: __t.bool(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.u32(), + resetAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type RateLimitEvent = __Infer; + +export const RateLimitEventsAdmin = __t.object("RateLimitEventsAdmin", {}); +export type RateLimitEventsAdmin = __Infer; + +export const ReactorActionResult = __t.object("ReactorActionResult", { + allowed: __t.bool(), + action: __t.string(), + message: __t.string(), + energy: __t.u64(), + energyDelta: __t.i64(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type ReactorActionResult = __Infer; + +export const ReactorEvent = __t.object("ReactorEvent", { + id: __t.u64(), + identity: __t.identity(), + actorName: __t.string(), + actorColor: __t.string(), + kind: __t.string(), + scope: __t.string(), + message: __t.string(), + allowed: __t.bool(), + energyDelta: __t.i64(), + retryAfterSeconds: __t.u32(), + createdAt: __t.timestamp(), +}); +export type ReactorEvent = __Infer; + +export const ReactorEvents = __t.object("ReactorEvents", {}); +export type ReactorEvents = __Infer; + +export const ReactorLimitStatus = __t.object("ReactorLimitStatus", {}); +export type ReactorLimitStatus = __Infer; + +export const ReactorLimitStatusRow = __t.object("ReactorLimitStatusRow", { + scope: __t.string(), + label: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.option(__t.timestamp()), +}); +export type ReactorLimitStatusRow = __Infer; + +export const ReactorPlayerRow = __t.object("ReactorPlayerRow", { + identity: __t.identity(), + displayName: __t.string(), + color: __t.string(), + contributedEnergy: __t.u64(), + taps: __t.u32(), + surges: __t.u32(), + coolantUses: __t.u32(), + upgradesBought: __t.u32(), + joinedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ReactorPlayerRow = __Infer; + +export const ReactorPlayerState = __t.object("ReactorPlayerState", { + identity: __t.identity(), + displayName: __t.string(), + color: __t.string(), + contributedEnergy: __t.u64(), + taps: __t.u32(), + surges: __t.u32(), + coolantUses: __t.u32(), + upgradesBought: __t.u32(), + joinedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ReactorPlayerState = __Infer; + +export const ReactorPlayers = __t.object("ReactorPlayers", {}); +export type ReactorPlayers = __Infer; + +export const ReactorRoomState = __t.object("ReactorRoomState", { + singleton: __t.bool(), + energy: __t.u64(), + reactorLevel: __t.u32(), + upgradeCount: __t.u32(), + powerUpgradeCount: __t.u32(), + coolingUpgradeCount: __t.u32(), + capacityUpgradeCount: __t.u32(), + chargeUpgradeCount: __t.u32(), + bayUpgradeCount: __t.u32(), + combo: __t.u32(), + bestCombo: __t.u32(), + heat: __t.u32(), + heatCapacity: __t.u32(), + coolingPerSecond: __t.u32(), + tapHeatGain: __t.u32(), + overheated: __t.bool(), + updatedAt: __t.timestamp(), +}); +export type ReactorRoomState = __Infer; + +export const ReactorShop = __t.object("ReactorShop", {}); +export type ReactorShop = __Infer; + +export const ReactorShopItemRow = __t.object("ReactorShopItemRow", { + slot: __t.u32(), + id: __t.string(), + name: __t.string(), + description: __t.string(), + effect: __t.string(), + cost: __t.u64(), + available: __t.bool(), +}); +export type ReactorShopItemRow = __Infer; + +export const ReactorState = __t.object("ReactorState", {}); +export type ReactorState = __Infer; + diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/types/procedures.ts b/spacetime-rate-limit-ts/example/src/codegen/app/types/procedures.ts new file mode 100644 index 00000000000..44dde79c5fd --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/types/procedures.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as BuyUpgradeProcedure from "../buy_upgrade_procedure"; +import * as OverchargeProcedure from "../overcharge_procedure"; +import * as RepairReactorProcedure from "../repair_reactor_procedure"; +import * as RunSweepProcedure from "../run_sweep_procedure"; +import * as StartReactorProcedure from "../start_reactor_procedure"; +import * as TapReactorProcedure from "../tap_reactor_procedure"; + +export type BuyUpgradeArgs = __Infer; +export type BuyUpgradeResult = __Infer; +export type OverchargeArgs = __Infer; +export type OverchargeResult = __Infer; +export type RepairReactorArgs = __Infer; +export type RepairReactorResult = __Infer; +export type RunSweepArgs = __Infer; +export type RunSweepResult = __Infer; +export type StartReactorArgs = __Infer; +export type StartReactorResult = __Infer; +export type TapReactorArgs = __Infer; +export type TapReactorResult = __Infer; + diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/types/reducers.ts b/spacetime-rate-limit-ts/example/src/codegen/app/types/reducers.ts new file mode 100644 index 00000000000..3bed08e992d --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/types/reducers.ts @@ -0,0 +1,16 @@ +// 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 +import ResetDemoReducer from "../reset_demo_reducer"; +import SetPlayerColorReducer from "../set_player_color_reducer"; +import UpdateConfigReducer from "../update_config_reducer"; + +export type ResetDemoParams = __Infer; +export type SetPlayerColorParams = __Infer; +export type UpdateConfigParams = __Infer; + diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/update_config_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/update_config_reducer.ts new file mode 100644 index 00000000000..d7ddcf900e6 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/codegen/app/update_config_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + sweepBatch: __t.option(__t.u32()), + retainEvents: __t.option(__t.u32()), + eventPruneBatch: __t.option(__t.u32()), +}; diff --git a/spacetime-rate-limit-ts/example/tsconfig.json b/spacetime-rate-limit-ts/example/tsconfig.json new file mode 100644 index 00000000000..ae0d0a4d3c3 --- /dev/null +++ b/spacetime-rate-limit-ts/example/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-rate-limit-ts/package.json b/spacetime-rate-limit-ts/package.json new file mode 100644 index 00000000000..6849acab585 --- /dev/null +++ b/spacetime-rate-limit-ts/package.json @@ -0,0 +1,64 @@ +{ + "name": "@spacetimedb/rate-limit", + "description": "Fixed-window rate limiting and bounded expiration sweeps for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./limit": { + "types": "./src/limit.ts", + "default": "./src/limit.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-rate-limit-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-rate-limit-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "rate-limit", + "security", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-rate-limit-ts/scripts/test.ts b/spacetime-rate-limit-ts/scripts/test.ts new file mode 100644 index 00000000000..5070563624c --- /dev/null +++ b/spacetime-rate-limit-ts/scripts/test.ts @@ -0,0 +1,150 @@ +import { Timestamp } from 'spacetimedb'; +import { + consumeRateLimit, + sweepRateLimits, + type RateLimitBucketRow, +} from '../src/limit.ts'; +import { buildRateLimitKey } from '../src/key.ts'; + +let pass = 0; +let fail = 0; + +function assert(cond: boolean, name: string, detail = ''): void { + if (cond) { + pass++; + process.stdout.write(` ok ${name}\n`); + } else { + fail++; + process.stdout.write( + ` FAIL ${name}${detail ? `\n ${detail}` : ''}\n` + ); + } +} + +function makeTx(nowMicros = 0n) { + const rows = new Map(); + const tx = { + timestamp: new Timestamp(nowMicros), + db: { + rateLimitBucket: { + key: { + find: (key: string) => rows.get(key), + update: (row: RateLimitBucketRow) => rows.set(row.key, row), + }, + insert: (row: RateLimitBucketRow) => rows.set(row.key, row), + delete: (row: RateLimitBucketRow) => rows.delete(row.key), + expiresAt: { + filter: function* () { + yield* [...rows.values()].sort((a, b) => + a.expiresAt.microsSinceUnixEpoch < + b.expiresAt.microsSinceUnixEpoch + ? -1 + : 1 + ); + }, + }, + }, + }, + rows, + }; + return tx; +} + +{ + const tx = makeTx(); + consumeRateLimit(tx, { + key: 'fresh-a', + scope: 's', + limit: 1, + windowSeconds: 100, + }); + consumeRateLimit(tx, { + key: 'fresh-b', + scope: 's', + limit: 1, + windowSeconds: 100, + }); + consumeRateLimit(tx, { + key: 'expired', + scope: 's', + limit: 1, + windowSeconds: 1, + }); + tx.timestamp = new Timestamp(2_000_000n); + const deleted = sweepRateLimits( + tx, + tx.db.rateLimitBucket.expiresAt.filter(), + 2 + ); + assert(deleted === 1, 'sweep reaches expired buckets beyond fresh inserts'); + assert(!tx.rows.has('expired'), 'indexed sweep removes the expired bucket'); +} + +process.stdout.write('\nrate limiter\n'); + +assert( + buildRateLimitKey('a:actor:b', 'c') !== buildRateLimitKey('a', 'b:actor:c'), + 'compound keys cannot collide through delimiters' +); + +{ + const tx = makeTx(); + const one = consumeRateLimit(tx, { + key: 'auth.login:ip:1', + scope: 'auth.login', + limit: 2, + windowSeconds: 60, + }); + const two = consumeRateLimit(tx, { + key: 'auth.login:ip:1', + scope: 'auth.login', + limit: 2, + windowSeconds: 60, + }); + const three = consumeRateLimit(tx, { + key: 'auth.login:ip:1', + scope: 'auth.login', + limit: 2, + windowSeconds: 60, + }); + assert(one.allowed && one.remaining === 1, 'first request allowed'); + assert(two.allowed && two.remaining === 0, 'second request allowed'); + assert( + !three.allowed && three.retryAfterSeconds === 60, + 'third request blocked' + ); +} + +{ + const tx = makeTx(); + consumeRateLimit(tx, { + key: 'k', + scope: 's', + limit: 1, + windowSeconds: 60, + }); + tx.timestamp = new Timestamp(61_000_000n); + const next = consumeRateLimit(tx, { + key: 'k', + scope: 's', + limit: 1, + windowSeconds: 60, + }); + assert(next.allowed && next.used === 1, 'expired window resets'); +} + +{ + const tx = makeTx(); + consumeRateLimit(tx, { + key: 'k', + scope: 's', + limit: 1, + windowSeconds: 1, + }); + tx.timestamp = new Timestamp(2_000_000n); + const deleted = sweepRateLimits(tx, tx.db.rateLimitBucket.expiresAt.filter()); + assert(deleted === 1 && tx.rows.size === 0, 'sweep removes expired buckets'); +} + +process.stdout.write(`\n${pass} passed, ${fail} failed\n`); +process.exit(fail === 0 ? 0 : 1); diff --git a/spacetime-rate-limit-ts/spacetimedb/package.json b/spacetime-rate-limit-ts/spacetimedb/package.json new file mode 100644 index 00000000000..017abaf1ca1 --- /dev/null +++ b/spacetime-rate-limit-ts/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-rate-limit-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-rate-limit", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-rate-limit" + }, + "dependencies": { + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-rate-limit-ts/spacetimedb/src/index.ts b/spacetime-rate-limit-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..c11828b0c59 --- /dev/null +++ b/spacetime-rate-limit-ts/spacetimedb/src/index.ts @@ -0,0 +1,17 @@ +import spacetimedb from '../../src/submodule/schema'; +import { installRateLimit } from '../../src/submodule/install'; +export { + adminRateLimitBuckets, + addRateLimitAdmin, + consume, + rate_limit_sweep, + resetBuckets, + runSweep, + updateConfig, +} from '../../src/submodule/operations'; + +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + installRateLimit(ctx); +}); diff --git a/spacetime-rate-limit-ts/spacetimedb/src/install.ts b/spacetime-rate-limit-ts/spacetimedb/src/install.ts new file mode 100644 index 00000000000..01cc812581e --- /dev/null +++ b/spacetime-rate-limit-ts/spacetimedb/src/install.ts @@ -0,0 +1 @@ +export { installRateLimit } from '../../src/submodule/install'; diff --git a/spacetime-rate-limit-ts/spacetimedb/src/submodule.ts b/spacetime-rate-limit-ts/spacetimedb/src/submodule.ts new file mode 100644 index 00000000000..fa63bda8629 --- /dev/null +++ b/spacetime-rate-limit-ts/spacetimedb/src/submodule.ts @@ -0,0 +1,2 @@ +export { default } from '../../src/submodule'; +export * from '../../src/submodule'; diff --git a/spacetime-rate-limit-ts/spacetimedb/tsconfig.json b/spacetime-rate-limit-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..8d8f9b03455 --- /dev/null +++ b/spacetime-rate-limit-ts/spacetimedb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/spacetime-rate-limit-ts/src/index.ts b/spacetime-rate-limit-ts/src/index.ts new file mode 100644 index 00000000000..ce1d8bf91d1 --- /dev/null +++ b/spacetime-rate-limit-ts/src/index.ts @@ -0,0 +1,15 @@ +export { + DEFAULT_SWEEP_BATCH, + DEFAULT_SWEEP_INTERVAL_SECONDS, + consumeRateLimit, + installRateLimitState, + resolveRateLimitSweepBatch, + runRateLimitSweep, + sweepRateLimits, + type ConsumeRateLimitOpts, + type RateLimitInitCtxLike, + type RateLimitResult, + type RateLimitSweepCtxLike, + type RateLimitTxLike, +} from './limit'; +export { buildRateLimitKey } from './key'; diff --git a/spacetime-rate-limit-ts/src/key.ts b/spacetime-rate-limit-ts/src/key.ts new file mode 100644 index 00000000000..7eb4c4b3cd4 --- /dev/null +++ b/spacetime-rate-limit-ts/src/key.ts @@ -0,0 +1,7 @@ +function encodePart(value: string): string { + return `${value.length}:${value}`; +} + +export function buildRateLimitKey(scope: string, actorKey: string): string { + return `${encodePart(scope)}${encodePart(actorKey)}`; +} diff --git a/spacetime-rate-limit-ts/src/limit.ts b/spacetime-rate-limit-ts/src/limit.ts new file mode 100644 index 00000000000..3d5e1fdaf7b --- /dev/null +++ b/spacetime-rate-limit-ts/src/limit.ts @@ -0,0 +1,290 @@ +import { ScheduleAt, Timestamp } from 'spacetimedb'; + +const ONE_SECOND_MICROS = 1_000_000n; +const U32_MAX = 0xffff_ffff; + +export const DEFAULT_SWEEP_BATCH = 500; +export const DEFAULT_SWEEP_INTERVAL_SECONDS = 30n; + +export interface ConsumeRateLimitOpts { + key: string; + scope: string; + limit: number; + windowSeconds: number; + cost?: number; +} + +export type RateLimitResult = + | { + allowed: true; + key: string; + scope: string; + limit: number; + used: number; + remaining: number; + resetAt: Timestamp; + retryAfterSeconds: 0; + } + | { + allowed: false; + key: string; + scope: string; + limit: number; + used: number; + remaining: 0; + resetAt: Timestamp; + retryAfterSeconds: number; + }; + +function assertPositiveInt(name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0 || value > U32_MAX) { + throw new Error(`rate_limit.invalid_${name}`); + } +} + +function plusSeconds(timestamp: Timestamp, seconds: number): Timestamp { + return new Timestamp( + (timestamp.microsSinceUnixEpoch as bigint) + + BigInt(seconds) * ONE_SECOND_MICROS + ); +} + +function secondsUntil(now: Timestamp, future: Timestamp): number { + const delta = + (future.microsSinceUnixEpoch as bigint) - + (now.microsSinceUnixEpoch as bigint); + if (delta <= 0n) return 0; + return Number((delta + ONE_SECOND_MICROS - 1n) / ONE_SECOND_MICROS); +} + +export interface RateLimitBucketRow { + key: string; + scope: string; + windowStart: Timestamp; + expiresAt: Timestamp; + count: number; + updatedAt: Timestamp; +} + +export interface RateLimitTxLike { + timestamp: Timestamp; + db: { + rateLimitBucket: { + key: { + find(key: string): RateLimitBucketRow | null | undefined; + update(row: RateLimitBucketRow): void; + }; + insert(row: RateLimitBucketRow): void; + delete(row: RateLimitBucketRow): void; + }; + }; +} + +export interface RateLimitInitCtxLike { + timestamp: Timestamp; + db: { + rateLimitConfig: { + singleton: { + find( + key: boolean + ): + | { singleton: boolean; sweepBatch: number; updatedAt: Timestamp } + | null + | undefined; + update(row: { + singleton: boolean; + sweepBatch: number; + updatedAt: Timestamp; + }): void; + }; + insert(row: { + singleton: boolean; + sweepBatch: number; + updatedAt: Timestamp; + }): void; + }; + rateLimitSweepTick: { + insert(row: { scheduledId: bigint; scheduledAt: ScheduleAt }): void; + }; + }; +} + +export interface RateLimitInstallOpts { + sweepBatch?: number; + sweepIntervalSeconds?: bigint; +} + +export function installRateLimitState( + ctx: RateLimitInitCtxLike, + opts?: RateLimitInstallOpts +): void { + const sweepBatch = opts?.sweepBatch ?? DEFAULT_SWEEP_BATCH; + assertPositiveInt('sweep_batch', sweepBatch); + const sweepIntervalSeconds = + opts?.sweepIntervalSeconds ?? DEFAULT_SWEEP_INTERVAL_SECONDS; + if (sweepIntervalSeconds <= 0n) + throw new Error('rate_limit.invalid_sweep_interval_seconds'); + + const existing = ctx.db.rateLimitConfig.singleton.find(true); + if (!existing) { + ctx.db.rateLimitConfig.insert({ + singleton: true, + sweepBatch, + updatedAt: ctx.timestamp, + }); + } + + ctx.db.rateLimitSweepTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(sweepIntervalSeconds * ONE_SECOND_MICROS), + }); +} + +export function resolveRateLimitSweepBatch( + ctx: { + db: { + rateLimitConfig: { + singleton: { + find(key: boolean): { sweepBatch: number } | null | undefined; + }; + }; + }; + }, + fallback = DEFAULT_SWEEP_BATCH +): number { + const cfg = ctx.db.rateLimitConfig.singleton.find(true); + if (!cfg) return fallback; + const batch = Number(cfg.sweepBatch); + return batch > 0 ? batch : fallback; +} + +export interface RateLimitSweepCtxLike extends RateLimitTxLike { + db: RateLimitTxLike['db'] & { + rateLimitConfig: { + singleton: { + find(key: boolean): { sweepBatch: number } | null | undefined; + }; + }; + }; +} + +export function runRateLimitSweep( + ctx: RateLimitSweepCtxLike, + expiredRows: Iterable +): number { + const batch = resolveRateLimitSweepBatch(ctx); + return sweepRateLimits(ctx, expiredRows, batch); +} + +export function consumeRateLimit( + tx: RateLimitTxLike, + opts: ConsumeRateLimitOpts +): RateLimitResult { + assertPositiveInt('limit', opts.limit); + assertPositiveInt('window', opts.windowSeconds); + const cost = opts.cost ?? 1; + assertPositiveInt('cost', cost); + + const now = tx.timestamp as Timestamp; + const resetAt = plusSeconds(now, opts.windowSeconds); + const existing = tx.db.rateLimitBucket.key.find(opts.key); + + if ( + !existing || + (existing.expiresAt.microsSinceUnixEpoch as bigint) <= + (now.microsSinceUnixEpoch as bigint) + ) { + if (cost > opts.limit) { + return { + allowed: false, + key: opts.key, + scope: opts.scope, + limit: opts.limit, + used: 0, + remaining: 0, + resetAt, + retryAfterSeconds: opts.windowSeconds, + }; + } + // Reuse an expired row until the sweeper deletes it. Update in place to + // avoid a key collision. + if (existing) { + tx.db.rateLimitBucket.key.update({ + ...existing, + scope: opts.scope, + windowStart: now, + expiresAt: resetAt, + count: cost, + updatedAt: now, + }); + } else { + tx.db.rateLimitBucket.insert({ + key: opts.key, + scope: opts.scope, + windowStart: now, + expiresAt: resetAt, + count: cost, + updatedAt: now, + }); + } + return { + allowed: true, + key: opts.key, + scope: opts.scope, + limit: opts.limit, + used: cost, + remaining: opts.limit - cost, + resetAt, + retryAfterSeconds: 0, + }; + } + + const used = existing.count as number; + if (used + cost > opts.limit) { + return { + allowed: false, + key: opts.key, + scope: opts.scope, + limit: opts.limit, + used, + remaining: 0, + resetAt: existing.expiresAt, + retryAfterSeconds: secondsUntil(now, existing.expiresAt), + }; + } + + const nextUsed = used + cost; + tx.db.rateLimitBucket.key.update({ + ...existing, + scope: opts.scope, + count: nextUsed, + updatedAt: now, + }); + return { + allowed: true, + key: opts.key, + scope: opts.scope, + limit: opts.limit, + used: nextUsed, + remaining: opts.limit - nextUsed, + resetAt: existing.expiresAt, + retryAfterSeconds: 0, + }; +} + +export function sweepRateLimits( + tx: RateLimitTxLike, + expiredRows: Iterable, + maxRows = DEFAULT_SWEEP_BATCH +): number { + assertPositiveInt('sweep_batch', maxRows); + const nowMicros = tx.timestamp.microsSinceUnixEpoch as bigint; + let deleted = 0; + for (const row of expiredRows) { + if (deleted >= maxRows) break; + if ((row.expiresAt.microsSinceUnixEpoch as bigint) > nowMicros) break; + tx.db.rateLimitBucket.delete(row); + deleted++; + } + return deleted; +} diff --git a/spacetime-rate-limit-ts/src/submodule.ts b/spacetime-rate-limit-ts/src/submodule.ts new file mode 100644 index 00000000000..9ada70bb768 --- /dev/null +++ b/spacetime-rate-limit-ts/src/submodule.ts @@ -0,0 +1,26 @@ +export { default } from './submodule/schema'; +export { installRateLimit } from './submodule/install'; +export { + DEFAULT_SWEEP_BATCH, + DEFAULT_SWEEP_INTERVAL_SECONDS, + consumeRateLimit, + installRateLimitState, + resolveRateLimitSweepBatch, + runRateLimitSweep, + sweepRateLimits, + type ConsumeRateLimitOpts, + type RateLimitInitCtxLike, + type RateLimitResult, + type RateLimitSweepCtxLike, + type RateLimitTxLike, +} from './limit'; +export { buildRateLimitKey } from './key'; +export { + adminRateLimitBuckets, + addRateLimitAdmin, + consume, + rate_limit_sweep, + resetBuckets, + runSweep, + updateConfig, +} from './submodule/operations'; diff --git a/spacetime-rate-limit-ts/src/submodule/install.ts b/spacetime-rate-limit-ts/src/submodule/install.ts new file mode 100644 index 00000000000..0ad4b1ffcb3 --- /dev/null +++ b/spacetime-rate-limit-ts/src/submodule/install.ts @@ -0,0 +1,12 @@ +import { DEFAULT_SWEEP_BATCH, installRateLimitState } from '../index'; +import type { ReducerModuleCtx } from './schema'; + +export function installRateLimit(ctx: ReducerModuleCtx) { + if (ctx.db.rateLimitAdminIdentity.identity.find(ctx.sender) == null) { + ctx.db.rateLimitAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + installRateLimitState(ctx, { sweepBatch: DEFAULT_SWEEP_BATCH }); +} diff --git a/spacetime-rate-limit-ts/src/submodule/operations.ts b/spacetime-rate-limit-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..ba199eb5758 --- /dev/null +++ b/spacetime-rate-limit-ts/src/submodule/operations.ts @@ -0,0 +1,190 @@ +import { Range, SenderError } from 'spacetimedb/server'; +import { + consumeRateLimit, + DEFAULT_SWEEP_BATCH, + runRateLimitSweep, + sweepRateLimits, +} from '../index'; +import { + rateLimitBucket, + rateLimitSweepTick, + setRateLimitSweepReducer, + spacetimedb, + t, + type ReducerModuleCtx, + type ViewModuleCtx, +} from './schema'; +import { buildRateLimitKey } from '../key'; + +const MAX_SCOPE_LENGTH = 128; +const MAX_ACTOR_KEY_LENGTH = 256; + +export const consumeResult = t.object('RateLimitConsumeResult', { + allowed: t.bool(), + scope: t.string(), + key: t.string(), + limit: t.u32(), + used: t.u32(), + remaining: t.u32(), + retryAfterSeconds: t.u32(), + resetAt: t.timestamp(), +}); + +function isAdmin(ctx: ViewModuleCtx): boolean { + return ctx.db.rateLimitAdminIdentity.identity.find(ctx.sender) != null; +} + +function requireAdmin(ctx: ReducerModuleCtx): void { + if (ctx.db.rateLimitAdminIdentity.identity.find(ctx.sender) == null) { + throw new SenderError('rate_limit.not_authorized'); + } +} + +function toU32(name: string, value: number): number { + if (!Number.isInteger(value) || value <= 0 || value > 0xffff_ffff) { + throw new Error(`rate_limit.invalid_${name}`); + } + return value; +} + +function sanitizePart(s: string): string { + return s.trim().replace(/\s+/g, ' '); +} + +export const consume = spacetimedb.procedure( + { + scope: t.string(), + actorKey: t.string(), + limit: t.u32(), + windowSeconds: t.u32(), + cost: t.option(t.u32()), + }, + consumeResult, + (ctx, args) => { + const scope = sanitizePart(args.scope); + const actorKey = sanitizePart(args.actorKey); + if (scope.length === 0 || scope.length > MAX_SCOPE_LENGTH) { + throw new SenderError('rate_limit.invalid_scope'); + } + if (actorKey.length === 0 || actorKey.length > MAX_ACTOR_KEY_LENGTH) { + throw new SenderError('rate_limit.invalid_actor_key'); + } + const limit = toU32('limit', Number(args.limit)); + const windowSeconds = toU32('window_seconds', Number(args.windowSeconds)); + const cost = toU32('cost', Number(args.cost ?? 1)); + const key = buildRateLimitKey(scope, actorKey); + + const out = ctx.withTx(tx => { + requireAdmin(tx); + const r = consumeRateLimit(tx, { + key, + scope, + limit, + windowSeconds, + cost, + }); + return { + allowed: r.allowed, + scope: r.scope, + key: r.key, + limit, + used: r.used, + remaining: r.remaining, + retryAfterSeconds: r.retryAfterSeconds, + resetAt: r.resetAt, + }; + }); + if (!out) throw new Error('rate_limit.consume_tx_failed'); + return out; + } +); + +export const runSweep = spacetimedb.procedure( + { maxRows: t.option(t.u32()) }, + t.u32(), + (ctx, args) => { + const maxRows = + args.maxRows === undefined + ? undefined + : toU32('sweep_batch', Number(args.maxRows)); + return ctx.withTx(tx => { + requireAdmin(tx); + return sweepRateLimits( + tx, + tx.db.rateLimitBucket.expiresAt.filter( + new Range(undefined, { tag: 'included', value: tx.timestamp }) + ), + maxRows ?? DEFAULT_SWEEP_BATCH + ); + }); + } +); + +export const addRateLimitAdmin = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, args) => { + requireAdmin(ctx); + if (ctx.db.rateLimitAdminIdentity.identity.find(args.identity) == null) { + ctx.db.rateLimitAdminIdentity.insert({ + identity: args.identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + } +); + +export const updateConfig = spacetimedb.reducer( + { sweepBatch: t.u32() }, + (ctx, args) => { + requireAdmin(ctx); + const cfg = ctx.db.rateLimitConfig.singleton.find(true); + if (!cfg) throw new Error('rate_limit.config_missing'); + ctx.db.rateLimitConfig.singleton.update({ + ...cfg, + sweepBatch: toU32('sweep_batch', Number(args.sweepBatch)), + updatedAt: ctx.timestamp, + }); + } +); + +export const resetBuckets = spacetimedb.reducer( + { maxRows: t.option(t.u32()) }, + (ctx, args) => { + requireAdmin(ctx); + const maxRows = Math.min(Number(args.maxRows ?? 1000), 10_000); + let removed = 0; + for (const row of ctx.db.rateLimitBucket.iter()) { + if (removed >= maxRows) break; + ctx.db.rateLimitBucket.delete(row); + removed++; + } + } +); + +export const adminRateLimitBuckets = spacetimedb.view( + { name: 'admin_rate_limit_buckets', public: true }, + t.array(rateLimitBucket.rowType), + ctx => { + if (!isAdmin(ctx)) return []; + const rows = []; + for (const row of ctx.db.rateLimitBucket.iter()) { + if (rows.length >= 1000) break; + rows.push(row); + } + return rows; + } +); + +export const rate_limit_sweep = spacetimedb.reducer( + { arg: rateLimitSweepTick.rowType }, + (ctx, _args) => { + runRateLimitSweep( + ctx, + ctx.db.rateLimitBucket.expiresAt.filter( + new Range(undefined, { tag: 'included', value: ctx.timestamp }) + ) + ); + } +); + +setRateLimitSweepReducer(rate_limit_sweep); diff --git a/spacetime-rate-limit-ts/src/submodule/schema.ts b/spacetime-rate-limit-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..46484ca523e --- /dev/null +++ b/spacetime-rate-limit-ts/src/submodule/schema.ts @@ -0,0 +1,77 @@ +import { + schema, + table, + t, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; + +export const rateLimitBucket = table( + { name: 'rate_limit_bucket', public: false }, + { + key: t.string().primaryKey(), + scope: t.string().index(), + windowStart: t.timestamp().index(), + expiresAt: t.timestamp().index(), + count: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const rateLimitAdminIdentity = table( + { name: 'rate_limit_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +export const rateLimitConfig = table( + { name: 'rate_limit_config', public: true }, + { + singleton: t.bool().primaryKey(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), + } +); + +let rateLimitSweepReducer: unknown; + +export function setRateLimitSweepReducer(reducer: unknown): void { + rateLimitSweepReducer = reducer; +} + +export const rateLimitSweepTick = table( + { + name: 'rate_limit_sweep_tick', + scheduled: (): any => { + if (!rateLimitSweepReducer) { + throw new Error('rate_limit.sweep_reducer_not_registered'); + } + return rateLimitSweepReducer; + }, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +export const spacetimedb = schema({ + rateLimitBucket, + rateLimitAdminIdentity, + rateLimitConfig, + rateLimitSweepTick, +}); +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; + +export { t }; diff --git a/spacetime-rate-limit-ts/tsconfig.json b/spacetime-rate-limit-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-rate-limit-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-resend-ts/.gitignore b/spacetime-resend-ts/.gitignore new file mode 100644 index 00000000000..0eec7566545 --- /dev/null +++ b/spacetime-resend-ts/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +ts-codegen +*.tsbuildinfo +.DS_Store diff --git a/spacetime-resend-ts/LICENSE.txt b/spacetime-resend-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-resend-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-resend-ts/README.md b/spacetime-resend-ts/README.md new file mode 100644 index 00000000000..ed4361c4eba --- /dev/null +++ b/spacetime-resend-ts/README.md @@ -0,0 +1,232 @@ +# @spacetimedb/resend + +A SpacetimeDB submodule for transactional email via [Resend](https://resend.com): +admin-gated outbound delivery, idempotent webhook ingest, private delivery +state, synchronous procedures, and valibot-validated webhook payloads. + +--- + +## Install + +```bash +npm install @spacetimedb/resend @spacetimedb/crypto spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +This submodule can be published directly as its own STDB module from the root entrypoint. + +## Usage + +### Integrate into an application + +Mount Resend in the host schema, initialize its private state, and expose only +application-authorized send procedures and caller-scoped delivery views: + +```ts +import { schema } from 'spacetimedb/server'; +import * as resend from '@spacetimedb/resend/submodule'; + +const spacetimedb = schema({ resend }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + resend.installResend(ctx.as.resend); +}); +``` + +Provider configuration must run as the publishing owner or a registered Resend +administrator. See the +[Dispatch host module](./example/spacetimedb/) +for a narrow send procedure, scoped views, and signed webhook routing. + +### Standalone configuration + +Resend credentials live in a private `resend_config` singleton. During `init`, a +fresh database seeds the owner into the private `resend_admin_identity` table. + +```bash +spacetime call --server http://127.0.0.1:3000 resend-ts set_resend_config \ + '"re_..."' \ + '"whsec_..."' \ + '"onboarding@resend.dev"' +``` + +Args: `apiKey`, `webhookSigningSecret` (required for webhook ingest), `defaultFrom` (optional). + +Verify: + +```bash +spacetime call --server http://127.0.0.1:3000 resend-ts get_resend_config_status '{}' +``` + +`send_email` reads the Resend API key from private module state. + +## Private tables + +| Table | Key | Notes | +| ----------------------- | ----------- | ------------------------------------------------------------ | +| `resend_email` | `resend_id` | one row per outbound email; `status` reflects latest webhook | +| `resend_delivery_event` | `event_id` | append-only audit log of every event for an email | + +- `resend_webhook_event` - idempotency log +- `resend_config` - credentials singleton +- `resend_admin_identity` - admin allowlist + +None of the Resend base tables are subscribable. Email recipients, subject, +body, tracking state, webhook payloads, and signature headers remain private. +Host modules should expose caller- or tenant-scoped views over `userId` or +`orgId`; the included example demonstrates this pattern. + +## API + +**Setup** + +- `set_resend_config(apiKey, webhookSigningSecret, defaultFrom)` +- `get_resend_config_status()` - `{ isConfigured, hasWebhookSigningSecret, apiKeyLength, ... }` +- `add_admin_identity(identity)` / `remove_admin_identity(identity)` + +**Outbound** + +- `send_email({ from, to, subject, html, text, cc, bcc, replyTo, tagsJson, headersJson, scheduledAt, idempotencyKey})` - admin-gated; inserts a `resend_email` row with `status = queued`, returns `{ resendId }`. The first webhook flips it to `sent`/`delivered`. +- `cancel_email({ resendId })` - admin-gated +- `resend_api_request({ method, path, jsonBody, idempotencyKey })` - admin-gated + request to a relative `api.resend.com` path; accepted methods are `GET`, `POST`, + `PATCH`, and `DELETE` + +Email sends accept up to 100 combined `to`, `cc`, and `bcc` recipients. Address +fields are capped at 320 characters, subjects at 998 characters, HTML and text +at 200,000 characters each, and tag or header JSON at 16 KiB. Control characters +in address, subject, and schedule fields are rejected before provider HTTP. + +Mounted host modules should prefer the helper export: + +```ts +import * as resend from '@spacetimedb/resend/submodule'; + +resend.sendEmail(ctx.as.resend, { + to: ['delivered@resend.dev'], + subject: 'Welcome', + html: '

          Hello.

          ', + tagsJson: JSON.stringify({ userId: 'u_123', orgId: 'launch' }), +}); +``` + +That lets the host app own product-specific authorization and workflow while +the submodule owns config, delivery rows, and webhook ingest. + +Expose that helper through a product-facing procedure with recipient policy and +rate limits. The generated client then calls the wrapper: + +```ts +const result = await conn.procedures.sendDispatch({ + to: 'delivered@resend.dev', + subject: 'Welcome', + message: 'Your workspace is ready.', +}); + +if (!result.ok) throw new Error(result.message); +``` + +Subscribe to a caller-scoped host view for delivery status. Keep the private +Resend tables and generic administrative send operation restricted to +operators. + +**Webhook ingest / replay** + +- `ingest_resend_webhook(eventId, eventType, payloadJson, signatureHeader, timestampHeader)` - idempotent +- `replay_webhook_event(eventId)` - re-applies a stored event +- `makeResendWebhookHandler()` builds a direct HTTP webhook handler for a host + router. + +Webhook application is atomic. Invalid event data rolls back the event row and +email-state changes so Resend can redeliver the event. + +**Admin queries** + +- `get_email`, `list_emails_by_user_id`, `list_emails_by_org_id`, `list_emails_by_status` +- `list_delivery_events_for_email` + +List procedures return at most 1,000 rows. Host applications should expose +caller-scoped, paginated views for product-facing history. + +Package entrypoints: + +- `@spacetimedb/resend` can run as a standalone email database. +- `@spacetimedb/resend/submodule` supplies mounted configuration, delivery, + webhook, and query helpers. + +## Webhook events handled + +``` +email.sent email.delivered +email.delivery_delayed email.bounced +email.complained email.failed +email.opened email.clicked +``` + +`opened` / `clicked` / `complained` are recorded as **flags + timestamps** and leave `status` unchanged. Terminal states (`delivered`, `bounced`, `failed`, `cancelled`) and in-flight states (`queued`, `sent`, `delivery_delayed`) live in the `status` column. + +## Webhook signature verification + +The module's `ingest_resend_webhook` reducer verifies Standard Webhooks signatures (svix) using the configured `webhookSigningSecret`. `signatureHeader` and `timestampHeader` are stored on `resend_webhook_event` for forensic replay. + +## Tagging + +`userId` / `orgId` are extracted from Resend `tags` and indexed for per-user / per-org listing. Both tag shapes are accepted: + +- Object form: `{"userId": "u_123", "orgId": "o_456"}` +- Array form (Resend's webhook output): `[{"name":"userId","value":"u_123"}]` + +Pass `tagsJson` as a JSON string when calling `send_email`. + +## Integration testing + +```bash +# Build + publish + event paths, idempotency, replay, signed-type checks, and authorization checks +pnpm run test:smoke +``` + +The smoke test publishes only to the dedicated `resend-ts-smoke-test` database. + +For real Resend test-mode: + +```bash +# Bootstrap once with your real key: +spacetime call --server http://127.0.0.1:3000 resend-ts set_resend_config '"re_..."' null '"onboarding@resend.dev"' + +# Then send to one of Resend's test addresses (delivered@/bounced@/complained@): +spacetime call --server http://127.0.0.1:3000 resend-ts send_email \ + null \ + '["delivered@resend.dev"]' \ + '"Test from STDB"' \ + '"

          Hello.

          "' \ + null null null null \ + '"{\"userId\":\"u_123\"}"' \ + null null null +``` + +To exercise inbound webhooks end-to-end, expose your local relay via ngrok, register the URL in Resend's dashboard with the same `whsec_...` you passed to `set_resend_config`, and forward verified events to `ingest_resend_webhook`. + +## Architecture notes + +- **valibot for runtime validation.** `vEmailEvent` is a `v.variant('type', [...])` over the 8 supported event types. The unit and smoke suites lock down the accepted wire shapes. +- **Synchronous HTTP.** Procedures are synchronous and `ctx.http.fetch` returns a `SyncResponse`, so `callResend` in `src/submodule/http.ts` implements the required API surface directly. +- **Wire format.** The public input uses SDK-style camelCase (`replyTo`, `scheduledAt`), and `buildSendEmailBody` emits the provider's snake_case JSON fields. +- **Idempotency.** Each webhook event is keyed by `event.id`; re-ingest is a no-op. Status-changing events ratchet forward, so `email.complained` preserves a terminal `delivered` status. + +## Testing + +```bash +npm test --workspace @spacetimedb/resend +npm run lint --workspace @spacetimedb/resend +``` + +Credentialed smoke coverage is described in **Integration testing** above. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-resend-ts/example/.env.example b/spacetime-resend-ts/example/.env.example new file mode 100644 index 00000000000..fd1d523da42 --- /dev/null +++ b/spacetime-resend-ts/example/.env.example @@ -0,0 +1,28 @@ +# Copy to .env. The example server loads this on startup. + +# ---------------- Resend ---------------- +# Required to send email from Dispatch. +RESEND_API_KEY=re_... + +# Required to accept inbound webhooks (Resend dashboard, Webhooks -> your endpoint -> Signing secret). +# The module rejects requests when this is unset or the signature is invalid. +RESEND_WEBHOOK_SECRET=whsec_... + +# Optional comma-separated external recipients. Provider test recipients remain +# available automatically. The module rejects every address outside this list. +RESEND_ALLOWED_RECIPIENTS=you@example.com + +# ---------------- SpacetimeDB ---------------- +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_DATABASE=spacetime-resend-example +# Optional. When unset, the server creates a persistent local identity token in +# .stdb-server-token and the logged-in publishing identity authorizes it. +# STDB_SERVER_TOKEN= + +# ---------------- Static server ---------------- +PORT=8790 +HOST=127.0.0.1 + +# Default "from" address (must be a verified sender in your Resend account, or onboarding@resend.dev for testing). +DEFAULT_FROM=onboarding@resend.dev diff --git a/spacetime-resend-ts/example/.gitignore b/spacetime-resend-ts/example/.gitignore new file mode 100644 index 00000000000..5882ce447a9 --- /dev/null +++ b/spacetime-resend-ts/example/.gitignore @@ -0,0 +1,10 @@ +node_modules +dist +src/codegen +public/app.js +public/app.js.map +*.tsbuildinfo +.DS_Store +dev-server.log +.env +.stdb-server-token diff --git a/spacetime-resend-ts/example/README.md b/spacetime-resend-ts/example/README.md new file mode 100644 index 00000000000..4d666b253af --- /dev/null +++ b/spacetime-resend-ts/example/README.md @@ -0,0 +1,181 @@ +# Dispatch + +Dispatch demonstrates a host module that mounts +`@spacetimedb/resend/submodule`: compose an email, send it through Resend, and +watch verified delivery events stream back through SpacetimeDB. Provider credentials +and component administration remain outside the browser. + +## What this demonstrates + +- Mounting the Resend component under the `resend` namespace. +- Calling a host `send_dispatch` procedure backed by a private API key. +- Showing caller-scoped email and delivery-event views in real time. +- Receiving Resend webhooks through a native SpacetimeDB HTTP route. +- Verifying Svix signatures inside the module with `@spacetimedb/crypto`. +- Persisting and explicitly authorizing a dedicated local server identity. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server reachable as `local`. +- A logged-in CLI identity that publishes the database. +- A Resend API key. +- A Resend webhook signing secret for delivery-status updates. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-resend-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +``` + +Set `RESEND_API_KEY` and, for webhooks, `RESEND_WEBHOOK_SECRET` in `.env`. + +```powershell +pnpm run build:module:fresh +pnpm run dev +``` + +Open . The **Delivered**, **Bounced**, and **Complaint** +buttons fill Resend's test addresses. Sending creates a real Resend test request; +delivery-state transitions require a reachable webhook. + +`build:module:fresh` deletes and recreates only the local `spacetime-resend-example` +database. Use `pnpm run build:module` to preserve existing data. + +## Use in your project + +This workspace tests the component source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/resend @spacetimedb/rate-limit @spacetimedb/crypto spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +authorized send procedure, caller-scoped views, and signed webhook route; +replace the example's development identity bootstrap in production. + +## Configuration + +| Variable | Default | Purpose | +| --------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------- | +| `RESEND_API_KEY` | empty | Required to send mail. | +| `RESEND_WEBHOOK_SECRET` | empty | Required to accept webhook requests. Missing or invalid signatures are rejected. | +| `RESEND_ALLOWED_RECIPIENTS` | empty | Optional comma-separated external recipients. Resend test addresses are included automatically. | +| `DEFAULT_FROM` | `onboarding@resend.dev` | Default sender; use a verified address outside Resend's test flow. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser and server WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | CLI and native-route HTTP endpoint. Must match `STDB_URI`. | +| `STDB_DATABASE` | `spacetime-resend-example` | Published database name. | +| `STDB_SERVER_TOKEN` | generated locally | Optional pre-provisioned server identity token. | +| `HOST` | `127.0.0.1` | Static-server bind address. | +| `PORT` | `8790` | Static-server port. | + +When `STDB_SERVER_TOKEN` is unset, the server stores its identity token in the +ignored `.stdb-server-token` file. The logged-in publishing identity registers it +with `resend.add_admin_identity` before the server writes private configuration. +Configuration failure is fatal when an API key was supplied; the server will not +pretend to be ready with an unauthorized identity. + +## Webhooks + +The native module endpoint is: + +```text +POST http://127.0.0.1:3000/v1/database/spacetime-resend-example/route/webhook/resend +``` + +The Node server also exposes: + +```text +POST http://127.0.0.1:8790/webhook/resend +``` + +That route is a raw-body passthrough for stable tunnel URLs. Signature verification +and ingestion still occur inside the SpacetimeDB module. The module rejects requests +when no signing secret is configured or when the signature is invalid; there is no +unverified development fallback. + +Without a publicly reachable webhook, sending still creates a queued email row, but +Delivered, Opened, Clicked, Bounced, and Complaint transitions cannot arrive. + +## Architecture + +```text +Browser + -> send_dispatch host procedure + -> recipient allowlist and caller/global quotas + -> mounted resend namespace + -> Resend API + +Resend webhook + -> native SpacetimeDB HTTP route + -> signature verification and ingest + -> caller-scoped live views + +Authorized example server + -> private Resend configuration during startup + -> static UI and optional raw webhook passthrough +``` + +## Security and deployment boundaries + +- The browser never receives the Resend API key, signing secret, server token, or + component administrator role. +- `send_dispatch` accepts only server-configured recipients. It allows five sends + per caller every ten minutes and 25 sends globally per hour. +- Provider failures return a stable application error while detailed delivery + state remains in private component tables. +- `.env` and `.stdb-server-token` are ignored and must not be committed. +- The development server binds to loopback by default. +- A production deployment should provision its service identity and secret store + through deployment infrastructure. + +## Verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a provider smoke test, send to `delivered@resend.dev` and confirm a queued row +appears. With a reachable signed webhook, confirm it advances to Delivered. Invalid +or unsigned webhook fixtures must return a non-success status. + +## Troubleshooting + +- **`resend.not_authorized`:** publish with the logged-in CLI identity and restart + so it can authorize the persistent server identity. +- **Send remains Queued:** confirm the webhook is publicly reachable and its signing + secret matches `RESEND_WEBHOOK_SECRET`. +- **Connection targets disagree:** make `STDB_URI`, `STDB_HTTP`, and the publish + target refer to the same SpacetimeDB instance. +- **Changing the server identity intentionally:** stop the server, remove + `.stdb-server-token`, and restart while logged in as a database administrator. + +## Important files + +- `spacetimedb/src/index.ts`: host procedures, caller-scoped views, and native route. +- `server.ts`: safe startup configuration, server identity, and webhook passthrough. +- `src/app.ts`: browser connection and Dispatch UI behavior. +- `public/index.html`: Dispatch interface structure. +- `public/styles.css`: Dispatch presentation. diff --git a/spacetime-resend-ts/example/package.json b/spacetime-resend-ts/example/package.json new file mode 100644 index 00000000000..bc3ec4a6bb4 --- /dev/null +++ b/spacetime-resend-ts/example/package.json @@ -0,0 +1,29 @@ +{ + "name": "spacetime-resend-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "check": "tsc --noEmit", + "test:unit": "tsx scripts/test-message.ts", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "resend": "^6.12.2", + "tsx": "^4.21.0", + "typescript": "^6.0.3" + } +} diff --git a/spacetime-resend-ts/example/public/assets/brand.svg b/spacetime-resend-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-resend-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-resend-ts/example/public/index.html b/spacetime-resend-ts/example/public/index.html new file mode 100644 index 00000000000..c788c2ea747 --- /dev/null +++ b/spacetime-resend-ts/example/public/index.html @@ -0,0 +1,151 @@ + + + + + + + Dispatch + + + +
          +
          +
          + +
          +

          Dispatch

          + Send an email, watch its delivery resolve live +
          +
          +
          + +
          +
          +
          +

          Compose

          +

          + Dispatched through the mounted Resend submodule. +

          +
          + +
          + + +
          +
          + Message +
          + + +
          +
          + + +
          + +
          +
          + +
          + Resend test addresses +
          + + + +
          +
          +
          + +
          +
          +
          +

          Live delivery

          +

          + Every dispatch and its lifecycle, streamed from SpacetimeDB. +

          +
          +
          + 0 sent + +
          +
          + +
          +
          + 0Sent +
          +
          + 0Delivered +
          +
          + 0Opened +
          +
          + 0Bounced +
          +
          + +
          +
          +
          + +
          + Built on + SpacetimeDB +
          +
          + + + + diff --git a/spacetime-resend-ts/example/public/styles.css b/spacetime-resend-ts/example/public/styles.css new file mode 100644 index 00000000000..91cc3c7532b --- /dev/null +++ b/spacetime-resend-ts/example/public/styles.css @@ -0,0 +1,885 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap'); + +:root { + /* Tokens from spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-ibm: 'IBM Plex Mono', ui-monospace, monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-purple: #a880ff; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + min-height: 100%; +} +body { + color: var(--color-white); + background: var(--color-shade7); + font-family: var(--font-inter); + -webkit-font-smoothing: antialiased; +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} +a { + color: var(--color-green); + text-decoration: none; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) transparent; +} +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 3px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade1); +} + +button, +input, +textarea { + font: inherit; +} +button { + height: 34px; + padding: 0 16px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade7); + color: var(--color-n2); + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background 0.18s, + border-color 0.18s, + color 0.18s; +} +button:hover:not(:disabled) { + background: var(--color-shade4); + color: var(--color-white); +} +button:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} +button.primary { + background: var(--color-n3); + border-color: var(--color-n3); + color: var(--color-n8); +} +button.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +button.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} +button.primary.sent { + background: var(--color-green); + border-color: var(--color-green); + color: var(--color-n8); + opacity: 1; +} +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +input, +textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade8); + color: var(--color-white); + outline: none; + transition: + border-color 0.15s, + box-shadow 0.15s; +} +input { + height: 40px; + padding: 0 12px; +} +textarea { + min-height: 120px; + resize: none; + line-height: 1.5; + font-size: 14px; +} +input::placeholder, +textarea::placeholder { + color: var(--color-n4); +} +input:focus, +textarea:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-10); +} +label { + display: grid; + gap: 7px; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.07em; + text-transform: uppercase; +} +h1, +h2, +h3, +p { + margin: 0; +} +.muted { + color: var(--color-n4); +} + +.shell { + width: min(1200px, calc(100% - 32px)); + margin: 0 auto; + padding: 16px 0 0; + /* Viewport height gives the feed its own scroll area. Mobile uses auto + height in the media query. */ + height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #0d1920, #0b1319); + box-shadow: inset 0 1px 0 #26435166; + padding: 11px 16px; + margin-bottom: 14px; +} +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.brand-mark { + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: var(--radius); + display: grid; + place-items: center; + color: var(--color-green); + background: var(--color-green-10); + border: 1px solid var(--color-green-20); +} +.brand-mark svg { + width: 18px; + height: 18px; +} +.brand-text { + display: grid; + gap: 1px; + min-width: 0; +} +.brand-text h1 { + margin: 0; + font-size: 15px; + font-weight: 700; + line-height: 1.2; + color: var(--color-n1); +} +.brand-sub { + color: var(--color-n4); + font-size: 12px; +} + +.workspace { + flex: 1; + display: grid; + grid-template-columns: 372px minmax(0, 1fr); + /* One row that is at least 520px and otherwise fills the viewport, so both + panels are always equal height and grow when the window grows. */ + grid-template-rows: minmax(520px, 1fr); + gap: 14px; + align-items: stretch; + min-height: 0; + margin-bottom: 16px; +} +.panel { + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); +} + +/* Compose */ +.compose { + padding: 18px; + display: flex; + flex-direction: column; + gap: 16px; +} +.compose-head { + display: grid; + gap: 3px; +} +.compose-head h2 { + font-size: 15px; + font-weight: 700; + color: var(--color-n1); +} +.compose-head p { + font-size: 13px; +} +.form-grid { + display: flex; + flex-direction: column; + gap: 13px; + flex: 1; + min-height: 0; +} +.form-grid > button.primary { + height: 42px; + font-size: 14px; +} +.field { + display: flex; + flex-direction: column; + gap: 7px; + flex: 1; + min-height: 0; +} +.field textarea, +.field .mail-preview { + flex: 1; + min-height: 120px; +} + +.test-block { + display: grid; + gap: 9px; +} +.test-label { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.test-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.test-row button { + min-width: 0; + height: 32px; + padding: 0 6px; + font-size: 12px; +} +.test-row button b { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--color-n2); +} + +/* Message field with write/preview tabs */ +.field-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.field-label { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.msg-tabs { + display: inline-flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade8); +} +.msg-tab { + height: 24px; + padding: 0 12px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.msg-tab:hover:not(.active) { + background: transparent; + color: var(--color-n2); +} +.msg-tab.active { + background: var(--color-shade4); + color: var(--color-n1); +} + +.mail-preview { + min-height: 128px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: #ffffff; + overflow: hidden; +} +.mp-head { + padding: 12px 14px; + border-bottom: 1px solid #e6e8ec; + background: #f6f7f9; +} +.mp-subject { + font-size: 14px; + font-weight: 600; + color: #1f2933; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mp-from { + margin-top: 3px; + font-size: 12px; + color: #6b7280; +} +.mp-body { + padding: 14px; + font-size: 14px; + color: #1f2933; + line-height: 1.6; + word-break: break-word; +} +.mp-body .mp-empty { + color: #9aa3af; + font-style: italic; +} + +.form-error { + color: var(--color-orange); + font-family: var(--font-ibm); + font-size: 12px; + line-height: 1.4; +} +.form-error:empty { + display: none; +} + +/* Live delivery console */ +.console { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + min-height: 0; +} +.console-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 16px 18px; + border-bottom: 1px solid var(--color-shade4); +} +.console-copy { + display: grid; + gap: 3px; +} +.console-copy h2 { + font-size: 15px; + font-weight: 700; + color: var(--color-n1); +} +.console-copy p { + font-size: 13px; +} +.console-right { + display: flex; + align-items: center; + gap: 12px; +} +.feed-count { + color: var(--color-n5); + font-family: var(--font-ibm); + font-size: 11px; +} +#clear-btn { + height: 30px; + padding: 0 12px; + font-size: 12px; +} +#clear-btn:disabled { + opacity: 0.4; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + padding: 16px 18px; + border-bottom: 1px solid var(--color-shade4); +} +.metric { + display: grid; + gap: 7px; + padding: 13px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + background: var(--color-shade7); +} +.metric strong { + font-size: 25px; + font-weight: 700; + line-height: 1; + color: var(--color-white); + font-variant-numeric: tabular-nums; +} +.metric span { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.metric.delivered strong { + color: var(--color-green); +} +.metric.opened strong { + color: var(--color-green); +} +.metric.bounced strong { + color: var(--color-red); +} + +.feed { + min-height: 0; + overflow-y: auto; + padding: 14px 18px 18px; + display: grid; + gap: 11px; + align-content: start; +} + +.mail { + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + background: var(--color-shade7); + padding: 14px 15px 16px; + transition: border-color 0.15s; +} +.mail:hover { + border-color: var(--color-shade1); +} +.mail.bad { + border-color: #4a2626; +} +.mail-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 11px; + align-items: center; +} +.mail-del { + width: 26px; + height: 26px; + padding: 0; + flex: 0 0 auto; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-n5); + font-size: 17px; + line-height: 1; + display: grid; + place-items: center; +} +.mail-del:hover:not(:disabled) { + background: #1a0f0f; + border-color: #4a2626; + color: var(--color-red); +} +.avatar { + width: 36px; + height: 36px; + flex: 0 0 auto; + border-radius: var(--radius); + display: grid; + place-items: center; + font-weight: 700; + font-size: 12px; + color: var(--av, var(--color-n3)); + background: color-mix(in srgb, var(--av, #555) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--av, #555) 32%, transparent); +} +.mail-who { + min-width: 0; +} +.mail-to { + font-size: 14px; + color: var(--color-n1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mail-sub { + margin-top: 2px; + font-size: 13px; + color: var(--color-n4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mail-when { + flex: 0 0 auto; + text-align: right; + color: var(--color-n5); + font-family: var(--font-ibm); + font-size: 11px; +} + +/* Delivery timeline */ +.track { + display: flex; + margin-top: 15px; +} +.node { + flex: 1; + position: relative; + text-align: center; + min-width: 0; +} +.node::before { + content: ''; + position: absolute; + top: 6px; + left: -50%; + width: 100%; + height: 2px; + background: var(--color-shade4); +} +.node:first-child::before { + display: none; +} +.bead { + position: relative; + z-index: 1; + display: block; + width: 14px; + height: 14px; + margin: 0 auto; + border-radius: 50%; + background: var(--color-shade4); + border: 2px solid var(--color-shade6); +} +.node-label { + display: block; + margin-top: 7px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-n5); +} +.node-time { + display: block; + margin-top: 2px; + font-family: var(--font-ibm); + font-size: 10px; + color: var(--color-n5); + min-height: 12px; +} + +.node.done::before { + background: var(--color-green); +} +.node.done .bead { + background: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-10); +} +.node.done .node-label { + color: var(--color-n2); +} +.node.done .node-time { + color: var(--color-n4); +} + +.node.live::before { + background: var(--color-green); +} +.node.live .bead { + background: var(--color-green); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--color-green) 26%, transparent); + animation: live-pulse 1.8s ease-in-out infinite; +} +.node.live .node-label { + color: var(--color-green); +} + +.node.bad::before { + background: var(--color-red); +} +.node.bad .bead { + background: var(--color-red); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-red) 20%, transparent); +} +.node.bad .node-label { + color: var(--color-red); +} + +.node.warn::before { + background: var(--color-yellow); +} +.node.warn .bead { + background: var(--color-yellow); +} +.node.warn .node-label { + color: var(--color-yellow); +} + +/* One-shot: the node that just reached its state ripples out from the bead. + Applied only to the node that changed this render. */ +.node.just-lit .bead::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: 50%; + border: 2px solid var(--color-green); + animation: bead-ripple 0.65s ease-out forwards; +} +.node.just-lit.bad .bead::after { + border-color: var(--color-red); +} +.node.just-lit.warn .bead::after { + border-color: var(--color-yellow); +} +.node.just-lit::before { + animation: connector-sweep 0.5s ease-out; +} +.node.just-lit .node-label, +.node.just-lit .node-time { + animation: label-in 0.5s ease-out; +} + +.mail.card-enter { + animation: card-enter 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); +} +/* Odometer roll: the current number exits as the incoming value enters. + JavaScript injects the clip window for the animation. */ +.metric strong .odo-clip { + display: block; + overflow: hidden; + height: 1em; +} +.metric strong .odo { + display: block; +} +.metric strong .odo-line { + height: 1em; + line-height: 1; +} +.metric strong .odo.roll-up { + animation: odo-up 0.42s cubic-bezier(0.3, 0.9, 0.3, 1) forwards; +} +.metric strong .odo.roll-down { + animation: odo-down 0.42s cubic-bezier(0.3, 0.9, 0.3, 1) forwards; +} + +@keyframes live-pulse { + 0%, + 100% { + box-shadow: 0 0 0 5px + color-mix(in srgb, var(--color-green) 26%, transparent); + } + 50% { + box-shadow: 0 0 0 8px color-mix(in srgb, var(--color-green) 8%, transparent); + } +} +@keyframes bead-ripple { + 0% { + transform: scale(1); + opacity: 0.9; + } + 100% { + transform: scale(2.6); + opacity: 0; + } +} +@keyframes connector-sweep { + 0% { + clip-path: inset(0 100% 0 0); + } + 100% { + clip-path: inset(0 0 0 0); + } +} +@keyframes label-in { + 0% { + opacity: 0.35; + } + 100% { + opacity: 1; + } +} +@keyframes card-enter { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.985); + } + 100% { + opacity: 1; + transform: none; + } +} +@keyframes odo-up { + from { + transform: translateY(0); + } + to { + transform: translateY(-1em); + } +} +@keyframes odo-down { + from { + transform: translateY(-1em); + } + to { + transform: translateY(0); + } +} +@media (prefers-reduced-motion: reduce) { + .node.live .bead, + .node.just-lit .bead::after, + .node.just-lit::before, + .node.just-lit .node-label, + .node.just-lit .node-time, + .mail.card-enter, + .metric strong .odo { + animation: none; + } +} + +.mail-err { + margin-top: 12px; + padding: 8px 10px; + border: 1px solid #4a2626; + border-radius: var(--radius-sm); + background: #1a0f0f; + color: var(--color-orange); + font-family: var(--font-ibm); + font-size: 11px; + line-height: 1.4; +} + +.empty { + border: 1px dashed var(--color-shade4); + border-radius: var(--radius); + padding: 34px 18px; + color: var(--color-n4); + text-align: center; + font-size: 13px; + line-height: 1.5; +} + +.page-foot { + min-height: 58px; + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + border-top: 1px solid var(--color-shade4); +} +.page-foot .by { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.page-foot img { + height: 26px; + width: auto; + opacity: 0.85; +} + +@media (max-width: 1040px) { + /* Stack the panels at their natural heights and use page-level scrolling. */ + .shell { + height: auto; + min-height: 100vh; + } + .workspace { + grid-template-columns: 1fr; + grid-template-rows: none; + align-items: start; + } + .console { + min-height: 560px; + } +} +@media (max-width: 560px) { + .metrics { + grid-template-columns: repeat(2, 1fr); + } + .node-label { + font-size: 9px; + } +} diff --git a/spacetime-resend-ts/example/scripts/test-message.ts b/spacetime-resend-ts/example/scripts/test-message.ts new file mode 100644 index 00000000000..c7d2169c2c0 --- /dev/null +++ b/spacetime-resend-ts/example/scripts/test-message.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { messageHtml } from '../spacetimedb/src/message'; + +const html = messageHtml( + 'Hello & friends\nNext line\n\nSecond paragraph' +); + +assert.match(html, /BlinkMacSystemFont/); +assert.match(html, /max-width:560px/); +assert.match(html, /Hello <team> & friends
          Next line/); +assert.match(html, /Second paragraph/); +assert.equal((html.match(/

          /); + +console.log('resend message tests passed'); diff --git a/spacetime-resend-ts/example/server.ts b/spacetime-resend-ts/example/server.ts new file mode 100644 index 00000000000..aa905bef0ae --- /dev/null +++ b/spacetime-resend-ts/example/server.ts @@ -0,0 +1,241 @@ +// Node process: serves the static UI and seeds Resend config on startup. It does +// NOT process webhooks - POST /webhook/resend is a thin passthrough to the module's +// own native HTTP route, which verifies (in-module, via crypto-ts) and ingests. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { DbConnection, type ErrorContext } from './src/codegen'; +import { + discardStoredServerToken, + grantServerIdentity, + loadServerToken, + saveServerToken, +} from '../../tools/example-server-identity'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared env supplies secrets/defaults; example-local env wins for app settings. +// Explicit process environment has highest priority. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8790', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const STDB_DB = process.env.STDB_DATABASE ?? 'spacetime-resend-example'; +const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime'; +const RESEND_API_KEY = process.env.RESEND_API_KEY ?? ''; +const RESEND_WEBHOOK_SECRET = process.env.RESEND_WEBHOOK_SECRET ?? ''; +const DEFAULT_FROM = process.env.DEFAULT_FROM ?? 'onboarding@resend.dev'; +const RESEND_TEST_RECIPIENTS = [ + 'delivered@resend.dev', + 'bounced@resend.dev', + 'complained@resend.dev', +]; +const ALLOWED_RECIPIENTS = [ + ...new Set([ + ...RESEND_TEST_RECIPIENTS, + ...(process.env.RESEND_ALLOWED_RECIPIENTS ?? '') + .split(',') + .map(value => value.trim().toLowerCase()) + .filter(Boolean), + ]), +]; +const SERVER_TOKEN_PATH = path.resolve(__dirname, '.stdb-server-token'); + +let stdb: DbConnection | null = null; +let resendConfigured = false; + +type ConnectedServer = { + connection: DbConnection; + identity: string; +}; + +function connectAttempt(token: string | undefined): Promise { + return new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(STDB_URI) + .withDatabaseName(STDB_DB) + .onConnect((connection, identity, nextToken) => { + if (!process.env.STDB_SERVER_TOKEN?.trim()) { + saveServerToken(SERVER_TOKEN_PATH, nextToken); + } + resolve({ connection, identity: identity.toHexString() }); + }) + .onDisconnect((_ctx, err) => { + console.error( + `[stdb] disconnected: ${err?.message ?? 'unknown'} - exiting for supervisor restart` + ); + process.exit(1); + }) + .onConnectError((_ctx: ErrorContext, err) => reject(err)); + if (token) builder = builder.withToken(token); + builder.build(); + }); +} + +async function connectStdb(): Promise { + const stored = loadServerToken( + SERVER_TOKEN_PATH, + process.env.STDB_SERVER_TOKEN + ); + try { + return await connectAttempt(stored.token); + } catch (error) { + if (stored.source !== 'file') throw error; + discardStoredServerToken(SERVER_TOKEN_PATH); + console.warn( + '[stdb] stored server token was rejected; creating a new identity' + ); + return connectAttempt(undefined); + } +} + +function requireStdb(): DbConnection { + if (!stdb) throw new Error('STDB not connected yet'); + return stdb; +} + +const app = express(); + +// Webhook uses RAW body (svix signs raw bytes); mounted before express.json(). +app.post( + '/webhook/resend', + express.raw({ type: '*/*', limit: '512kb' }), + handleResendWebhook +); + +app.use(express.json({ limit: '512kb' })); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, database: STDB_DB }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + stdbUri: STDB_URI, + database: STDB_DB, + resendConfigured, + defaultFrom: DEFAULT_FROM, + allowedRecipients: ALLOWED_RECIPIENTS, + }); +}); + +// Forward the raw body and Svix headers to the module's native route for +// signature verification and ingestion. +async function handleResendWebhook(req: Request, res: Response): Promise { + const rawBody = + req.body instanceof Buffer ? req.body : Buffer.from(String(req.body ?? '')); + const url = `${STDB_HTTP}/v1/database/${STDB_DB}/route/webhook/resend`; + + const headers: Record = { + 'content-type': 'application/json', + }; + for (const name of ['svix-id', 'svix-timestamp', 'svix-signature']) { + const value = req.headers[name]; + if (typeof value === 'string') headers[name] = value; + else if (Array.isArray(value)) headers[name] = value.join(','); + } + + try { + const upstream = await fetch(url, { + method: 'POST', + headers, + body: rawBody, + }); + const text = await upstream.text(); + res.status(upstream.status).send(text); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + console.error(`[webhook] passthrough to STDB route failed: ${reason}`); + res.status(502).send(`passthrough failed: ${reason}`); + } +} + +async function bootstrapResendConfig(): Promise { + if (!RESEND_API_KEY) { + resendConfigured = false; + process.stdout.write( + ' ! RESEND_API_KEY missing: Dispatch connects; email sends require configuration\n' + ); + return; + } + + await requireStdb().procedures['resend.setResendConfig']({ + apiKey: RESEND_API_KEY, + webhookSigningSecret: RESEND_WEBHOOK_SECRET || undefined, + defaultFrom: DEFAULT_FROM, + }); + await requireStdb().procedures.setDispatchPolicy({ + allowedRecipientsJson: JSON.stringify(ALLOWED_RECIPIENTS), + }); + resendConfigured = true; + process.stdout.write(' + Resend config loaded from server environment\n'); +} + +(async () => { + console.log(`[stdb] connecting to ${STDB_URI}/${STDB_DB} ...`); + try { + const connected = await connectStdb(); + stdb = connected.connection; + grantServerIdentity({ + spacetimeBin: SPACETIME_BIN, + server: STDB_HTTP, + database: STDB_DB, + procedure: 'resend.add_admin_identity', + identity: connected.identity, + }); + console.log(`[stdb] connected as authorized server ${connected.identity}`); + } catch (err) { + console.error( + `[stdb] connection or authorization failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[stdb] is the SpacetimeDB host running and the module published?' + ); + process.exit(1); + } + + try { + await bootstrapResendConfig(); + } catch (err) { + console.error( + `[resend] configuration failed: ${err instanceof Error ? err.message : String(err)}` + ); + process.exit(1); + } + + app.listen(PORT, HOST, () => { + process.stdout.write(`\nDispatch running at http://${HOST}:${PORT}\n`); + if (!RESEND_WEBHOOK_SECRET) { + process.stdout.write( + ' ! RESEND_WEBHOOK_SECRET not set - incoming webhooks are rejected\n' + ); + } + process.stdout.write( + ` webhook endpoint: POST http://127.0.0.1:${PORT}/webhook/resend\n` + ); + process.stdout.write(` database: ${STDB_URI}/${STDB_DB}\n\n`); + }); +})(); diff --git a/spacetime-resend-ts/example/spacetimedb/package.json b/spacetime-resend-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..6bf47b66746 --- /dev/null +++ b/spacetime-resend-ts/example/spacetimedb/package.json @@ -0,0 +1,20 @@ +{ + "name": "spacetime-resend-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-resend-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-resend-example" + }, + "dependencies": { + "@spacetimedb/rate-limit": "workspace:*", + "@spacetimedb/resend": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^6.0.3" + } +} diff --git a/spacetime-resend-ts/example/spacetimedb/src/index.ts b/spacetime-resend-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..48c65f8719f --- /dev/null +++ b/spacetime-resend-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,270 @@ +// Dispatch: a thin host module over the Resend submodule. +// +// The browser never talks to Resend directly and is never granted admin. It calls +// host procedures that forward to the submodule through `ctx.as.resend`. Resend's +// base tables stay private; caller-scoped views below expose only the current +// connection's dispatches. + +import { schema, table, t, SenderError, Router } from 'spacetimedb/server'; +import * as resend from '@spacetimedb/resend/submodule'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { messageHtml } from './message'; + +const dispatchPolicy = table( + { name: 'dispatch_policy', public: false }, + { + singleton: t.bool().primaryKey(), + allowedRecipientsJson: t.string(), + updatedAt: t.timestamp(), + } +); + +const spacetimedb = schema({ + resend, + rateLimit, + dispatchPolicy, +}); + +function subjectFor(ctx: { sender: { toHexString(): string } }): string { + return ctx.sender.toHexString(); +} + +export const myDispatchEmails = spacetimedb.view( + { name: 'my_dispatch_emails', public: true }, + resend.t.array(resend.resendEmailTable.rowType), + ctx => [...ctx.db.resend.resendEmail.byUserId.filter(subjectFor(ctx))] +); + +export const myDispatchDeliveryEvents = spacetimedb.view( + { name: 'my_dispatch_delivery_events', public: true }, + resend.t.array(resend.resendDeliveryEventTable.rowType), + ctx => { + const out = []; + for (const email of ctx.db.resend.resendEmail.byUserId.filter( + subjectFor(ctx) + )) { + for (const event of ctx.db.resend.resendDeliveryEvent.byResendId.filter( + email.resendId + )) { + out.push(event); + } + } + return out; + } +); + +const MAX_SUBJECT = 200; +const MAX_MESSAGE = 5000; +const MAX_ALLOWED_RECIPIENTS = 10; +const MAX_RECIPIENT_POLICY_LENGTH = 4096; +const CALLER_SEND_LIMIT = 5; +const CALLER_WINDOW_SECONDS = 10 * 60; +const GLOBAL_SEND_LIMIT = 25; +const GLOBAL_WINDOW_SECONDS = 60 * 60; + +function fail(message: string): never { + throw new SenderError(`dispatch.${message}`); +} + +function normalizeEmail(email: string): string { + const out = email.trim().toLowerCase(); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(out)) fail('invalid_email'); + return out; +} + +function parseAllowedRecipients(value: string): string[] { + if (value.length > MAX_RECIPIENT_POLICY_LENGTH) { + fail('recipient_policy_too_large'); + } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + fail('invalid_recipient_policy'); + } + if (!Array.isArray(parsed) || parsed.length === 0) { + fail('invalid_recipient_policy'); + } + const recipients = [ + ...new Set(parsed.map(value => normalizeEmail(String(value)))), + ]; + if (recipients.length > MAX_ALLOWED_RECIPIENTS) { + fail('too_many_allowed_recipients'); + } + return recipients; +} + +function clean( + value: string | undefined, + fallback: string, + max: number +): string { + const out = (value ?? '').trim().replace(/\s+/g, ' '); + return (out || fallback).slice(0, max); +} + +const dispatchSendResult = t.object('DispatchSendResult', { + ok: t.bool(), + resendId: t.option(t.string()), + message: t.string(), +}); + +export const set_dispatch_policy = spacetimedb.procedure( + { allowedRecipientsJson: t.string() }, + t.bool(), + (ctx, args) => { + const isAdmin = ctx.withTx( + tx => tx.db.resend.resendAdminIdentity.identity.find(ctx.sender) != null + ); + if (!isAdmin) fail('not_authorized'); + const recipients = parseAllowedRecipients(args.allowedRecipientsJson); + ctx.withTx(tx => { + const existing = tx.db.dispatchPolicy.singleton.find(true); + const row = { + singleton: true, + allowedRecipientsJson: JSON.stringify(recipients), + updatedAt: ctx.timestamp, + }; + if (existing) tx.db.dispatchPolicy.singleton.update(row); + else tx.db.dispatchPolicy.insert(row); + }); + return true; + } +); + +// The host policy restricts recipients and applies caller and global quotas before +// delegating delivery through the private Resend configuration. +export const send_dispatch = spacetimedb.procedure( + { + to: t.string(), + subject: t.string(), + message: t.string(), + }, + dispatchSendResult, + (ctx, args) => { + const to = normalizeEmail(args.to); + const subject = clean(args.subject, '(no subject)', MAX_SUBJECT); + const message = (args.message ?? '').trim().slice(0, MAX_MESSAGE); + if (!message) fail('empty_message'); + + const policyJson = ctx.withTx( + tx => + tx.db.dispatchPolicy.singleton.find(true)?.allowedRecipientsJson ?? null + ); + if (policyJson == null) fail('policy_missing'); + const allowed = parseAllowedRecipients(policyJson); + if (!allowed.includes(to)) fail('recipient_not_allowed'); + + const authorization = ctx.withTx(tx => { + const caller = rateLimit.consumeRateLimit(tx.as.rateLimit, { + key: `dispatch:caller:${subjectFor(ctx)}`, + scope: 'dispatch.send.caller', + limit: CALLER_SEND_LIMIT, + windowSeconds: CALLER_WINDOW_SECONDS, + }); + if (!caller.allowed) return 'rate_limited' as const; + const global = rateLimit.consumeRateLimit(tx.as.rateLimit, { + key: 'dispatch:global', + scope: 'dispatch.send.global', + limit: GLOBAL_SEND_LIMIT, + windowSeconds: GLOBAL_WINDOW_SECONDS, + }); + return global.allowed ? ('allowed' as const) : ('rate_limited' as const); + }); + if (authorization !== 'allowed') fail(authorization); + + try { + const result = resend.sendEmail(ctx.as.resend, { + to: [to], + subject, + html: messageHtml(message), + text: message, + tagsJson: JSON.stringify([ + { name: 'source', value: 'dispatch' }, + { name: 'userId', value: subjectFor(ctx) }, + ]), + }); + return { + ok: true, + resendId: result.resendId, + message: `Dispatched to ${to}.`, + }; + } catch { + return { + ok: false, + resendId: undefined, + message: 'dispatch.delivery_failed', + }; + } + } +); + +const dispatchDeleteResult = t.object('DispatchDeleteResult', { + ok: t.bool(), + removed: t.u32(), +}); + +// Remove a single dispatch and any delivery events it collected. This is a demo +// convenience so the log can be pruned; it writes directly to the submodule tables. +export const delete_dispatch = spacetimedb.procedure( + { resendId: t.string() }, + dispatchDeleteResult, + (ctx, args) => { + return ctx.withTx(tx => { + let removed = 0; + const row = tx.db.resend.resendEmail.resendId.find(args.resendId); + if (row && row.userId === subjectFor(ctx)) { + tx.db.resend.resendEmail.delete(row); + removed += 1; + for (const event of tx.db.resend.resendDeliveryEvent.byResendId.filter( + args.resendId + )) { + tx.db.resend.resendDeliveryEvent.delete(event); + } + } + return { ok: true, removed }; + }); + } +); + +// Clear the whole log. +export const clear_dispatches = spacetimedb.procedure( + {}, + dispatchDeleteResult, + ctx => { + return ctx.withTx(tx => { + let removed = 0; + const owned = [ + ...tx.db.resend.resendEmail.byUserId.filter(subjectFor(ctx)), + ]; + for (const row of owned) { + for (const event of tx.db.resend.resendDeliveryEvent.byResendId.filter( + row.resendId + )) { + tx.db.resend.resendDeliveryEvent.delete(event); + } + tx.db.resend.resendEmail.delete(row); + removed += 1; + } + return { ok: true, removed }; + }); + } +); + +// Resend posts delivery webhooks straight to the database over a native STDB HTTP +// route. The submodule verifies the svix signature in-module (via crypto-ts) and +// ingests. No Node relay does any of this work. +const resendWebhookHandler = resend.makeResendWebhookHandler(); +export const resend_webhook = spacetimedb.httpHandler((ctx, req) => + resendWebhookHandler(ctx.as.resend, req) +); +export const router = spacetimedb.httpRouter( + new Router().post('/webhook/resend', resend_webhook) +); + +export const init = spacetimedb.init(ctx => { + resend.installResend(ctx.as.resend); + rateLimit.installRateLimit(ctx.as.rateLimit); +}); + +export default spacetimedb; diff --git a/spacetime-resend-ts/example/spacetimedb/src/message.ts b/spacetime-resend-ts/example/spacetimedb/src/message.ts new file mode 100644 index 00000000000..38656a3766c --- /dev/null +++ b/spacetime-resend-ts/example/spacetimedb/src/message.ts @@ -0,0 +1,20 @@ +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export function messageHtml(message: string): string { + const paragraphs = message + .split(/\n{2,}/) + .map(block => escapeHtml(block).replace(/\n/g, '
          ')) + .map(block => `

          ${block}

          `) + .join(''); + return [ + "
          ', + paragraphs, + '
          ', + ].join(''); +} diff --git a/spacetime-resend-ts/example/spacetimedb/tsconfig.json b/spacetime-resend-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..f004a6cbc79 --- /dev/null +++ b/spacetime-resend-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-resend-ts/example/src/app.ts b/spacetime-resend-ts/example/src/app.ts new file mode 100644 index 00000000000..c93675fef49 --- /dev/null +++ b/spacetime-resend-ts/example/src/app.ts @@ -0,0 +1,672 @@ +import { + DbConnection, + type ErrorContext, + type EventContext, + type SubscriptionEventContext, +} from './codegen'; +import type { Timestamp } from 'spacetimedb'; +import { messageHtml } from '../spacetimedb/src/message'; + +type TableAccessor = { + iter(): Iterable; + onInsert(cb: (ctx: EventContext, row: T) => void): void; + onUpdate(cb: (ctx: EventContext, old: T, row: T) => void): void; + onDelete(cb: (ctx: EventContext, row: T) => void): void; +}; + +// Mirrors the caller-scoped my_dispatch_emails view. Options arrive as +// `T | undefined`; status is a tagged enum. +type ResendEmail = { + resendId: string; + fromAddress: string; + toAddressesJson: string; + subject?: string; + status: { tag: string } | string; + lastError?: string; + bouncedAt?: Timestamp; + failedAt?: Timestamp; + failureReason?: string; + complained: boolean; + complainedAt?: Timestamp; + opened: boolean; + openedAt?: Timestamp; + clicked: boolean; + clickedAt?: Timestamp; + deliveredAt?: Timestamp; + sentAt?: Timestamp; + createdAt: Timestamp; + updatedAt: Timestamp; +}; + +type SendResult = { + ok: boolean; + resendId?: string; + message: string; +}; + +type ServerConfig = { + stdbUri: string; + database: string; + resendConfigured: boolean; + defaultFrom: string; + allowedRecipients: string[]; +}; + +const $ = (id: string) => + document.getElementById(id) as T; + +let conn: DbConnection | null = null; +let currentConfig: ServerConfig | null = null; +let sending = false; + +function escapeHtml(value: unknown): string { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function tagOf(value: { tag: string } | string | undefined): string { + if (value && typeof value === 'object' && 'tag' in value) return value.tag; + return String(value ?? ''); +} + +function timestampMs(ts: Timestamp | undefined): number { + if (!ts) return 0; + return Number(ts.microsSinceUnixEpoch / 1000n); +} + +function timeLabel(ts: Timestamp | undefined): string { + const ms = timestampMs(ts); + if (!ms) return ''; + return new Date(ms).toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }); +} + +const AVATAR_COLORS = [ + '#4cf490', + '#02befa', + '#a880ff', + '#fbdc8e', + '#ff9e9e', + '#00ccb4', + '#ff80fb', +]; + +// The Resend test addresses always get the same, recognizable colour. +const KNOWN_AVATAR_COLORS: Record = { + 'delivered@resend.dev': '#4cf490', // green + 'bounced@resend.dev': '#ff9e9e', // orange + 'complained@resend.dev': '#a880ff', // purple +}; + +function avatarColor(seed: string): string { + const known = KNOWN_AVATAR_COLORS[seed.toLowerCase()]; + if (known) return known; + let h = 0; + for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0; + return AVATAR_COLORS[h % AVATAR_COLORS.length]; +} + +function recipient(row: ResendEmail): string { + try { + const parsed = JSON.parse(row.toAddressesJson); + if (Array.isArray(parsed) && parsed.length > 0) return String(parsed[0]); + } catch { + /* fall through */ + } + return row.toAddressesJson; +} + +function initials(email: string): string { + const local = email.split('@')[0] ?? email; + return (local.slice(0, 2) || '?').toUpperCase(); +} + +function showError(message: string) { + $('form-error').textContent = message; +} + +function clearError() { + $('form-error').textContent = ''; +} + +let flashTimer: ReturnType | undefined; + +function flashSent() { + const btn = $('send-btn') as HTMLButtonElement; + btn.classList.add('sent'); + btn.textContent = 'Sent'; + if (flashTimer) clearTimeout(flashTimer); + flashTimer = setTimeout(() => { + btn.classList.remove('sent'); + if (!sending) btn.textContent = 'Send'; + }, 1800); +} + +async function loadServerConfig(): Promise { + const res = await fetch('/api/config'); + if (!res.ok) throw new Error(`/api/config returned ${res.status}`); + return (await res.json()) as ServerConfig; +} + +async function connect(config: ServerConfig): Promise { + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(config.stdbUri) + .withDatabaseName(config.database) + .onConnect(c => resolve(c)) + .onDisconnect((_ctx, err) => { + showError(`Disconnected: ${err?.message ?? 'connection lost'}`); + }) + .onConnectError((_ctx: ErrorContext, err) => reject(err)) + .build(); + }); +} + +function table(name: string): TableAccessor | undefined { + const db = (conn?.db ?? {}) as Record | undefined>; + return db[name]; +} + +function emails(): ResendEmail[] { + return [...(table('myDispatchEmails')?.iter() ?? [])].sort( + (a, b) => { + return timestampMs(b.createdAt) - timestampMs(a.createdAt); + } + ); +} + +type Node = { + key: string; + label: string; + at?: Timestamp; + state: 'done' | 'live' | 'pending' | 'bad' | 'warn'; +}; + +// Turn a stored email into an ordered set of lifecycle nodes. The EmailStatus enum +// tops out at Delivered; Opened/Clicked are booleans; negatives are terminal. +function timeline(row: ResendEmail): Node[] { + const status = tagOf(row.status); + const nodes: Node[] = [ + { key: 'queued', label: 'Queued', at: row.createdAt, state: 'done' }, + ]; + + const sentReached = + Boolean(row.sentAt) || + Boolean(row.deliveredAt) || + row.opened || + row.clicked || + status === 'Sent' || + status === 'Delivered'; + nodes.push({ + key: 'sent', + label: 'Sent', + at: row.sentAt, + state: sentReached ? 'done' : 'pending', + }); + + if (status === 'Bounced') { + nodes.push({ + key: 'bounced', + label: 'Bounced', + at: row.bouncedAt, + state: 'bad', + }); + return nodes; + } + if (status === 'Failed') { + nodes.push({ + key: 'failed', + label: 'Failed', + at: row.failedAt, + state: 'bad', + }); + return nodes; + } + if (status === 'Cancelled') { + nodes.push({ key: 'cancelled', label: 'Cancelled', state: 'warn' }); + return nodes; + } + + // A complaint (marked as spam) implies the mail was delivered, and it is terminal. + const deliveredReached = + Boolean(row.deliveredAt) || + row.opened || + row.clicked || + status === 'Delivered' || + row.complained; + const delayed = status === 'DeliveryDelayed' && !deliveredReached; + nodes.push({ + key: 'delivered', + label: delayed ? 'Delayed' : 'Delivered', + at: row.deliveredAt, + state: deliveredReached ? 'done' : delayed ? 'warn' : 'pending', + }); + + if (row.complained) { + // Terminal: show completed positive steps followed by Complaint. + // No trailing "pending" steps and no live pulse - the lifecycle is over. + if (row.opened) + nodes.push({ + key: 'opened', + label: 'Opened', + at: row.openedAt, + state: 'done', + }); + if (row.clicked) + nodes.push({ + key: 'clicked', + label: 'Clicked', + at: row.clickedAt, + state: 'done', + }); + nodes.push({ + key: 'complaint', + label: 'Complaint', + at: row.complainedAt, + state: 'bad', + }); + return nodes; + } + + nodes.push({ + key: 'opened', + label: 'Opened', + at: row.openedAt, + state: row.opened ? 'done' : 'pending', + }); + nodes.push({ + key: 'clicked', + label: 'Clicked', + at: row.clickedAt, + state: row.clicked ? 'done' : 'pending', + }); + + // The furthest reached positive node is the live frontier. A completed later + // node makes the final completed positive node the subtle accent. + const lastDone = nodes.map(n => n.state === 'done').lastIndexOf(true); + if ( + lastDone > 0 && + nodes[lastDone].state === 'done' && + nodes.some(n => n.state === 'pending') + ) { + nodes[lastDone].state = 'live'; + } + return nodes; +} + +function headPill(row: ResendEmail): { cls: string; text: string } { + const status = tagOf(row.status); + if (status === 'Bounced') return { cls: 'red', text: 'Bounced' }; + if (status === 'Failed') return { cls: 'red', text: 'Failed' }; + if (status === 'Cancelled') return { cls: 'muted', text: 'Cancelled' }; + if (row.complained) return { cls: 'red', text: 'Complaint' }; + if (row.clicked) return { cls: 'green', text: 'Clicked' }; + if (row.opened) return { cls: 'green', text: 'Opened' }; + if (row.deliveredAt || status === 'Delivered') + return { cls: 'green', text: 'Delivered' }; + if (status === 'DeliveryDelayed') return { cls: 'yellow', text: 'Delayed' }; + if (row.sentAt || status === 'Sent') return { cls: 'green', text: 'Sent' }; + return { cls: 'muted', text: 'Queued' }; +} + +// Per-email rendered-node keys ensure each changed transition animates once. +const lastReached = new Map>(); +const knownEmails = new Set(); +const lastCounts: Record = { + sent: -1, + delivered: -1, + opened: -1, + bounced: -1, +}; + +function prefersReducedMotion(): boolean { + return ( + typeof matchMedia === 'function' && + matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +// Odometer roll: stack the current and incoming values in a clip window. An +// increase rolls up from below; a decrease rolls down. +function rollNumber(el: HTMLElement, from: number, to: number) { + const up = to > from; + const top = up ? from : to; + const bottom = up ? to : from; + el.innerHTML = + `` + + `${top}${bottom}` + + ``; + const odo = el.querySelector('.odo'); + if (!odo) { + el.textContent = String(to); + return; + } + // Collapse back to a plain number once the roll finishes (or is superseded). + const settle = () => { + if (el.contains(odo)) el.textContent = String(to); + }; + odo.addEventListener('animationend', settle, { once: true }); + setTimeout(settle, 600); +} + +function bumpMetric(id: string, key: string, value: number) { + const el = $(id); + const prev = lastCounts[key]; + lastCounts[key] = value; + if (prev < 0 || prev === value || prefersReducedMotion()) { + el.textContent = String(value); + return; + } + rollNumber(el, prev, value); +} + +function renderMetrics(list: ResendEmail[]) { + const delivered = list.filter( + r => + r.deliveredAt || r.opened || r.clicked || tagOf(r.status) === 'Delivered' + ).length; + const opened = list.filter(r => r.opened).length; + const bounced = list.filter(r => { + const s = tagOf(r.status); + return s === 'Bounced' || s === 'Failed' || r.complained; + }).length; + bumpMetric('count-sent', 'sent', list.length); + bumpMetric('count-delivered', 'delivered', delivered); + bumpMetric('count-opened', 'opened', opened); + bumpMetric('count-bounced', 'bounced', bounced); +} + +function nodeHtml(node: Node, lit: boolean): string { + const time = node.at ? timeLabel(node.at) : ''; + return ` +
          + + ${escapeHtml(node.label)} + ${escapeHtml(time)} +
          `; +} + +function mailHtml( + row: ResendEmail, + nodes: Node[], + newlyLit: Set, + isNew: boolean +): string { + const to = recipient(row); + const pill = headPill(row); + const bad = pill.cls === 'red'; + const subject = + row.subject && row.subject.length ? row.subject : '(no subject)'; + const track = nodes.map(n => nodeHtml(n, newlyLit.has(n.key))).join(''); + const error = + bad && row.failureReason + ? `
          ${escapeHtml(row.failureReason)}
          ` + : bad && row.lastError + ? `
          ${escapeHtml(row.lastError)}
          ` + : ''; + return ` +
          +
          + ${escapeHtml(initials(to))} + + ${escapeHtml(to)} + ${escapeHtml(subject)} + + ${escapeHtml(timeLabel(row.createdAt))} + +
          +
          ${track}
          + ${error} +
          `; +} + +function reachedKeys(nodes: Node[]): Set { + return new Set(nodes.filter(n => n.state !== 'pending').map(n => n.key)); +} + +function render() { + const list = emails(); + renderMetrics(list); + $('feed-count').textContent = `${list.length} sent`; + ($('clear-btn') as HTMLButtonElement).disabled = list.length === 0; + + if (list.length === 0) { + $('feed').innerHTML = + `
          No dispatches yet.
          Compose a message and hit Send to watch it move.
          `; + lastReached.clear(); + knownEmails.clear(); + return; + } + + const entries = list.map(row => ({ row, nodes: timeline(row) })); + + $('feed').innerHTML = entries + .map(({ row, nodes }) => { + const reached = reachedKeys(nodes); + const prev = lastReached.get(row.resendId); + const isNew = !knownEmails.has(row.resendId); + const newlyLit = new Set(); + if (prev) for (const k of reached) if (!prev.has(k)) newlyLit.add(k); + return mailHtml(row, nodes, newlyLit, isNew); + }) + .join(''); + + // Record post-render state so the next render can diff against it. + const currentIds = new Set(); + for (const { row, nodes } of entries) { + lastReached.set(row.resendId, reachedKeys(nodes)); + knownEmails.add(row.resendId); + currentIds.add(row.resendId); + } + for (const id of [...knownEmails]) { + if (!currentIds.has(id)) { + knownEmails.delete(id); + lastReached.delete(id); + } + } +} + +// Coalesce the burst of table events from a single webhook (email row update + +// delivery-event insert land together) into one render, so the change diff sees the +// whole transition at once and animates the node that advanced. +let renderScheduled = false; +function scheduleRender() { + if (renderScheduled) return; + renderScheduled = true; + setTimeout(() => { + renderScheduled = false; + render(); + }, 0); +} + +function wireTable(name: string) { + const accessor = table(name); + if (!accessor) throw new Error(`missing table accessor: ${name}`); + accessor.onInsert(() => scheduleRender()); + accessor.onUpdate(() => scheduleRender()); + accessor.onDelete(() => scheduleRender()); +} + +function wireDataHandlers() { + for (const name of ['myDispatchEmails', 'myDispatchDeliveryEvents']) { + wireTable(name); + } + + conn! + .subscriptionBuilder() + .onApplied((_ctx: SubscriptionEventContext) => { + render(); + if (!currentConfig?.resendConfigured) { + showError( + 'RESEND_API_KEY is missing in this example server environment.' + ); + } + }) + .onError((ctx: ErrorContext) => { + console.error('subscription error:', ctx.event); + showError('Subscription failed. Check the server console.'); + }) + .subscribe([ + 'SELECT * FROM my_dispatch_emails', + 'SELECT * FROM my_dispatch_delivery_events', + ]); +} + +async function sendDispatch( + to: string, + subject: string, + message: string +): Promise { + if (!conn) throw new Error('not connected'); + return conn.procedures.sendDispatch({ to, subject, message }); +} + +async function deleteDispatch(resendId: string): Promise { + if (!conn) throw new Error('not connected'); + await conn.procedures.deleteDispatch({ resendId }); +} + +async function clearDispatches(): Promise { + if (!conn) throw new Error('not connected'); + await conn.procedures.clearDispatches({}); +} + +function setSending(next: boolean) { + sending = next; + const btn = $('send-btn') as HTMLButtonElement; + btn.disabled = next; + if (next) btn.textContent = 'Sending...'; + else if (!btn.classList.contains('sent')) btn.textContent = 'Send'; +} + +function renderPreview() { + const subject = + ($('subject-input') as HTMLInputElement).value.trim() || '(no subject)'; + const message = ($('message-input') as HTMLTextAreaElement).value.trim(); + const from = currentConfig?.defaultFrom || 'onboarding@resend.dev'; + const body = message + ? messageHtml(message) + : 'Nothing to preview yet.'; + $('message-preview').innerHTML = ` +
          +
          ${escapeHtml(subject)}
          +
          From ${escapeHtml(from)}
          +
          +
          ${body}
          `; +} + +function setMsgTab(tab: 'write' | 'preview') { + const isPreview = tab === 'preview'; + if (isPreview) renderPreview(); + ($('message-input') as HTMLTextAreaElement).hidden = isPreview; + $('message-preview').hidden = !isPreview; + document.querySelectorAll('.msg-tab').forEach(b => { + b.classList.toggle('active', b.dataset.tab === tab); + }); +} + +async function submitCompose() { + if (sending) return; + const to = ($('to-input') as HTMLInputElement).value.trim(); + const subject = ($('subject-input') as HTMLInputElement).value.trim(); + const message = ($('message-input') as HTMLTextAreaElement).value.trim(); + if (!to) { + showError('Add a recipient first.'); + return; + } + if (!message) { + showError('Write a message before sending.'); + return; + } + clearError(); + setSending(true); + try { + const result = await sendDispatch(to, subject, message); + setSending(false); + if (result.ok) { + ($('subject-input') as HTMLInputElement).value = ''; + ($('message-input') as HTMLTextAreaElement).value = ''; + setMsgTab('write'); + flashSent(); + } else { + showError(result.message); + } + } catch (err) { + setSending(false); + showError(err instanceof Error ? err.message : String(err)); + } +} + +function wireActions() { + $('compose-form').addEventListener('submit', event => { + event.preventDefault(); + submitCompose().catch(err => { + console.error(err); + showError(err instanceof Error ? err.message : String(err)); + }); + }); + + document.querySelectorAll('.msg-tab').forEach(button => { + button.addEventListener('click', () => { + setMsgTab(button.dataset.tab === 'preview' ? 'preview' : 'write'); + }); + }); + + document + .querySelectorAll('[data-fill]') + .forEach(button => { + button.addEventListener('click', () => { + const email = button.dataset.fill ?? 'delivered@resend.dev'; + ($('to-input') as HTMLInputElement).value = email; + const subject = $('subject-input') as HTMLInputElement; + const message = $('message-input') as HTMLTextAreaElement; + if (!subject.value.trim()) subject.value = 'Hello from Dispatch'; + if (!message.value.trim()) + message.value = 'Watching this one travel through the pipeline.'; + clearError(); + ($('to-input') as HTMLInputElement).focus(); + }); + }); + + $('feed').addEventListener('click', event => { + const btn = (event.target as HTMLElement).closest( + '[data-del]' + ); + if (!btn) return; + const resendId = btn.dataset.del; + if (!resendId) return; + btn.setAttribute('disabled', 'true'); + deleteDispatch(resendId).catch(err => { + console.error(err); + showError(err instanceof Error ? err.message : String(err)); + }); + }); + + $('clear-btn').addEventListener('click', () => { + clearDispatches().catch(err => { + console.error(err); + showError(err instanceof Error ? err.message : String(err)); + }); + }); +} + +async function main() { + wireActions(); + currentConfig = await loadServerConfig(); + const recipientInput = $('to-input') as HTMLInputElement; + recipientInput.value = currentConfig.allowedRecipients[0] ?? ''; + conn = await connect(currentConfig); + wireDataHandlers(); +} + +main().catch(err => { + console.error(err); + showError(err instanceof Error ? err.message : String(err)); +}); diff --git a/spacetime-resend-ts/example/tsconfig.json b/spacetime-resend-ts/example/tsconfig.json new file mode 100644 index 00000000000..8e5e2bbddf0 --- /dev/null +++ b/spacetime-resend-ts/example/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": false, + "esModuleInterop": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "public", "dist"] +} diff --git a/spacetime-resend-ts/package.json b/spacetime-resend-ts/package.json new file mode 100644 index 00000000000..fe39fe9bd49 --- /dev/null +++ b/spacetime-resend-ts/package.json @@ -0,0 +1,71 @@ +{ + "name": "@spacetimedb/resend", + "description": "Resend email delivery, webhook verification, and event storage for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-resend-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-resend-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "resend", + "email", + "webhooks" + ], + "scripts": { + "build": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test-unit.ts", + "generate-ts": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-resend", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-resend", + "test:smoke": "tsx scripts/test-resend-smoke.ts", + "test:gate:local": "tsx scripts/test-resend-smoke.ts" + }, + "dependencies": { + "@spacetimedb/crypto": "workspace:^", + "valibot": "^1.4.2" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-resend-ts/scripts/test-resend-smoke.ts b/spacetime-resend-ts/scripts/test-resend-smoke.ts new file mode 100644 index 00000000000..2c46febdfa6 --- /dev/null +++ b/spacetime-resend-ts/scripts/test-resend-smoke.ts @@ -0,0 +1,454 @@ +// Smoke test: build, publish, ingest synthetic events, verify state. No API key or real webhooks needed. Usage: pnpm run test:resend:smoke [-- --skip-build-publish] + +import { spawn } from 'node:child_process'; +import { createHmac } from 'node:crypto'; + +type Options = { + server: string; + database: string; + skipBuildPublish: boolean; +}; + +function parseArgs(argv: string[]): Options { + const opts: Options = { + server: 'http://127.0.0.1:3000', + // Dedicated DB so smoke test never overwrites the dev module's real config. + database: 'resend-ts-smoke-test', + skipBuildPublish: false, + }; + for (let i = 0; i < argv.length; i++) { + const raw = argv[i]!; + const flag = raw.replace(/^-+/, '').toLowerCase(); + if (flag === 'skip-build-publish') opts.skipBuildPublish = true; + if (flag === 'server') opts.server = argv[++i]!; + if (flag === 'database') opts.database = argv[++i]!; + } + return opts; +} + +function step(name: string) { + process.stdout.write(`\n==> ${name}\n`); +} + +function run( + cmd: string, + args: string[] +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise(resolve => { + const child = spawn(cmd, args, { shell: false }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', d => (stdout += String(d))); + child.stderr?.on('data', d => (stderr += String(d))); + child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); + child.on('error', err => + resolve({ code: 1, stdout, stderr: stderr + String(err) }) + ); + }); +} + +async function callReducer( + opts: Options, + name: string, + args: string[] +): Promise { + const result = await run('spacetime', [ + 'call', + '--server', + opts.server, + opts.database, + name, + ...args, + ]); + if (result.code !== 0) { + throw new Error( + `spacetime call ${name} failed: code=${result.code}\nstderr: ${result.stderr}\nstdout: ${result.stdout}` + ); + } + return result.stdout; +} + +// Expects the call to fail. Returns combined stderr/stdout for assertion. +async function expectCallFails( + opts: Options, + name: string, + args: string[], + anonymous = false +): Promise { + const result = await run('spacetime', [ + 'call', + ...(anonymous ? ['--anonymous'] : []), + '--server', + opts.server, + opts.database, + name, + ...args, + ]); + if (result.code === 0) { + throw new Error( + `expected ${name} to fail but it succeeded:\nstdout: ${result.stdout}` + ); + } + return result.stderr + result.stdout; +} + +function quote(s: string): string { + return JSON.stringify(s); +} + +function some(s: string): string { + return JSON.stringify({ some: s }); +} + +const RESEND_WEBHOOK_SECRET_RAW = 'resend_smoke_test_secret'; +const RESEND_WEBHOOK_SECRET = `whsec_${Buffer.from(RESEND_WEBHOOK_SECRET_RAW).toString('base64')}`; + +function svixSignature(args: { + eventId: string; + timestamp: string; + payloadJson: string; +}): string { + const digest = createHmac('sha256', RESEND_WEBHOOK_SECRET_RAW) + .update(`${args.eventId}.${args.timestamp}.${args.payloadJson}`) + .digest('base64'); + return `v1,${digest}`; +} + +async function ingestWebhook( + opts: Options, + eventId: string, + eventType: string, + payloadJson: string +) { + const timestamp = String(Math.floor(Date.now() / 1000)); + await callReducer(opts, 'ingest_resend_webhook', [ + quote(eventId), + quote(eventType), + quote(payloadJson), + some(svixSignature({ eventId, timestamp, payloadJson })), + some(timestamp), + ]); +} + +function eventPayload(args: { + type: string; + emailId: string; + from?: string; + to?: string[]; + subject?: string; + extra?: Record; +}): string { + const data: Record = { + email_id: args.emailId, + created_at: '2026-05-04T00:00:00Z', + from: args.from ?? 'onboarding@resend.dev', + to: args.to ?? ['delivered@resend.dev'], + subject: args.subject ?? 'smoke test', + ...(args.extra ?? {}), + }; + return JSON.stringify({ + type: args.type, + created_at: '2026-05-04T00:00:00Z', + data, + }); +} + +const EMAIL_STATUS = [ + 'Queued', + 'Sent', + 'Delivered', + 'DeliveryDelayed', + 'Bounced', + 'Failed', + 'Cancelled', +] as const; + +function emailStatus(rowText: string): string { + const parsed = JSON.parse(rowText); + const row = parsed?.[1]; + const variant = row?.[4]; + const tag = variant?.[0]; + if (typeof tag !== 'number' || tag < 0 || tag >= EMAIL_STATUS.length) { + throw new Error(`could not parse email status from row: ${rowText}`); + } + return EMAIL_STATUS[tag]!; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + // Unique run id so re-runs don't collide on idempotent webhook IDs. + const RUN = Date.now().toString(36); + const evt = (suffix: string) => `evt_${RUN}_${suffix}`; + const em = (suffix: string) => `em_${RUN}_${suffix}`; + + if (!opts.skipBuildPublish) { + step('spacetime build'); + const build = await run('spacetime', ['build']); + if (build.code !== 0) { + process.stderr.write(build.stderr); + throw new Error('build failed'); + } + + step(`spacetime publish --server ${opts.server} ${opts.database}`); + const publish = await run('spacetime', [ + 'publish', + '--server', + opts.server, + '--yes', + '--delete-data', + opts.database, + ]); + if (publish.code !== 0) { + process.stderr.write(publish.stderr); + throw new Error('publish failed'); + } + } + + // Negative path: send_email refuses cleanly when config is not set. + step('negative: send_email before config, expect failure'); + const preBootstrap = await expectCallFails(opts, 'send_email', [ + 'null', + JSON.stringify(['delivered@resend.dev']), + quote('hello'), + some('

          hello

          '), + 'null', + 'null', + 'null', + 'null', + 'null', + 'null', + 'null', + 'null', + ]); + if (!preBootstrap.toLowerCase().includes('config')) { + throw new Error( + `expected error to mention config; got: ${preBootstrap.slice(0, 400)}` + ); + } + + step('set_resend_config'); + await callReducer(opts, 'set_resend_config', [ + quote('re_smoke_placeholder'), + some(RESEND_WEBHOOK_SECRET), + some('onboarding@resend.dev'), + ]); + + step('negative: anonymous callers cannot query email state'); + const unauthorized = await expectCallFails( + opts, + 'get_email', + [quote(em('a'))], + true + ); + if (!unauthorized.toLowerCase().includes('not_authorized')) { + throw new Error( + `expected get_email to reject a non-admin caller: ${unauthorized.slice(0, 400)}` + ); + } + + step('negative: signed webhook type must match supplied event type'); + const mismatchEventId = evt('metadata_mismatch'); + const mismatchPayload = eventPayload({ + type: 'email.delivered', + emailId: em('metadata_mismatch'), + }); + const mismatchTimestamp = String(Math.floor(Date.now() / 1000)); + const mismatch = await expectCallFails(opts, 'ingest_resend_webhook', [ + quote(mismatchEventId), + quote('email.bounced'), + quote(mismatchPayload), + some( + svixSignature({ + eventId: mismatchEventId, + timestamp: mismatchTimestamp, + payloadJson: mismatchPayload, + }) + ), + some(mismatchTimestamp), + ]); + if (!mismatch.toLowerCase().includes('metadata')) { + throw new Error( + `expected signed metadata mismatch failure: ${mismatch.slice(0, 400)}` + ); + } + + // Email A happy path: queued -> sent -> delivered, then flag overlays must not roll status back. + step(`ingest email.sent for ${em('a')}`); + await ingestWebhook( + opts, + evt('a'), + 'email.sent', + eventPayload({ type: 'email.sent', emailId: em('a') }) + ); + + step(`ingest email.delivered for ${em('a')}`); + await ingestWebhook( + opts, + evt('b'), + 'email.delivered', + eventPayload({ type: 'email.delivered', emailId: em('a') }) + ); + + step(`verify status=delivered for ${em('a')}`); + let row = await callReducer(opts, 'get_email', [quote(em('a'))]); + if (emailStatus(row) !== 'Delivered') { + throw new Error(`expected delivered for ${em('a')}, got: ${row}`); + } + + step(`ingest email.complained for ${em('a')} (flag, must NOT change status)`); + await ingestWebhook( + opts, + evt('e'), + 'email.complained', + eventPayload({ type: 'email.complained', emailId: em('a') }) + ); + row = await callReducer(opts, 'get_email', [quote(em('a'))]); + if (emailStatus(row) !== 'Delivered') { + throw new Error(`complained should not flip status: ${row}`); + } + + step(`ingest email.opened for ${em('a')} (flag)`); + await ingestWebhook( + opts, + evt('g'), + 'email.opened', + eventPayload({ type: 'email.opened', emailId: em('a') }) + ); + row = await callReducer(opts, 'get_email', [quote(em('a'))]); + if (emailStatus(row) !== 'Delivered') { + throw new Error(`opened should not flip status: ${row}`); + } + + step(`ingest email.clicked for ${em('a')} (flag + detail captured)`); + await ingestWebhook( + opts, + evt('h'), + 'email.clicked', + eventPayload({ + type: 'email.clicked', + emailId: em('a'), + extra: { + click: { + ipAddress: '203.0.113.42', + link: 'https://spacetimedb.com', + timestamp: '2026-05-04T00:00:00Z', + userAgent: 'Mozilla/5.0 (smoke-test)', + }, + }, + }) + ); + const clickEvents = await callReducer( + opts, + 'list_delivery_events_for_email', + [quote(em('a'))] + ); + if (!clickEvents.includes('email.clicked')) { + throw new Error(`expected click event in delivery log: ${clickEvents}`); + } + if (!clickEvents.includes('spacetimedb.com')) { + throw new Error(`expected click detail (link) preserved: ${clickEvents}`); + } + + // Email C bounce path with structured detail. + step(`ingest email.bounced for ${em('c')}`); + await ingestWebhook( + opts, + evt('c'), + 'email.bounced', + eventPayload({ + type: 'email.bounced', + emailId: em('c'), + to: ['bounced@resend.dev'], + extra: { + bounce: { + message: 'Mailbox does not exist', + subType: 'NoEmail', + type: 'Permanent', + }, + }, + }) + ); + const bouncedRow = await callReducer(opts, 'get_email', [quote(em('c'))]); + if (emailStatus(bouncedRow) !== 'Bounced') { + throw new Error(`expected bounced for ${em('c')}: ${bouncedRow}`); + } + if (!bouncedRow.includes('Mailbox does not exist')) { + throw new Error(`expected bounce reason in row: ${bouncedRow}`); + } + + // Email D delivery_delayed status path. + step(`ingest email.delivery_delayed for ${em('d')}`); + await ingestWebhook( + opts, + evt('d'), + 'email.delivery_delayed', + eventPayload({ type: 'email.delivery_delayed', emailId: em('d') }) + ); + const delayed = await callReducer(opts, 'get_email', [quote(em('d'))]); + if (emailStatus(delayed) !== 'DeliveryDelayed') { + throw new Error(`expected delivery_delayed for ${em('d')}: ${delayed}`); + } + + // Email F failed status with reason. + step(`ingest email.failed for ${em('f')}`); + await ingestWebhook( + opts, + evt('f'), + 'email.failed', + eventPayload({ + type: 'email.failed', + emailId: em('f'), + extra: { failed: { reason: 'rate limited by destination MTA' } }, + }) + ); + const failedRow = await callReducer(opts, 'get_email', [quote(em('f'))]); + if (emailStatus(failedRow) !== 'Failed') { + throw new Error(`expected failed for ${em('f')}: ${failedRow}`); + } + if (!failedRow.includes('rate limited')) { + throw new Error(`expected failure reason in row: ${failedRow}`); + } + + // Idempotency + replay. + step( + `verify idempotency: re-ingest ${evt('a')} (already processed, should no-op)` + ); + await ingestWebhook( + opts, + evt('a'), + 'email.sent', + eventPayload({ type: 'email.sent', emailId: em('a') }) + ); + row = await callReducer(opts, 'get_email', [quote(em('a'))]); + if (emailStatus(row) !== 'Delivered') { + throw new Error(`replay broke status: ${row}`); + } + + step(`replay_webhook_event re-applies ${evt('b')} (status stays delivered)`); + await callReducer(opts, 'replay_webhook_event', [quote(evt('b'))]); + row = await callReducer(opts, 'get_email', [quote(em('a'))]); + if (emailStatus(row) !== 'Delivered') { + throw new Error(`replay reducer altered state: ${row}`); + } + + step('negative: replay unknown event_id, expect failure'); + const replayMissing = await expectCallFails(opts, 'replay_webhook_event', [ + quote('evt_does_not_exist_xyz'), + ]); + if (!replayMissing.toLowerCase().includes('not_found')) { + throw new Error( + `expected not_found error; got: ${replayMissing.slice(0, 400)}` + ); + } + + step( + 'done: smoke test passed (8 event types + idempotency + replay + 2 negative)' + ); +} + +main().catch(err => { + process.stderr.write( + `\nSMOKE TEST FAILED: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); +}); diff --git a/spacetime-resend-ts/scripts/test-unit.ts b/spacetime-resend-ts/scripts/test-unit.ts new file mode 100644 index 00000000000..c4cffb97510 --- /dev/null +++ b/spacetime-resend-ts/scripts/test-unit.ts @@ -0,0 +1,98 @@ +import * as assert from 'node:assert/strict'; +import { buildResendHttpRequest } from '../src/submodule/request.ts'; +import { validateEmailInput } from '../src/submodule/email-input.ts'; +import { parseResendEventType } from '../src/submodule/webhook-metadata.ts'; + +assert.equal( + parseResendEventType('{"type":"email.delivered","data":{}}'), + 'email.delivered' +); +assert.equal(parseResendEventType('{"type":"","data":{}}'), undefined); +assert.equal(parseResendEventType('{"data":{}}'), undefined); +assert.equal(parseResendEventType('{bad json'), undefined); +assert.equal(parseResendEventType('[]'), undefined); + +const request = buildResendHttpRequest({ + method: 'post', + path: '/emails', + apiKey: 're_test_secret', + jsonBody: '{"to":["user@example.com"]}', + idempotencyKey: 'email-user-1', +}); +assert.equal(request.url, 'https://api.resend.com/emails'); +assert.equal(request.method, 'POST'); +assert.equal(request.headers.Authorization, 'Bearer re_test_secret'); +assert.throws( + () => + buildResendHttpRequest({ + method: 'GET', + path: 'https://attacker.example/collect', + apiKey: 're_test_secret', + jsonBody: undefined, + idempotencyKey: undefined, + }), + /resend\.request_path_invalid/ +); +assert.throws( + () => + buildResendHttpRequest({ + method: 'GET', + path: '/emails\u007fblocked', + apiKey: 're_test_secret', + jsonBody: undefined, + idempotencyKey: undefined, + }), + /resend\.request_path_invalid/ +); +assert.throws( + () => + buildResendHttpRequest({ + method: 'TRACE', + path: '/emails', + apiKey: 're_test_secret', + jsonBody: undefined, + idempotencyKey: undefined, + }), + /resend\.request_method_invalid/ +); + +assert.doesNotThrow(() => + validateEmailInput({ + to: ['user@example.com'], + subject: 'Welcome', + text: 'Hello', + }) +); +assert.throws( + () => validateEmailInput({ to: [], subject: 'Welcome', text: 'Hello' }), + /resend\.send_email_no_recipients/ +); +assert.throws( + () => + validateEmailInput({ + to: ['user@example.com\u007fblocked'], + subject: 'Welcome', + text: 'Hello', + }), + /resend\.to_invalid_address/ +); +assert.throws( + () => + validateEmailInput({ + to: ['user@example.com'], + subject: 'Welcome\r\nx-injected: yes', + text: 'Hello', + }), + /resend\.send_email_invalid_subject/ +); +assert.throws( + () => + validateEmailInput({ + to: Array.from({ length: 101 }, (_, index) => `user${index}@example.com`), + subject: 'Welcome', + text: 'Hello', + }), + /resend\.to_too_many/ +); + +console.log('resend unit tests passed'); diff --git a/spacetime-resend-ts/src/index.ts b/spacetime-resend-ts/src/index.ts new file mode 100644 index 00000000000..c2683a06994 --- /dev/null +++ b/spacetime-resend-ts/src/index.ts @@ -0,0 +1,24 @@ +// Top-level entry. Only re-exports registered reducers/procedures (the runtime rejects other public exports). + +export { default, init } from './submodule/schema'; +export { + ingest_resend_webhook, + replay_webhook_event, +} from './submodule/webhooks'; + +// Setup procedures explicit (avoid re-exporting helpers from auth.ts / config.ts). +export { + set_resend_config, + get_resend_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; +export { + cancel_email, + get_email, + list_delivery_events_for_email, + list_emails_by_org_id, + list_emails_by_status, + list_emails_by_user_id, + resend_api_request, + send_email, +} from './submodule/operations'; diff --git a/spacetime-resend-ts/src/submodule.ts b/spacetime-resend-ts/src/submodule.ts new file mode 100644 index 00000000000..6c7a5910234 --- /dev/null +++ b/spacetime-resend-ts/src/submodule.ts @@ -0,0 +1,15 @@ +export { default } from './submodule/schema'; +export { + resendDeliveryEventTable, + resendEmailTable, + t, +} from './submodule/schema'; +export { installResend } from './submodule/install'; +export * from './submodule/webhooks'; +export * from './submodule/operations'; + +export { + set_resend_config, + get_resend_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; diff --git a/spacetime-resend-ts/src/submodule/auth.ts b/spacetime-resend-ts/src/submodule/auth.ts new file mode 100644 index 00000000000..894fbcfd826 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/auth.ts @@ -0,0 +1,80 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { throwSenderError } from './utils'; + +// Admin gate. Fresh publishes seed the owner via init. Public submodule calls +// never bootstrap admin state from "first caller wins". Procedure callers must +// pass the outer ctx.sender explicitly; transaction ctx may not carry sender. +type Sender = WriteCtx['sender']; +type ModuleTimestamp = WriteCtx['timestamp']; + +export type AdminVerdict = 'admin' | 'denied'; + +export function isAdmin(ctx: WriteCtx, sender: Sender): boolean { + return ctx.db.resendAdminIdentity.identity.find(sender) != null; +} + +export function adminVerdict(ctx: WriteCtx, sender: Sender): AdminVerdict { + return isAdmin(ctx, sender) ? 'admin' : 'denied'; +} + +export function denyIfNotAdmin(verdict: AdminVerdict): void { + if (verdict === 'denied') throwSenderError('resend.not_authorized'); +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError('resend.not_authorized'); +} + +// For owner-gated repair/setup code only. Do not call from a public bootstrap path. +export function seedAdmin( + ctx: WriteCtx, + sender: Sender, + timestamp: ModuleTimestamp +) { + if (ctx.db.resendAdminIdentity.identity.find(sender) != null) return; + ctx.db.resendAdminIdentity.insert({ + identity: sender, + addedAtMicros: timestamp.microsSinceUnixEpoch, + }); +} + +export const add_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + if (tx.db.resendAdminIdentity.identity.find(identity) == null) { + tx.db.resendAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + }); + return {}; + } +); + +export const remove_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + const existing = tx.db.resendAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.resendAdminIdentity.count() <= 1n) { + throwSenderError('resend.cannot_remove_last_admin'); + } + tx.db.resendAdminIdentity.delete(existing); + }); + return {}; + } +); diff --git a/spacetime-resend-ts/src/submodule/config.ts b/spacetime-resend-ts/src/submodule/config.ts new file mode 100644 index 00000000000..aab7c530f47 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/config.ts @@ -0,0 +1,112 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { adminVerdict, denyIfNotAdmin } from './auth'; +import { throwSenderError } from './utils'; + +export type ResendConfig = { + apiKey: string; + webhookSigningSecret: string | undefined; + defaultFrom: string | undefined; +}; + +export function loadConfigOrThrow(ctx: WriteCtx): ResendConfig { + const row = ctx.db.resendConfig.singleton.find(true); + if (!row) { + throwSenderError( + 'resend.config_not_set: call set_resend_config(...) first' + ); + } + return { + apiKey: row.apiKey, + webhookSigningSecret: row.webhookSigningSecret, + defaultFrom: row.defaultFrom, + }; +} + +export function loadConfigOrThrowFromProcedure( + ctx: ProcedureModuleCtx +): ResendConfig { + return ctx.withTx(tx => loadConfigOrThrow(tx)); +} + +function upsertConfig( + ctx: WriteCtx, + args: { + apiKey: string; + webhookSigningSecret?: string | undefined; + defaultFrom?: string | undefined; + } +) { + const existing = ctx.db.resendConfig.singleton.find(true); + const row = { + singleton: true, + apiKey: args.apiKey, + webhookSigningSecret: + args.webhookSigningSecret ?? existing?.webhookSigningSecret, + defaultFrom: args.defaultFrom ?? existing?.defaultFrom, + updatedAt: ctx.timestamp, + }; + if (!existing) { + ctx.db.resendConfig.insert(row); + return; + } + if (ctx.db.resendConfig.singleton.update) { + ctx.db.resendConfig.singleton.update(row); + } else { + ctx.db.resendConfig.delete(existing); + ctx.db.resendConfig.insert(row); + } +} + +// Requires an admin seeded by the database owner; no public first-call bootstrap. +export const set_resend_config = spacetimedb.procedure( + { + apiKey: t.string(), + webhookSigningSecret: t.option(t.string()), + defaultFrom: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + upsertConfig(tx, args); + }); + return {}; + } +); + +export const get_resend_config_status = spacetimedb.procedure( + {}, + t.object('ResendConfigStatus', { + isConfigured: t.bool(), + hasWebhookSecret: t.bool(), + defaultFrom: t.option(t.string()), + apiKeyLength: t.u16(), + }), + ctx => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + return ctx.withTx(tx => { + const row = tx.db.resendConfig.singleton.find(true); + if (!row) { + return { + isConfigured: false, + hasWebhookSecret: false, + defaultFrom: undefined, + apiKeyLength: 0, + }; + } + return { + isConfigured: true, + hasWebhookSecret: row.webhookSigningSecret !== undefined, + defaultFrom: row.defaultFrom, + apiKeyLength: row.apiKey.length, + }; + }); + } +); diff --git a/spacetime-resend-ts/src/submodule/email-input.ts b/spacetime-resend-ts/src/submodule/email-input.ts new file mode 100644 index 00000000000..c61608ff2a6 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/email-input.ts @@ -0,0 +1,90 @@ +import { hasControlCharacter } from './text-validation'; + +export type EmailInput = { + from?: string | undefined; + to: string[]; + subject: string; + html?: string | undefined; + text?: string | undefined; + cc?: string[] | undefined; + bcc?: string[] | undefined; + replyTo?: string[] | undefined; + tagsJson?: string | undefined; + headersJson?: string | undefined; + scheduledAt?: string | undefined; +}; + +function fail(code: string): never { + throw new Error(`resend.${code}`); +} + +function validateAddressList( + values: string[] | undefined, + field: string +): void { + if (values === undefined) return; + if (values.length > 100) fail(`${field}_too_many`); + for (const value of values) { + if ( + value.length === 0 || + value.length > 320 || + hasControlCharacter(value) + ) { + fail(`${field}_invalid_address`); + } + } +} + +function validateJson( + value: string | undefined, + field: string, + maxLength: number +): void { + if (value === undefined) return; + if (value.length > maxLength) fail(`${field}_too_large`); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + fail(`${field}_invalid_json`); + } + if (parsed === null || typeof parsed !== 'object') + fail(`${field}_invalid_json`); +} + +export function validateEmailInput(args: EmailInput): void { + if (args.to.length === 0) fail('send_email_no_recipients'); + validateAddressList(args.to, 'to'); + validateAddressList(args.cc, 'cc'); + validateAddressList(args.bcc, 'bcc'); + validateAddressList(args.replyTo, 'reply_to'); + const recipientCount = + args.to.length + (args.cc?.length ?? 0) + (args.bcc?.length ?? 0); + if (recipientCount > 100) fail('send_email_too_many_recipients'); + if ( + args.from !== undefined && + (args.from.length === 0 || + args.from.length > 320 || + hasControlCharacter(args.from)) + ) + fail('send_email_invalid_from'); + if ( + args.subject.length === 0 || + args.subject.length > 998 || + hasControlCharacter(args.subject) + ) { + fail('send_email_invalid_subject'); + } + if (args.html === undefined && args.text === undefined) + fail('send_email_missing_content'); + if ((args.html?.length ?? 0) > 200_000) fail('send_email_html_too_large'); + if ((args.text?.length ?? 0) > 200_000) fail('send_email_text_too_large'); + validateJson(args.tagsJson, 'tags', 16_384); + validateJson(args.headersJson, 'headers', 16_384); + if ( + (args.scheduledAt?.length ?? 0) > 128 || + hasControlCharacter(args.scheduledAt ?? '') + ) { + fail('send_email_invalid_schedule'); + } +} diff --git a/spacetime-resend-ts/src/submodule/email_writes.ts b/spacetime-resend-ts/src/submodule/email_writes.ts new file mode 100644 index 00000000000..c7839b68880 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/email_writes.ts @@ -0,0 +1,77 @@ +import { + EmailStatus, + type EmailStatusValue, + type ModuleTimestamp, + type WriteCtx, +} from './schema'; + +// Any field passed as `undefined` preserves the existing row's value. Used by webhooks (sparse per-event fields). +export function upsertEmail( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + resendId: string; + fromAddress: string; + toAddressesJson: string; + subject: string | undefined; + html: string | undefined; + text: string | undefined; + status: EmailStatusValue | undefined; + lastError: string | undefined; + bouncedAt: ModuleTimestamp | undefined; + bounceJson: string | undefined; + failedAt: ModuleTimestamp | undefined; + failureReason: string | undefined; + complained: boolean; + complainedAt: ModuleTimestamp | undefined; + opened: boolean; + openedAt: ModuleTimestamp | undefined; + clicked: boolean; + clickedAt: ModuleTimestamp | undefined; + deliveredAt: ModuleTimestamp | undefined; + sentAt: ModuleTimestamp | undefined; + tagsJson: string | undefined; + userId: string | undefined; + orgId: string | undefined; + } +) { + const existing = ctx.db.resendEmail.resendId.find(args.resendId); + const row = { + resendId: args.resendId, + fromAddress: args.fromAddress, + toAddressesJson: args.toAddressesJson, + subject: args.subject ?? existing?.subject, + html: args.html ?? existing?.html, + text: args.text ?? existing?.text, + status: args.status ?? existing?.status ?? EmailStatus.Queued, + lastError: args.lastError ?? existing?.lastError, + bouncedAt: args.bouncedAt ?? existing?.bouncedAt, + bounceJson: args.bounceJson ?? existing?.bounceJson, + failedAt: args.failedAt ?? existing?.failedAt, + failureReason: args.failureReason ?? existing?.failureReason, + complained: args.complained || (existing?.complained ?? false), + complainedAt: args.complainedAt ?? existing?.complainedAt, + opened: args.opened || (existing?.opened ?? false), + openedAt: args.openedAt ?? existing?.openedAt, + clicked: args.clicked || (existing?.clicked ?? false), + clickedAt: args.clickedAt ?? existing?.clickedAt, + deliveredAt: args.deliveredAt ?? existing?.deliveredAt, + sentAt: args.sentAt ?? existing?.sentAt, + tagsJson: args.tagsJson ?? existing?.tagsJson, + userId: args.userId ?? existing?.userId, + orgId: args.orgId ?? existing?.orgId, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.resendEmail.insert(row); + return; + } + if (ctx.db.resendEmail.resendId.update) { + ctx.db.resendEmail.resendId.update(row); + } else { + ctx.db.resendEmail.delete(existing); + ctx.db.resendEmail.insert(row); + } +} diff --git a/spacetime-resend-ts/src/submodule/http.ts b/spacetime-resend-ts/src/submodule/http.ts new file mode 100644 index 00000000000..c71b70167ca --- /dev/null +++ b/spacetime-resend-ts/src/submodule/http.ts @@ -0,0 +1,66 @@ +import { type ProcedureModuleCtx, vResendErrorBody } from './schema'; +import { attemptToParse, safeJsonParse, throwSenderError } from './utils'; +import { buildResendHttpRequest } from './request'; + +export type ResendHttpResponse = { + status: number; + body: string; +}; + +export function callResend( + ctx: ProcedureModuleCtx, + args: { + method: string; + path: string; + apiKey: string; + jsonBody: string | undefined; + idempotencyKey: string | undefined; + } +): ResendHttpResponse { + let request; + try { + request = buildResendHttpRequest(args); + } catch (error) { + throwSenderError( + error instanceof Error ? error.message : 'resend.request_invalid' + ); + } + const response = ctx.http.fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + }); + return { + status: response.status, + body: response.text(), + }; +} +export function ensureOkOrThrow( + response: ResendHttpResponse, + errorPrefix: string +): void { + if (response.status >= 200 && response.status < 300) return; + throwSenderError( + `${errorPrefix}:${response.status}${resendErrorSuffix(response.body)}` + ); +} + +export function resendErrorSuffix(body: string): string { + const parsed = safeJsonParse(body); + if (parsed !== undefined) { + const result = attemptToParse(vResendErrorBody, parsed); + if (result.kind === 'success') { + const parts: string[] = []; + if (result.data.name) parts.push(`name=${result.data.name}`); + if (result.data.message) { + parts.push( + `msg=${result.data.message.replace(/\s+/g, ' ').slice(0, 240)}` + ); + } + if (parts.length > 0) return `:${parts.join('|')}`; + } + } + const compact = body.replace(/\s+/g, ' ').trim(); + if (!compact) return ''; + return `:body=${compact.slice(0, 240)}`; +} diff --git a/spacetime-resend-ts/src/submodule/install.ts b/spacetime-resend-ts/src/submodule/install.ts new file mode 100644 index 00000000000..67858e196b6 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/install.ts @@ -0,0 +1,9 @@ +import type { ReducerModuleCtx } from './schema'; + +export function installResend(ctx: ReducerModuleCtx) { + if (ctx.db.resendAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.resendAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-resend-ts/src/submodule/operations.ts b/spacetime-resend-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..d9f63cbf7a0 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/operations.ts @@ -0,0 +1,376 @@ +import * as v from 'valibot'; +import { + EmailStatus, + emailStatus, + resendDeliveryEventTable, + resendEmailTable, + sendEmailResult, + spacetimedb, + t, + vSendEmailResponse, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { callResend, ensureOkOrThrow } from './http'; +import { + attemptToParse, + safeJsonParse, + summarizeIssues, + throwSenderError, +} from './utils'; +import { upsertEmail } from './email_writes'; +import { loadConfigOrThrowFromProcedure } from './config'; +import { adminVerdict, denyIfNotAdmin } from './auth'; +import { validateEmailInput } from './email-input'; + +function requireProcedureAdmin(ctx: ProcedureModuleCtx): void { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); +} + +const MAX_QUERY_ROWS = 1000; + +function takeRows(rows: Iterable): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= MAX_QUERY_ROWS) break; + out.push(row); + } + return out; +} + +const vTagsForExtraction = v.union([ + v.record(v.string(), v.string()), + v.array(v.object({ name: v.string(), value: v.string() })), +]); + +function extractTagFieldsFromJson(tagsJson: string | undefined): { + userId: string | undefined; + orgId: string | undefined; +} { + if (!tagsJson) return { userId: undefined, orgId: undefined }; + const parsed = safeJsonParse(tagsJson); + if (parsed === undefined) return { userId: undefined, orgId: undefined }; + const result = attemptToParse(vTagsForExtraction, parsed); + if (result.kind === 'error') { + return { userId: undefined, orgId: undefined }; + } + const tags = result.data; + if (Array.isArray(tags)) { + let userId: string | undefined; + let orgId: string | undefined; + for (const tag of tags) { + if (tag.name === 'userId') userId = tag.value; + if (tag.name === 'orgId') orgId = tag.value; + } + return { userId, orgId }; + } + return { userId: tags['userId'], orgId: tags['orgId'] }; +} + +// Build POST /emails body. Resend HTTP API expects snake_case on the wire; SDK converts internally. We hand-roll, so emit snake_case directly. +type ResendSendEmailBody = { + from: string; + to: string[]; + subject: string; + html?: string; + text?: string; + cc?: string[]; + bcc?: string[]; + reply_to?: string[]; + scheduled_at?: string; + tags?: unknown; + headers?: unknown; +}; + +function buildSendEmailBody(args: { + from: string; + to: string[]; + subject: string; + html: string | undefined; + text: string | undefined; + cc: string[] | undefined; + bcc: string[] | undefined; + replyTo: string[] | undefined; + tagsJson: string | undefined; + headersJson: string | undefined; + scheduledAt: string | undefined; +}): string { + const body: ResendSendEmailBody = { + from: args.from, + to: args.to, + subject: args.subject, + }; + if (args.html !== undefined) body.html = args.html; + if (args.text !== undefined) body.text = args.text; + if (args.cc !== undefined && args.cc.length > 0) body.cc = args.cc; + if (args.bcc !== undefined && args.bcc.length > 0) body.bcc = args.bcc; + if (args.replyTo !== undefined && args.replyTo.length > 0) { + body.reply_to = args.replyTo; + } + if (args.scheduledAt !== undefined) body.scheduled_at = args.scheduledAt; + if (args.tagsJson !== undefined) { + const parsed = safeJsonParse(args.tagsJson); + if (parsed !== undefined) body.tags = parsed; + } + if (args.headersJson !== undefined) { + const parsed = safeJsonParse(args.headersJson); + if (parsed !== undefined) body.headers = parsed; + } + return JSON.stringify(body); +} + +function recordQueuedEmail( + ctx: WriteCtx, + now: ProcedureModuleCtx['timestamp'], + args: { + resendId: string; + from: string; + to: string[]; + subject: string; + html: string | undefined; + text: string | undefined; + tagsJson: string | undefined; + } +) { + const tagFields = extractTagFieldsFromJson(args.tagsJson); + upsertEmail(ctx, now, { + resendId: args.resendId, + fromAddress: args.from, + toAddressesJson: JSON.stringify(args.to), + subject: args.subject, + html: args.html, + text: args.text, + status: EmailStatus.Queued, + lastError: undefined, + bouncedAt: undefined, + bounceJson: undefined, + failedAt: undefined, + failureReason: undefined, + complained: false, + complainedAt: undefined, + opened: false, + openedAt: undefined, + clicked: false, + clickedAt: undefined, + deliveredAt: undefined, + sentAt: undefined, + tagsJson: args.tagsJson, + userId: tagFields.userId, + orgId: tagFields.orgId, + }); +} + +export type SendEmailArgs = { + from?: string | undefined; + to: string[]; + subject: string; + html?: string | undefined; + text?: string | undefined; + cc?: string[] | undefined; + bcc?: string[] | undefined; + replyTo?: string[] | undefined; + tagsJson?: string | undefined; + headersJson?: string | undefined; + scheduledAt?: string | undefined; + idempotencyKey?: string | undefined; +}; + +export function sendEmail(ctx: ProcedureModuleCtx, args: SendEmailArgs) { + try { + validateEmailInput(args); + } catch (error) { + throwSenderError( + error instanceof Error ? error.message : 'resend.send_email_invalid_input' + ); + } + const cfg = loadConfigOrThrowFromProcedure(ctx); + const fromAddress = args.from ?? cfg.defaultFrom; + if (!fromAddress) throwSenderError('resend.send_email_missing_from'); + + const jsonBody = buildSendEmailBody({ + from: fromAddress, + to: args.to, + subject: args.subject, + html: args.html, + text: args.text, + cc: args.cc, + bcc: args.bcc, + replyTo: args.replyTo, + tagsJson: args.tagsJson, + headersJson: args.headersJson, + scheduledAt: args.scheduledAt, + }); + + const response = callResend(ctx, { + method: 'POST', + path: '/emails', + apiKey: cfg.apiKey, + jsonBody, + idempotencyKey: args.idempotencyKey, + }); + ensureOkOrThrow(response, 'resend.send_email_failed'); + + const parsed = safeJsonParse(response.body); + if (parsed === undefined) + throwSenderError('resend.send_email_invalid_response'); + const result = attemptToParse(vSendEmailResponse, parsed); + if (result.kind === 'error') { + throwSenderError( + `resend.send_email_invalid_response:${summarizeIssues(result.issues)}` + ); + } + + const resendId = result.data.id; + ctx.withTx(tx => { + recordQueuedEmail(tx, ctx.timestamp, { + resendId, + from: fromAddress, + to: args.to, + subject: args.subject, + html: args.html, + text: args.text, + tagsJson: args.tagsJson, + }); + }); + return { resendId }; +} + +const sendEmailArgs = { + from: t.option(t.string()), + to: t.array(t.string()), + subject: t.string(), + html: t.option(t.string()), + text: t.option(t.string()), + cc: t.option(t.array(t.string())), + bcc: t.option(t.array(t.string())), + replyTo: t.option(t.array(t.string())), + tagsJson: t.option(t.string()), + headersJson: t.option(t.string()), + scheduledAt: t.option(t.string()), + idempotencyKey: t.option(t.string()), +}; + +export const send_email = spacetimedb.procedure( + sendEmailArgs, + sendEmailResult, + (ctx, args) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + return sendEmail(ctx, args); + } +); + +export const cancel_email = spacetimedb.procedure( + { resendId: t.string() }, + t.unit(), + (ctx, args) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callResend(ctx, { + method: 'POST', + path: `/emails/${args.resendId}/cancel`, + apiKey: cfg.apiKey, + jsonBody: undefined, + idempotencyKey: undefined, + }); + ensureOkOrThrow(response, 'resend.cancel_email_failed'); + + ctx.withTx(tx => { + const existing = tx.db.resendEmail.resendId.find(args.resendId); + if (!existing) return; + const updated = { + ...existing, + status: EmailStatus.Cancelled, + updatedAt: ctx.timestamp, + }; + if (tx.db.resendEmail.resendId.update) { + tx.db.resendEmail.resendId.update(updated); + } else { + tx.db.resendEmail.delete(existing); + tx.db.resendEmail.insert(updated); + } + }); + return {}; + } +); + +export const get_email = spacetimedb.procedure( + { resendId: t.string() }, + t.option(resendEmailTable.rowType), + (ctx, { resendId }) => { + requireProcedureAdmin(ctx); + return ctx.withTx( + tx => tx.db.resendEmail.resendId.find(resendId) ?? undefined + ); + } +); + +export const list_emails_by_user_id = spacetimedb.procedure( + { userId: t.string() }, + t.array(resendEmailTable.rowType), + (ctx, { userId }) => { + requireProcedureAdmin(ctx); + return ctx.withTx(tx => + takeRows(tx.db.resendEmail.byUserId.filter(userId)) + ); + } +); + +export const list_emails_by_org_id = spacetimedb.procedure( + { orgId: t.string() }, + t.array(resendEmailTable.rowType), + (ctx, { orgId }) => { + requireProcedureAdmin(ctx); + return ctx.withTx(tx => takeRows(tx.db.resendEmail.byOrgId.filter(orgId))); + } +); + +export const list_emails_by_status = spacetimedb.procedure( + { status: emailStatus }, + t.array(resendEmailTable.rowType), + (ctx, { status }) => { + requireProcedureAdmin(ctx); + return ctx.withTx(tx => + takeRows(tx.db.resendEmail.byStatus.filter(status)) + ); + } +); + +export const list_delivery_events_for_email = spacetimedb.procedure( + { resendId: t.string() }, + t.array(resendDeliveryEventTable.rowType), + (ctx, { resendId }) => { + requireProcedureAdmin(ctx); + return ctx.withTx(tx => + takeRows(tx.db.resendDeliveryEvent.byResendId.filter(resendId)) + ); + } +); + +export const resend_api_request = spacetimedb.procedure( + { + method: t.string(), + path: t.string(), + jsonBody: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + t.object('ResendApiRequestResult', { + status: t.u16(), + body: t.string(), + }), + (ctx, args) => { + // Administrators may make authenticated Resend calls with the stored key. + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + const cfg = loadConfigOrThrowFromProcedure(ctx); + return callResend(ctx, { + method: args.method, + path: args.path, + apiKey: cfg.apiKey, + jsonBody: args.jsonBody, + idempotencyKey: args.idempotencyKey, + }); + } +); diff --git a/spacetime-resend-ts/src/submodule/request.ts b/spacetime-resend-ts/src/submodule/request.ts new file mode 100644 index 00000000000..b7c067c77b6 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/request.ts @@ -0,0 +1,67 @@ +import { hasControlCharacter } from './text-validation'; + +const RESEND_API_BASE = 'https://api.resend.com'; +const ALLOWED_METHODS = new Set(['GET', 'POST', 'PATCH', 'DELETE']); +const MAX_PATH_LENGTH = 2048; +const MAX_JSON_BODY_LENGTH = 256 * 1024; +const MAX_IDEMPOTENCY_KEY_LENGTH = 256; + +export type ResendHttpRequest = { + method: string; + url: string; + headers: Record; + body: string | undefined; +}; + +function validatePath(path: string): string { + const normalized = path.trim(); + if (!normalized.startsWith('/') || normalized.startsWith('//')) { + throw new Error('resend.request_path_invalid'); + } + if (normalized.includes('\\') || normalized.includes('#')) { + throw new Error('resend.request_path_invalid'); + } + if (normalized.length > MAX_PATH_LENGTH || hasControlCharacter(normalized)) { + throw new Error('resend.request_path_invalid'); + } + return normalized; +} + +export function buildResendHttpRequest(args: { + method: string; + path: string; + apiKey: string; + jsonBody: string | undefined; + idempotencyKey: string | undefined; +}): ResendHttpRequest { + const method = args.method.trim().toUpperCase(); + if (!ALLOWED_METHODS.has(method)) { + throw new Error('resend.request_method_invalid'); + } + + const path = validatePath(args.path); + const body = args.jsonBody?.length ? args.jsonBody : undefined; + if (body !== undefined && body.length > MAX_JSON_BODY_LENGTH) { + throw new Error('resend.request_body_too_large'); + } + if ( + args.idempotencyKey && + args.idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH + ) { + throw new Error('resend.idempotency_key_too_long'); + } + + const headers: Record = { + Authorization: `Bearer ${args.apiKey}`, + Accept: 'application/json', + }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (args.idempotencyKey) headers['Idempotency-Key'] = args.idempotencyKey; + + return { + method, + url: `${RESEND_API_BASE}${path}`, + headers, + body, + }; +} diff --git a/spacetime-resend-ts/src/submodule/schema.ts b/spacetime-resend-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..47faa95f1a7 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/schema.ts @@ -0,0 +1,293 @@ +import { + schema, + table, + t, + Range, + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as v from 'valibot'; +import { installResend } from './install'; + +// Webhook delivery state. Received = ingest accepted. Processed = applied. +// Ignored = duplicate/unknown event type. Failed = signature/format error. +export const webhookEventStatus = t.enum('WebhookEventStatus', [ + 'Received', + 'Processed', + 'Ignored', + 'Failed', +]); +export const WebhookEventStatus = { + Received: { tag: 'Received' as const }, + Processed: { tag: 'Processed' as const }, + Ignored: { tag: 'Ignored' as const }, + Failed: { tag: 'Failed' as const }, +}; + +// Lifecycle of a tracked outbound email. Tags match Resend's +// `email.` webhook events for direct ingestion mapping. +export const emailStatus = t.enum('EmailStatus', [ + 'Queued', + 'Sent', + 'Delivered', + 'DeliveryDelayed', + 'Bounced', + 'Failed', + 'Cancelled', +]); +export const EmailStatus = { + Queued: { tag: 'Queued' as const }, + Sent: { tag: 'Sent' as const }, + Delivered: { tag: 'Delivered' as const }, + DeliveryDelayed: { tag: 'DeliveryDelayed' as const }, + Bounced: { tag: 'Bounced' as const }, + Failed: { tag: 'Failed' as const }, + Cancelled: { tag: 'Cancelled' as const }, +}; +export type EmailStatusValue = (typeof EmailStatus)[keyof typeof EmailStatus]; +export type WebhookEventStatusValue = + (typeof WebhookEventStatus)[keyof typeof WebhookEventStatus]; + +export const resendEmailRow = { + resendId: t.string().primaryKey(), + fromAddress: t.string(), + toAddressesJson: t.string(), + subject: t.option(t.string()), + status: emailStatus, + lastError: t.option(t.string()), + bouncedAt: t.option(t.timestamp()), + bounceJson: t.option(t.string()), + failedAt: t.option(t.timestamp()), + failureReason: t.option(t.string()), + complained: t.bool(), + complainedAt: t.option(t.timestamp()), + opened: t.bool(), + openedAt: t.option(t.timestamp()), + clicked: t.bool(), + clickedAt: t.option(t.timestamp()), + deliveredAt: t.option(t.timestamp()), + sentAt: t.option(t.timestamp()), + html: t.option(t.string()), + text: t.option(t.string()), + tagsJson: t.option(t.string()), + userId: t.option(t.string()), + orgId: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const resendDeliveryEventRow = { + eventId: t.string().primaryKey(), + resendId: t.string(), + eventType: t.string(), + createdAtIso: t.string(), + detailJson: t.option(t.string()), + insertedAt: t.timestamp(), +}; + +export const resendWebhookEventRow = { + eventId: t.string().primaryKey(), + eventType: t.string(), + payloadJson: t.string(), + signatureHeader: t.option(t.string()), + timestampHeader: t.option(t.string()), + status: webhookEventStatus, + errorMessage: t.option(t.string()), + receivedAt: t.timestamp(), + processedAt: t.option(t.timestamp()), +}; + +// Private singleton; secrets never leak via subscription. +export const resendConfigRow = { + singleton: t.bool().primaryKey(), + apiKey: t.string(), + webhookSigningSecret: t.option(t.string()), + defaultFrom: t.option(t.string()), + updatedAt: t.timestamp(), +}; + +// Fresh publishes seed the owner via init; public procedures never bootstrap admin state. +export const resendAdminIdentityRow = { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), +}; + +export const resendEmailTable = table( + { + name: 'resend_email', + public: false, + indexes: [ + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { accessor: 'byUpdatedAt', algorithm: 'btree', columns: ['updatedAt'] }, + ], + }, + resendEmailRow +); + +export const resendDeliveryEventTable = table( + { + name: 'resend_delivery_event', + public: false, + indexes: [ + { accessor: 'byResendId', algorithm: 'btree', columns: ['resendId'] }, + { + accessor: 'byResendIdEventType', + algorithm: 'btree', + columns: ['resendId', 'eventType'], + }, + ], + }, + resendDeliveryEventRow +); + +// Private because the raw payload and signature headers are operational data. +// Host modules can expose an admin-only view when needed. +export const resendWebhookEventTable = table( + { + name: 'resend_webhook_event', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + ], + }, + resendWebhookEventRow +); + +export const resendConfigTable = table( + { name: 'resend_config', public: false, indexes: [] }, + resendConfigRow +); + +export const resendAdminIdentityTable = table( + { name: 'resend_admin_identity', public: false, indexes: [] }, + resendAdminIdentityRow +); + +export const spacetimedb = schema({ + resendEmail: resendEmailTable, + resendDeliveryEvent: resendDeliveryEventTable, + resendWebhookEvent: resendWebhookEventTable, + resendConfig: resendConfigTable, + resendAdminIdentity: resendAdminIdentityTable, +}); + +export const init = spacetimedb.init(ctx => { + installResend(ctx); +}); + +export default spacetimedb; + +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx< + typeof spacetimedb.schemaType +>; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; +export type ModuleTimestamp = ReducerModuleCtx['timestamp']; + +export const sendEmailResult = t.object('SendEmailResult', { + resendId: t.string(), +}); + +export const resendHttpResponse = t.object('ResendHttpResponse', { + status: t.u16(), + body: t.string(), +}); + +export { Range, SenderError, t }; + +// Mirror Resend SDK's BaseEmailEventData. +const vBaseEmailEventData = { + broadcast_id: v.optional(v.string()), + created_at: v.string(), + email_id: v.string(), + from: v.string(), + to: v.array(v.string()), + subject: v.string(), + template_id: v.optional(v.string()), + tags: v.optional(v.record(v.string(), v.string())), +}; + +const vBaseEvent = { + created_at: v.string(), +}; + +export const vEmailEvent = v.variant('type', [ + v.object({ + ...vBaseEvent, + type: v.literal('email.sent'), + data: v.object(vBaseEmailEventData), + }), + v.object({ + ...vBaseEvent, + type: v.literal('email.delivered'), + data: v.object(vBaseEmailEventData), + }), + v.object({ + ...vBaseEvent, + type: v.literal('email.delivery_delayed'), + data: v.object(vBaseEmailEventData), + }), + v.object({ + ...vBaseEvent, + type: v.literal('email.complained'), + data: v.object(vBaseEmailEventData), + }), + v.object({ + ...vBaseEvent, + type: v.literal('email.bounced'), + data: v.object({ + ...vBaseEmailEventData, + bounce: v.object({ + message: v.string(), + subType: v.string(), + type: v.string(), + }), + }), + }), + v.object({ + ...vBaseEvent, + type: v.literal('email.opened'), + data: v.object(vBaseEmailEventData), + }), + v.object({ + ...vBaseEvent, + type: v.literal('email.clicked'), + data: v.object({ + ...vBaseEmailEventData, + click: v.object({ + ipAddress: v.string(), + link: v.string(), + timestamp: v.string(), + userAgent: v.string(), + }), + }), + }), + v.object({ + ...vBaseEvent, + type: v.literal('email.failed'), + data: v.object({ + ...vBaseEmailEventData, + failed: v.object({ + reason: v.string(), + }), + }), + }), +]); + +export type EmailEvent = v.InferOutput; +export type EmailEventType = EmailEvent['type']; + +export const vResendErrorBody = v.object({ + name: v.optional(v.string()), + message: v.optional(v.string()), + statusCode: v.optional(v.union([v.number(), v.null()])), +}); + +export const vSendEmailResponse = v.object({ + id: v.string(), +}); diff --git a/spacetime-resend-ts/src/submodule/text-validation.ts b/spacetime-resend-ts/src/submodule/text-validation.ts new file mode 100644 index 00000000000..5409d6119b8 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/text-validation.ts @@ -0,0 +1,7 @@ +export function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} diff --git a/spacetime-resend-ts/src/submodule/utils.ts b/spacetime-resend-ts/src/submodule/utils.ts new file mode 100644 index 00000000000..c792151a0cc --- /dev/null +++ b/spacetime-resend-ts/src/submodule/utils.ts @@ -0,0 +1,45 @@ +import * as v from 'valibot'; +import { SenderError } from 'spacetimedb/server'; + +export type ParseResult = + | { kind: 'success'; data: T } + | { kind: 'error'; issues: v.BaseIssue[] }; + +export function attemptToParse( + schema: TSchema, + input: unknown +): ParseResult> { + const result = v.safeParse(schema, input); + if (result.success) return { kind: 'success', data: result.output }; + return { kind: 'error', issues: result.issues }; +} + +export function assertExhaustive(value: never): never { + throw new Error(`Unhandled discriminant: ${value as string}`); +} + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function safeJsonParse(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return undefined; + } +} + +export function summarizeIssues(issues: v.BaseIssue[]): string { + if (issues.length === 0) return 'no issues'; + const head = issues[0]!; + const path = (head.path ?? []) + .map(p => + typeof p.key === 'string' || typeof p.key === 'number' + ? String(p.key) + : '?' + ) + .join('.'); + const where = path ? ` at ${path}` : ''; + return `${head.message}${where}`; +} diff --git a/spacetime-resend-ts/src/submodule/webhook-metadata.ts b/spacetime-resend-ts/src/submodule/webhook-metadata.ts new file mode 100644 index 00000000000..62b77b6e133 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/webhook-metadata.ts @@ -0,0 +1,13 @@ +export function parseResendEventType(payloadJson: string): string | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(payloadJson); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const eventType = (parsed as Record).type; + return typeof eventType === 'string' && eventType.length > 0 + ? eventType + : undefined; +} diff --git a/spacetime-resend-ts/src/submodule/webhooks.ts b/spacetime-resend-ts/src/submodule/webhooks.ts new file mode 100644 index 00000000000..beebf4edfd2 --- /dev/null +++ b/spacetime-resend-ts/src/submodule/webhooks.ts @@ -0,0 +1,397 @@ +import { + EmailStatus, + WebhookEventStatus, + spacetimedb, + t, + vEmailEvent, + type EmailEvent, + type EmailStatusValue, + type ModuleTimestamp, + type ReducerModuleCtx, + type WebhookEventStatusValue, + type WriteCtx, +} from './schema'; +import { upsertEmail } from './email_writes'; +import { requireAdmin } from './auth'; +import { verifySvixSignature } from '@spacetimedb/crypto'; +import { + SyncResponse, + type Request, + type HandlerContext, +} from 'spacetimedb/server'; +import { + assertExhaustive, + attemptToParse, + safeJsonParse, + summarizeIssues, + throwSenderError, +} from './utils'; +import { parseResendEventType } from './webhook-metadata'; + +type ResendTags = EmailEvent['data']['tags']; + +function toAddressArray(value: string | string[]): string[] { + return Array.isArray(value) ? value : [value]; +} + +function tagsToJson(tags: ResendTags): string | undefined { + if (!tags) return undefined; + try { + return JSON.stringify(tags); + } catch { + return undefined; + } +} + +function extractTagFields(tags: ResendTags): { + userId: string | undefined; + orgId: string | undefined; +} { + if (!tags) return { userId: undefined, orgId: undefined }; + if (Array.isArray(tags)) { + let userId: string | undefined; + let orgId: string | undefined; + for (const tag of tags) { + if (tag.name === 'userId') userId = tag.value; + if (tag.name === 'orgId') orgId = tag.value; + } + return { userId, orgId }; + } + return { userId: tags['userId'], orgId: tags['orgId'] }; +} + +function recordDeliveryEvent( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + eventId: string; + resendId: string; + eventType: string; + createdAtIso: string; + detailJson: string | undefined; + } +) { + if (ctx.db.resendDeliveryEvent.eventId.find(args.eventId)) return; + ctx.db.resendDeliveryEvent.insert({ + eventId: args.eventId, + resendId: args.resendId, + eventType: args.eventType, + createdAtIso: args.createdAtIso, + detailJson: args.detailJson, + insertedAt: now, + }); +} + +function updateWebhookStatus( + ctx: ReducerModuleCtx, + eventId: string, + status: WebhookEventStatusValue, + errorMessage: string | undefined +) { + const existing = ctx.db.resendWebhookEvent.eventId.find(eventId); + if (!existing) return; + + const isTerminal = status.tag === 'Processed' || status.tag === 'Failed'; + const updated = { + ...existing, + status, + errorMessage, + processedAt: isTerminal ? ctx.timestamp : existing.processedAt, + }; + + if (ctx.db.resendWebhookEvent.eventId.update) { + ctx.db.resendWebhookEvent.eventId.update(updated); + } else { + ctx.db.resendWebhookEvent.delete(existing); + ctx.db.resendWebhookEvent.insert(updated); + } +} + +function makeEmailUpsertArgs( + event: EmailEvent, + now: ModuleTimestamp +): Parameters[2] { + const data = event.data; + const fromAddress = Array.isArray(data.from) ? data.from[0]! : data.from; + const toAddresses = toAddressArray(data.to); + const tagFields = extractTagFields(data.tags); + const tagsJson = tagsToJson(data.tags); + const subject = data.subject; + + const base = { + resendId: data.email_id, + fromAddress, + toAddressesJson: JSON.stringify(toAddresses), + subject, + // undefined preserves whatever send_email recorded. + html: undefined, + text: undefined, + lastError: undefined, + bouncedAt: undefined, + bounceJson: undefined, + failedAt: undefined, + failureReason: undefined, + complained: false, + complainedAt: undefined, + opened: false, + openedAt: undefined, + clicked: false, + clickedAt: undefined, + deliveredAt: undefined, + sentAt: undefined, + tagsJson, + userId: tagFields.userId, + orgId: tagFields.orgId, + // status undefined = preserve existing or default to queued; branches override. + status: undefined as EmailStatusValue | undefined, + } satisfies Parameters[2]; + + switch (event.type) { + case 'email.sent': + return { ...base, status: EmailStatus.Sent, sentAt: now }; + case 'email.delivered': + return { ...base, status: EmailStatus.Delivered, deliveredAt: now }; + case 'email.delivery_delayed': + return { ...base, status: EmailStatus.DeliveryDelayed }; + case 'email.bounced': + return { + ...base, + status: EmailStatus.Bounced, + bouncedAt: now, + bounceJson: JSON.stringify(event.data.bounce), + lastError: event.data.bounce.message, + }; + case 'email.failed': + return { + ...base, + status: EmailStatus.Failed, + failedAt: now, + failureReason: event.data.failed.reason, + lastError: event.data.failed.reason, + }; + case 'email.complained': + return { ...base, complained: true, complainedAt: now }; + case 'email.opened': + return { ...base, opened: true, openedAt: now }; + case 'email.clicked': + return { ...base, clicked: true, clickedAt: now }; + default: + return assertExhaustive(event); + } +} + +function detailJsonForEvent(event: EmailEvent): string | undefined { + switch (event.type) { + case 'email.bounced': + return JSON.stringify(event.data.bounce); + case 'email.failed': + return JSON.stringify(event.data.failed); + case 'email.clicked': + return JSON.stringify(event.data.click); + case 'email.sent': + case 'email.delivered': + case 'email.delivery_delayed': + case 'email.complained': + case 'email.opened': + return undefined; + default: + return assertExhaustive(event); + } +} + +function applyResendEvent( + ctx: ReducerModuleCtx, + eventId: string, + payloadJson: string +): { status: WebhookEventStatusValue; error: string | undefined } { + const parsed = safeJsonParse(payloadJson); + if (parsed === undefined) { + return { status: WebhookEventStatus.Failed, error: 'invalid JSON payload' }; + } + + const result = attemptToParse(vEmailEvent, parsed); + if (result.kind === 'error') { + return { + status: WebhookEventStatus.Failed, + error: `payload validation failed: ${summarizeIssues(result.issues)}`, + }; + } + + const event = result.data; + const now = ctx.timestamp; + upsertEmail(ctx, now, makeEmailUpsertArgs(event, now)); + recordDeliveryEvent(ctx, now, { + eventId, + resendId: event.data.email_id, + eventType: event.type, + createdAtIso: event.created_at, + detailJson: detailJsonForEvent(event), + }); + return { status: WebhookEventStatus.Processed, error: undefined }; +} + +export interface ResendWebhookIngestArgs { + eventId: string; + eventType: string; + payloadJson: string; + signatureHeader?: string | undefined; + timestampHeader?: string | undefined; +} + +const MAX_WEBHOOK_BODY_LENGTH = 1024 * 1024; +const MAX_WEBHOOK_HEADER_LENGTH = 8192; +const MAX_WEBHOOK_METADATA_LENGTH = 255; + +// Verify the Svix signature in-module, then store and apply the event. The +// reducer and native HTTP route share this single ingest path and receive an +// HTTP-shaped result. +function ingestResendWebhook( + ctx: WriteCtx, + args: ResendWebhookIngestArgs +): { status: number; code: string } { + if ( + args.eventId.length === 0 || + args.eventId.length > MAX_WEBHOOK_METADATA_LENGTH || + args.eventType.length === 0 || + args.eventType.length > MAX_WEBHOOK_METADATA_LENGTH + ) { + return { status: 400, code: 'resend.webhook_metadata_invalid' }; + } + if (args.payloadJson.length > MAX_WEBHOOK_BODY_LENGTH) { + return { status: 413, code: 'resend.webhook_payload_too_large' }; + } + if ( + (args.signatureHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH || + (args.timestampHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH + ) { + return { status: 400, code: 'resend.webhook_header_too_large' }; + } + + const cfg = ctx.db.resendConfig.singleton.find(true); + if (!cfg?.webhookSigningSecret) { + return { status: 500, code: 'resend.webhook_secret_not_configured' }; + } + const nowSeconds = Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n); + const sigOk = verifySvixSignature({ + svixId: args.eventId, + svixTimestamp: args.timestampHeader ?? '', + svixSignature: args.signatureHeader ?? '', + rawBody: args.payloadJson, + secret: cfg.webhookSigningSecret, + nowSeconds, + }); + if (!sigOk) return { status: 401, code: 'resend.webhook_signature_mismatch' }; + + const signedEventType = parseResendEventType(args.payloadJson); + if (!signedEventType || signedEventType !== args.eventType) { + return { status: 400, code: 'resend.webhook_metadata_mismatch' }; + } + + // Idempotent: svix redelivers, so a known event id is a success no-op. + if (ctx.db.resendWebhookEvent.eventId.find(args.eventId)) { + return { status: 200, code: 'ok' }; + } + + ctx.db.resendWebhookEvent.insert({ + eventId: args.eventId, + eventType: signedEventType, + payloadJson: args.payloadJson, + signatureHeader: args.signatureHeader, + timestampHeader: args.timestampHeader, + status: WebhookEventStatus.Received, + errorMessage: undefined, + receivedAt: ctx.timestamp, + processedAt: undefined, + }); + + const outcome = applyResendEvent( + ctx as ReducerModuleCtx, + args.eventId, + args.payloadJson + ); + updateWebhookStatus( + ctx as ReducerModuleCtx, + args.eventId, + outcome.status, + outcome.error + ); + return { status: 200, code: 'ok' }; +} + +export const ingest_resend_webhook = spacetimedb.reducer( + { + eventId: t.string(), + eventType: t.string(), + payloadJson: t.string(), + signatureHeader: t.option(t.string()), + timestampHeader: t.option(t.string()), + }, + (ctx, args) => { + const result = ingestResendWebhook(ctx, args); + if (result.status !== 200) throwSenderError(result.code); + } +); + +function webhookJson(body: unknown, status: number): SyncResponse { + return new SyncResponse(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +// Native SpacetimeDB HTTP route handler. Host modules mount this on a router so +// Resend posts directly to the database. No external relay, no shim. +export function makeResendWebhookHandler() { + // The host passes the mounted context, so this handler remains schema-agnostic. + return function resendWebhook( + ctx: HandlerContext, + req: Request + ): SyncResponse { + if (req.method.toUpperCase() !== 'POST') { + return webhookJson({ error: 'method_not_allowed' }, 405); + } + + const rawBody = req.text(); + const svixId = req.headers.get('svix-id') ?? ''; + const svixTimestamp = req.headers.get('svix-timestamp') ?? undefined; + const svixSignature = req.headers.get('svix-signature') ?? undefined; + if (!svixId) return webhookJson({ error: 'missing_svix_id' }, 400); + + let eventType: string | undefined; + const parsed = safeJsonParse(rawBody); + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as { type?: unknown }).type === 'string' + ) { + eventType = (parsed as { type: string }).type; + } + if (!eventType) return webhookJson({ error: 'missing_event_type' }, 400); + + const result = ctx.withTx(tx => + ingestResendWebhook(tx as WriteCtx, { + eventId: svixId, + eventType, + payloadJson: rawBody, + signatureHeader: svixSignature, + timestampHeader: svixTimestamp, + }) + ); + return webhookJson( + { ok: result.status === 200, code: result.code }, + result.status + ); + }; +} + +export const replay_webhook_event = spacetimedb.reducer( + { eventId: t.string() }, + (ctx, { eventId }) => { + // Administrators may run this operation over stored events. + requireAdmin(ctx, ctx.sender); + const event = ctx.db.resendWebhookEvent.eventId.find(eventId); + if (!event) throwSenderError(`resend.webhook_event_not_found:${eventId}`); + const outcome = applyResendEvent(ctx, eventId, event.payloadJson); + updateWebhookStatus(ctx, eventId, outcome.status, outcome.error); + } +); diff --git a/spacetime-resend-ts/tsconfig.json b/spacetime-resend-ts/tsconfig.json new file mode 100644 index 00000000000..c659d97428a --- /dev/null +++ b/spacetime-resend-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-retry-ts/LICENSE.txt b/spacetime-retry-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-retry-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-retry-ts/README.md b/spacetime-retry-ts/README.md new file mode 100644 index 00000000000..e29e807b1c6 --- /dev/null +++ b/spacetime-retry-ts/README.md @@ -0,0 +1,124 @@ +# @spacetimedb/retry + +A typed retry factory for SpacetimeDB TypeScript modules. It creates a private +scheduled-task table, attempt history, admin controls, and exponential-backoff +dispatch around handlers defined by the host module. + +## Install + +```bash +npm install @spacetimedb/retry spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +`spacetimedb` is a peer dependency. Keep its version aligned with the SDK used +to build the host module. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +## Usage + +### Integrate into an application + +Retry is a factory because task variants and handlers belong to the host. The +example below is a module-definition skeleton: replace `sendReceipt` with an +idempotent application handler. Keep the registration casts at this SDK/factory +boundary; application code stays typed through the handler map. + +Create the factory before the schema so its tables can be mounted. Register the +scheduled reducer afterward to resolve the scheduled-table reference. + +```ts +import { SenderError, schema, t, table } from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; +import { + createRetrySubmodule, + retryFailed, + retryHandler, + retryOk, +} from '@spacetimedb/retry'; + +const retry = createRetrySubmodule( + { table, t, SenderError, ScheduleAt }, + { + send_receipt: retryHandler( + t.object('SendReceiptArgs', { orderId: t.u64() }), + (ctx, { orderId }) => { + const result = sendReceipt(ctx, orderId); + return result.sent ? retryOk() : retryFailed(result.error); + } + ), + } +); + +const db = schema({ ...retry.tables }); +const retryFire = db.reducer( + { arg: retry.tables.retryTask.rowType }, + retry.reducers.retryFire +); +retry.setRetryFireReducer(retryFire); + +export const submitRetryTask = db.reducer( + retry.reducers.submitRetryTask.params, + retry.reducers.submitRetryTask.handler +); + +export const init = db.init(ctx => retry.installRetry(ctx)); +export default db; +``` + +Submit tagged arguments with an attempt cap and base backoff. The first attempt +is scheduled immediately; subsequent delays are `backoffSecs * 2^attempt`. + +## API + +- `retryHandler(args, run)` associates a SpacetimeDB type builder with a task + handler. The handler returns `retryOk()` or `retryFailed(error)`. +- `makeRetryDispatch(handlers)` creates a typed tagged-union dispatcher. +- `createRetrySubmodule(deps, handlers, auth?)` returns tables, enum helpers, + reducers, admin views, installation, and scheduled-reducer wiring. +- `setRetryFireReducer(reducer)` completes the scheduled-table forward + reference and must be called during module definition. +- `installRetry(ctx)` seeds the publishing identity as the initial admin. + +The generated client can submit a task when the host exports +`submitRetryTask`. The default factory authorization restricts this operation +to Retry administrators: + +```ts +await conn.reducers.submitRetryTask({ + name: `receipt:${orderId}`, + args: { tag: 'send_receipt', value: { orderId } }, + maxAttempts: 5, + backoffSecs: 2, +}); +``` + +Product-facing applications usually expose a narrower reducer with fixed retry +limits and arguments derived from authorized application state. Operational +screens can subscribe to the factory's admin task and history views. + +Package entrypoints: + +- `@spacetimedb/retry/submodule` exports `createRetrySubmodule`. +- `@spacetimedb/retry/kit` exports handler, dispatch, and result helpers. +- `@spacetimedb/retry` re-exports the supported public surface. + +The helpers are available from the package root and `./kit`; the complete +factory is available from `./submodule`. + +## Testing + +```bash +npm test --workspace @spacetimedb/retry +npm run lint --workspace @spacetimedb/retry +npm run build +``` + +The repository build compiles the local fixture module that mounts the factory. + +## License + +BUSL-1.1. See [`LICENSE.txt`](./LICENSE.txt). diff --git a/spacetime-retry-ts/package.json b/spacetime-retry-ts/package.json new file mode 100644 index 00000000000..c6ec8edde6c --- /dev/null +++ b/spacetime-retry-ts/package.json @@ -0,0 +1,64 @@ +{ + "name": "@spacetimedb/retry", + "description": "Typed retry dispatch, exponential backoff, and attempt history for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./kit": { + "types": "./src/kit.ts", + "default": "./src/kit.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-retry-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-retry-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "retry", + "backoff", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-retry-ts/scripts/test.ts b/spacetime-retry-ts/scripts/test.ts new file mode 100644 index 00000000000..13e23feac74 --- /dev/null +++ b/spacetime-retry-ts/scripts/test.ts @@ -0,0 +1,45 @@ +import * as assert from 'node:assert/strict'; +import type { AlgebraicType } from 'spacetimedb'; +import type { TypeBuilder } from 'spacetimedb/server'; +import { + makeRetryDispatch, + retryFailed, + retryHandler, + retryOk, +} from '../src/kit.ts'; + +const fakeBuilder = (): TypeBuilder => + ({}) as unknown as TypeBuilder; + +const calls: string[] = []; +const handlers = { + noArgs: retryHandler(fakeBuilder>(), () => { + calls.push('noArgs'); + return retryOk(); + }), + withArgs: retryHandler( + fakeBuilder<{ value: string }>(), + (_ctx, args: { value: string }) => { + calls.push(args.value); + return retryFailed('try again'); + } + ), +}; + +assert.deepEqual(Object.keys(handlers), ['noArgs', 'withArgs']); +const dispatch = makeRetryDispatch(handlers); +assert.deepEqual(dispatch({}, { tag: 'noArgs' }), { ok: true }); +assert.deepEqual( + dispatch({}, { tag: 'withArgs', value: { value: 'payload' } }), + { + ok: false, + error: 'try again', + } +); +assert.deepEqual(calls, ['noArgs', 'payload']); +assert.throws( + () => dispatch({}, { tag: 'missing' as keyof typeof handlers }), + /unknown retry handler/ +); + +console.log('retry tests passed'); diff --git a/spacetime-retry-ts/spacetimedb/package.json b/spacetime-retry-ts/spacetimedb/package.json new file mode 100644 index 00000000000..10d92ea39cc --- /dev/null +++ b/spacetime-retry-ts/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-retry-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-retry", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-retry" + }, + "dependencies": { + "@spacetimedb/retry": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-retry-ts/spacetimedb/src/index.ts b/spacetime-retry-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..87f7bd94b70 --- /dev/null +++ b/spacetime-retry-ts/spacetimedb/src/index.ts @@ -0,0 +1,129 @@ +import { + schema, + table, + t, + SenderError, + type InferSchema, + type ReducerCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { ScheduleAt } from 'spacetimedb'; +import { + createRetrySubmodule, + retryFailed, + retryHandler, + retryOk, + type RetryResult, +} from '@spacetimedb/retry'; + +interface FlakyTransaction { + timestamp: import('spacetimedb').Timestamp; + db: { + retryTask: { + name: { + filter(name: string): IterableIterator<{ attempt: number }>; + }; + }; + retryMetric: { + insert(row: { + id: bigint; + name: string; + value: number; + recordedAt: import('spacetimedb').Timestamp; + }): unknown; + }; + }; +} + +const flakyArgs = t.object('FlakyArgs', { + taskName: t.string(), + succeedAtAttempt: t.u8(), +}); + +const flaky = retryHandler(flakyArgs, (ctx, args): RetryResult => { + const tx = ctx as FlakyTransaction; + const task = tx.db.retryTask.name.filter(args.taskName).next().value; + const attempt = Number(task?.attempt ?? 0); + if (attempt < args.succeedAtAttempt) { + return retryFailed(`simulated failure at attempt ${attempt}`); + } + tx.db.retryMetric.insert({ + id: 0n, + name: `flaky-success-${args.taskName}`, + value: attempt, + recordedAt: tx.timestamp, + }); + return retryOk(); +}); + +const retryHandlers = { + flaky, +}; + +const retry = createRetrySubmodule( + { table, t, SenderError, ScheduleAt }, + retryHandlers +); +const { retryTask, retryHistory, retryAdminIdentity } = retry.tables; + +const retryMetric = table( + { name: 'retry_metric', public: true }, + { + id: t.u64().primaryKey().autoInc(), + name: t.string(), + value: t.f64(), + recordedAt: t.timestamp(), + } +); + +const spacetimedb = schema({ + retryTask, + retryHistory, + retryAdminIdentity, + retryMetric, +}); +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx; + +function isAdmin(ctx: ViewCtx): boolean { + return ctx.db.retryAdminIdentity.identity.find(ctx.sender) != null; +} + +export const retryTasksAdmin = spacetimedb.view( + { name: 'retry_tasks_admin', public: true }, + t.array(retryTask.rowType), + ctx => (isAdmin(ctx) ? retry.views.retryTasksAdmin(ctx) : []) +); + +export const retryHistoryAdmin = spacetimedb.view( + { name: 'retry_history_admin', public: true }, + t.array(retryHistory.rowType), + ctx => (isAdmin(ctx) ? retry.views.retryHistoryAdmin(ctx) : []) +); + +export const init = spacetimedb.init(ctx => { + retry.installRetry(ctx as Tx); +}); + +export const retry_fire = spacetimedb.reducer( + { arg: retryTask.rowType }, + retry.reducers.retryFire +); +retry.setRetryFireReducer(retry_fire); + +export const submit_retry_task = spacetimedb.reducer( + retry.reducers.submitRetryTask.params, + retry.reducers.submitRetryTask.handler +); + +export const add_retry_admin_identity = spacetimedb.reducer( + retry.reducers.addRetryAdminIdentity.params, + retry.reducers.addRetryAdminIdentity.handler +); + +export const remove_retry_admin_identity = spacetimedb.reducer( + retry.reducers.removeRetryAdminIdentity.params, + retry.reducers.removeRetryAdminIdentity.handler +); diff --git a/spacetime-retry-ts/spacetimedb/src/submodule.ts b/spacetime-retry-ts/spacetimedb/src/submodule.ts new file mode 100644 index 00000000000..03bea36788a --- /dev/null +++ b/spacetime-retry-ts/spacetimedb/src/submodule.ts @@ -0,0 +1,7 @@ +export { + createRetrySubmodule, + makeRetryDispatch, + retryHandler, + type RetryHandler, + type RetryHandlers, +} from '@spacetimedb/retry'; diff --git a/spacetime-retry-ts/spacetimedb/tsconfig.json b/spacetime-retry-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..8d8f9b03455 --- /dev/null +++ b/spacetime-retry-ts/spacetimedb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/spacetime-retry-ts/src/index.ts b/spacetime-retry-ts/src/index.ts new file mode 100644 index 00000000000..a73228de9ed --- /dev/null +++ b/spacetime-retry-ts/src/index.ts @@ -0,0 +1,9 @@ +export { + makeRetryDispatch, + retryFailed, + retryHandler, + retryOk, + type RetryHandler, + type RetryResult, +} from './kit'; +export { createRetrySubmodule, type RetryHandlers } from './submodule'; diff --git a/spacetime-retry-ts/src/kit.ts b/spacetime-retry-ts/src/kit.ts new file mode 100644 index 00000000000..f78ded50b89 --- /dev/null +++ b/spacetime-retry-ts/src/kit.ts @@ -0,0 +1,54 @@ +import type { ElementsObj, Infer as InferBuilder } from 'spacetimedb/server'; + +type TypeBuilderLike = ElementsObj[string]; +type IsUnit = [keyof T] extends [never] ? true : false; + +export type RetryResult = { ok: true } | { ok: false; error: string }; + +export const retryOk = (): RetryResult => ({ ok: true }); +export const retryFailed = (error: string): RetryResult => ({ + ok: false, + error, +}); + +type RunFn = + IsUnit> extends true + ? (ctx: unknown) => RetryResult + : (ctx: unknown, args: InferBuilder) => RetryResult; + +// A symbol key keeps this metadata outside t.enum's Object.keys traversal. +const RUN_KEY = Symbol.for('retry-ts/run'); + +export type RetryHandler = TB & { + [RUN_KEY]: RunFn; +}; + +export function retryHandler( + args: TB, + run: RunFn +): RetryHandler { + Object.defineProperty(args, RUN_KEY, { + value: run, + enumerable: false, + configurable: false, + writable: false, + }); + return args as RetryHandler; +} + +export function makeRetryDispatch>( + handlers: H +) { + return function dispatch( + ctx: Tx, + args: { tag: keyof H & string; value?: unknown } + ): RetryResult { + const h = (handlers as Record)[args.tag]; + if (!h) throw new Error(`unknown retry handler: ${args.tag}`); + const run = h[RUN_KEY]; + if ('value' in args) { + return (run as (c: Tx, v: unknown) => RetryResult)(ctx, args.value); + } + return (run as (c: Tx) => RetryResult)(ctx); + }; +} diff --git a/spacetime-retry-ts/src/submodule.ts b/spacetime-retry-ts/src/submodule.ts new file mode 100644 index 00000000000..5e5e4ee2203 --- /dev/null +++ b/spacetime-retry-ts/src/submodule.ts @@ -0,0 +1,341 @@ +import { makeRetryDispatch, type RetryHandler } from './kit'; +import type { Identity, ScheduleAt, Timestamp } from 'spacetimedb'; +import type { Infer, VariantsObj } from 'spacetimedb/server'; + +const ONE_SECOND_MICROS = 1_000_000n; +const MAX_ATTEMPTS = 10; +const MAX_BACKOFF_SECONDS = 3600; +const MAX_TASK_NAME_LENGTH = 128; +const MAX_ERROR_LENGTH = 2048; + +export type RetryHandlers = Record; + +export type RetrySubmoduleDeps = { + table: (typeof import('spacetimedb/server'))['table']; + t: (typeof import('spacetimedb/server'))['t']; + SenderError: new (message: string) => Error; + ScheduleAt: { time(microsSinceUnixEpoch: bigint): ScheduleAt }; +}; + +export type RetryAuthorizationPolicy = { + isAdmin?: (ctx: unknown) => boolean; + requireAdmin?: (ctx: unknown) => void; +}; + +export function createRetrySubmodule( + deps: RetrySubmoduleDeps, + handlers: H, + auth: RetryAuthorizationPolicy = {} +) { + const { table, t, SenderError, ScheduleAt } = deps; + const retryArgs = t.enum('RetryArgs', handlers as unknown as VariantsObj); + + let retryFireReducer: unknown; + const retryTask = table( + { + name: 'retry_task', + public: false, + scheduled: (): any => { + if (!retryFireReducer) { + throw new Error('retry.fire_reducer_not_registered'); + } + return retryFireReducer; + }, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + name: t.string().index(), + args: retryArgs, + attempt: t.u8(), + maxAttempts: t.u8(), + backoffSecs: t.u32(), + } + ); + + const retryHistoryStatus = t.enum('RetryHistoryStatus', [ + 'Attempted', + 'Ok', + 'Failed', + 'GaveUp', + ]); + const RetryHistoryStatus = { + Attempted: { tag: 'Attempted' as const }, + Ok: { tag: 'Ok' as const }, + Failed: { tag: 'Failed' as const }, + GaveUp: { tag: 'GaveUp' as const }, + }; + + const retryHistory = table( + { name: 'retry_history', public: false }, + { + id: t.u64().primaryKey().autoInc(), + taskName: t.string().index(), + attempt: t.u8(), + status: retryHistoryStatus.index(), + error: t.option(t.string()), + ranAt: t.timestamp().index(), + } + ); + + const retryAdminIdentity = table( + { name: 'retry_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } + ); + + type RetryDispatchArg = { tag: string; value?: unknown }; + type RetryTaskRow = Infer; + type RetryHistoryRow = Infer; + type RetryAdminIdentityRow = Infer; + + interface RetryContext { + sender: Identity; + timestamp: Timestamp; + db: { + retryTask: { + name: { filter(name: string): IterableIterator }; + insert(row: RetryTaskRow): RetryTaskRow; + iter(): Iterable; + }; + retryHistory: { + id: { update(row: RetryHistoryRow): void }; + insert(row: RetryHistoryRow): RetryHistoryRow; + iter(): Iterable; + }; + retryAdminIdentity: { + identity: { + find(identity: Identity): RetryAdminIdentityRow | null | undefined; + }; + insert(row: RetryAdminIdentityRow): RetryAdminIdentityRow; + delete(row: RetryAdminIdentityRow): void; + count(): bigint; + }; + }; + } + + function retryContext(ctx: unknown): RetryContext { + return ctx as RetryContext; + } + + const dispatchRetry = makeRetryDispatch(handlers); + + function setRetryFireReducer(reducer: unknown): void { + retryFireReducer = reducer; + } + + function installRetry(ctx: unknown): void { + const retryCtx = retryContext(ctx); + if (retryCtx.db.retryAdminIdentity.identity.find(retryCtx.sender) == null) { + retryCtx.db.retryAdminIdentity.insert({ + identity: retryCtx.sender, + addedAtMicros: retryCtx.timestamp.microsSinceUnixEpoch, + }); + } + } + + function requireAdmin(ctx: unknown): void { + if (auth.requireAdmin) { + auth.requireAdmin(ctx); + return; + } + const retryCtx = retryContext(ctx); + if (retryCtx.db.retryAdminIdentity.identity.find(retryCtx.sender) == null) { + throw new SenderError('retry.not_authorized'); + } + } + + function isAdmin(ctx: unknown): boolean { + if (auth.isAdmin) return auth.isAdmin(ctx); + const retryCtx = retryContext(ctx); + return ( + retryCtx.db.retryAdminIdentity.identity.find(retryCtx.sender) != null + ); + } + + function takeRows(rows: Iterable, limit = 1000): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; + } + + function retryTasksAdmin(ctx: unknown): RetryTaskRow[] { + const retryCtx = retryContext(ctx); + return isAdmin(ctx) ? takeRows(retryCtx.db.retryTask.iter()) : []; + } + + function retryHistoryAdmin(ctx: unknown): RetryHistoryRow[] { + const retryCtx = retryContext(ctx); + return isAdmin(ctx) ? takeRows(retryCtx.db.retryHistory.iter()) : []; + } + + function retryFire(ctx: unknown, { arg }: { arg: RetryTaskRow }): void { + const retryCtx = retryContext(ctx); + const nowMicros = retryCtx.timestamp.microsSinceUnixEpoch as bigint; + + const inserted = retryCtx.db.retryHistory.insert({ + id: 0n, + taskName: arg.name, + attempt: arg.attempt, + status: RetryHistoryStatus.Attempted, + error: undefined, + ranAt: retryCtx.timestamp, + }); + + const result = dispatchRetry( + retryCtx, + arg.args as RetryDispatchArg & { + tag: keyof H & string; + } + ); + + if (result.ok) { + retryCtx.db.retryHistory.id.update({ + ...inserted, + status: RetryHistoryStatus.Ok, + error: undefined, + }); + return; + } + + const errorMessage = result.error.slice(0, MAX_ERROR_LENGTH); + const isLast = arg.attempt + 1 >= arg.maxAttempts; + retryCtx.db.retryHistory.id.update({ + ...inserted, + status: isLast ? RetryHistoryStatus.GaveUp : RetryHistoryStatus.Failed, + error: errorMessage, + }); + + if (isLast) return; + + const factor = 1n << BigInt(arg.attempt); + const delay = BigInt(arg.backoffSecs) * factor * ONE_SECOND_MICROS; + retryCtx.db.retryTask.insert({ + ...arg, + scheduledId: 0n, + scheduledAt: ScheduleAt.time(nowMicros + delay), + attempt: arg.attempt + 1, + }); + } + + const submitRetryTaskParams = { + name: t.string(), + args: retryArgs, + maxAttempts: t.u8(), + backoffSecs: t.u32(), + }; + + function submitRetryTask( + ctx: unknown, + args: { + name: string; + args: RetryDispatchArg; + maxAttempts: number; + backoffSecs: number; + } + ): void { + const retryCtx = retryContext(ctx); + requireAdmin(ctx); + if ( + typeof args.name !== 'string' || + args.name.length === 0 || + args.name.length > MAX_TASK_NAME_LENGTH + ) { + throw new SenderError('retry.invalid_task_name'); + } + if ( + !Number.isInteger(args.maxAttempts) || + args.maxAttempts < 1 || + args.maxAttempts > MAX_ATTEMPTS + ) { + throw new SenderError('retry.invalid_max_attempts'); + } + if ( + !Number.isInteger(args.backoffSecs) || + args.backoffSecs < 1 || + args.backoffSecs > MAX_BACKOFF_SECONDS + ) { + throw new SenderError('retry.invalid_backoff_seconds'); + } + if (retryCtx.db.retryTask.name.filter(args.name).next().value != null) { + throw new SenderError(`retry.task_already_exists:${args.name}`); + } + retryCtx.db.retryTask.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.time( + retryCtx.timestamp.microsSinceUnixEpoch as bigint + ), + name: args.name, + args: args.args, + attempt: 0, + maxAttempts: args.maxAttempts, + backoffSecs: args.backoffSecs, + }); + } + + function addRetryAdminIdentity( + ctx: unknown, + { identity }: { identity: Identity } + ): void { + const retryCtx = retryContext(ctx); + requireAdmin(ctx); + if (retryCtx.db.retryAdminIdentity.identity.find(identity) == null) { + retryCtx.db.retryAdminIdentity.insert({ + identity, + addedAtMicros: retryCtx.timestamp.microsSinceUnixEpoch, + }); + } + } + + function removeRetryAdminIdentity( + ctx: unknown, + { identity }: { identity: Identity } + ): void { + const retryCtx = retryContext(ctx); + requireAdmin(ctx); + const existing = retryCtx.db.retryAdminIdentity.identity.find(identity); + if (!existing) return; + if (retryCtx.db.retryAdminIdentity.count() <= 1n) { + throw new SenderError('retry.cannot_remove_last_admin'); + } + retryCtx.db.retryAdminIdentity.delete(existing); + } + + return { + tables: { + retryTask, + retryHistory, + retryAdminIdentity, + }, + retryArgs, + retryHistoryStatus, + RetryHistoryStatus, + setRetryFireReducer, + installRetry, + requireAdmin, + views: { + retryTasksAdmin, + retryHistoryAdmin, + }, + reducers: { + retryFire, + submitRetryTask: { + params: submitRetryTaskParams, + handler: submitRetryTask, + }, + addRetryAdminIdentity: { + params: { identity: t.identity() }, + handler: addRetryAdminIdentity, + }, + removeRetryAdminIdentity: { + params: { identity: t.identity() }, + handler: removeRetryAdminIdentity, + }, + }, + } as const; +} diff --git a/spacetime-retry-ts/tsconfig.json b/spacetime-retry-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-retry-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-stripe-ts/LICENSE.txt b/spacetime-stripe-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-stripe-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-stripe-ts/README.md b/spacetime-stripe-ts/README.md new file mode 100644 index 00000000000..f0396841023 --- /dev/null +++ b/spacetime-stripe-ts/README.md @@ -0,0 +1,239 @@ +# @spacetimedb/stripe + +A SpacetimeDB submodule that mirrors Stripe customers, subscriptions, Checkout +sessions, invoices, and payments. Stripe webhooks feed private base tables, and +host modules expose product-specific views and workflows. Procedures are +synchronous and webhook payloads use valibot validation. + +--- + +## Install + +```bash +npm install @spacetimedb/stripe @spacetimedb/crypto spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +This submodule can be published directly as its own STDB module from the root entrypoint. + +## Usage + +### Integrate into an application + +Mount Stripe in the application schema and initialize its private tables. The +host must place authorization in front of customer, Checkout, portal, and +billing procedures and expose only caller-scoped billing views: + +```ts +import { schema } from 'spacetimedb/server'; +import * as stripe from '@spacetimedb/stripe/submodule'; + +const spacetimedb = schema({ stripe }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + stripe.installStripe(ctx.as.stripe); +}); +``` + +Configure credentials through an administrator-only startup path. See the +[Premium Store host module](./example/spacetimedb/) +for service-identity delegation, safe return URLs, catalog synchronization, and +webhook routing. + +Expose a product-facing Checkout procedure that resolves the application user, +validates every price against a server-owned catalog, fixes the allowed return +URL origins, and delegates to Stripe: + +```ts +export const create_store_checkout_session = spacetimedb.procedure( + storeCheckoutParams, + storeCheckoutResult, + (ctx, args) => { + const checkout = authorizeStoreCheckout(ctx, args); + return stripe.create_checkout_session(ctx.as.stripe, checkout); + } +); +``` + +`storeCheckoutParams`, `storeCheckoutResult`, and `authorizeStoreCheckout` +belong to the host application. The generated client calls that wrapper and +navigates only to the returned Stripe URL: + +```ts +const checkout = await conn.procedures.createStoreCheckoutSession({ + items: [{ priceId, quantity: 1n }], + customerId, + mode: 'payment', + successUrl: `${location.origin}/?checkout=success`, + cancelUrl: `${location.origin}/?checkout=cancelled`, + metadataJson: JSON.stringify({ cartId }), + subscriptionMetadataJson: undefined, + paymentIntentMetadataJson: undefined, +}); + +if (!checkout.url) throw new Error('stripe.checkout_url_missing'); +location.assign(checkout.url); +``` + +### Standalone configuration + +Stripe credentials live in a private `stripe_config` singleton. During `init`, a +fresh database seeds the owner into the private `stripe_admin_identity` table. + +```bash +spacetime call --server http://127.0.0.1:3000 stripe-ts set_stripe_config \ + '"sk_test_..."' \ + null \ + '"whsec_..."' # webhook signing secret, optional +``` + +Verify: + +```bash +spacetime call --server http://127.0.0.1:3000 stripe-ts get_stripe_config_status '{}' +``` + +The Stripe secret stays in private module state. Every provider-backed, +billing-state, configuration, and query procedure is admin-gated. A host module +can perform application-specific authorization and then call the helpers through +its mounted `ctx.as.stripe` context. + +## Private tables + +| Table | Key | Notes | +| ------------------------- | ---------------------------- | ----------------------------------------- | +| `stripe_customer` | `stripe_customer_id` | indexed by email, app-userId | +| `stripe_subscription` | `stripe_subscription_id` | indexed by customer, org, user | +| `stripe_checkout_session` | `stripe_checkout_session_id` | indexed by customer, status | +| `stripe_invoice` | `stripe_invoice_id` | indexed by customer, subscription, status | +| `stripe_payment` | `stripe_payment_intent_id` | indexed by customer, status | + +- `stripe_webhook_event`: idempotency log (`get_webhook_event_count` exposes the size) +- `stripe_config`: credentials singleton +- `stripe_admin_identity`: admin allowlist + +Stripe base tables are private. Expose product-specific fields and rows through +caller-scoped host views. Resolve customer, user, and organization IDs from +trusted application context. + +## API + +**Setup** + +- `set_stripe_config(secretKey, stripeVersion, webhookSigningSecret)` +- `set_stripe_webhook_signing_secret(webhookSigningSecret)`: rotates only the webhook secret +- `get_stripe_config_status()`: returns `{ isConfigured, hasWebhookSecret, secretKeyLength, ... }` +- `add_admin_identity(identity)` / `remove_admin_identity(identity)` + +**Customer / billing flows** + +- `create_customer({ email, name, metadataJson, idempotencyKey })` +- `create_or_update_customer({ stripeCustomerId, email, name, metadataJson })` +- `get_or_create_customer({ userId, email, name})` +- `create_checkout_session({ items, mode, successUrl, cancelUrl, customerId, ...metadata })` +- `validate_stripe_price({ priceId })`: confirms a price exists and is active +- `get_remote_checkout_session({ sessionId })`: fetch session state from Stripe +- `create_customer_portal_session({ customerId, returnUrl })` +- `cancel_subscription({ stripeSubscriptionId, cancelAtPeriodEnd })` +- `reactivate_subscription({ stripeSubscriptionId })` +- `update_subscription_quantity({ stripeSubscriptionId, quantity })` +- `update_subscription_metadata({ stripeSubscriptionId, metadataJson, orgId, userId })` +- `stripe_api_request({ method, path, formBody, idempotencyKey })`: admin-gated + request to a relative `/v1/` path on `api.stripe.com`; accepted methods are + `GET`, `POST`, and `DELETE` + +**Webhook ingest / replay** + +- `ingest_stripe_webhook(eventId, eventType, livemode, payloadJson, signatureHeader)`: idempotent +- `replay_webhook_event(eventId)`: re-applies a stored event +- `get_webhook_event_count()`: observability +- `stripe_webhook_handler` and `handle_stripe_webhook` support direct host HTTP + routing. +- `upsert_customer`, `upsert_subscription`, `update_payment_customer`, and + `update_subscription_quantity_internal` apply trusted synchronization data. + +**Admin queries** + +- `get_customer`, `get_customer_by_email`, `get_customer_by_user_id` +- `get_subscription`, `list_subscriptions`, `list_subscriptions_with_creation_time`, `get_subscription_by_org_id`, `list_subscriptions_by_org_id`, `list_subscriptions_by_user_id` +- `get_payment`, `list_payments`, `list_payments_by_org_id`, `list_payments_by_user_id` +- `list_invoices`, `list_invoices_by_org_id`, `list_invoices_by_user_id` +- `get_checkout_session`, `list_checkout_sessions` + +List procedures return at most 1,000 rows. Build paginated, product-specific +views in the host module when a UI needs a larger history. + +Package entrypoints: + +- `@spacetimedb/stripe` can run as a standalone billing database. +- `@spacetimedb/stripe/submodule` supplies mounted billing, webhook, + configuration, and query operations. + +## Webhook events handled + +``` +customer.created customer.updated +customer.subscription.created customer.subscription.updated +customer.subscription.deleted checkout.session.completed +invoice.created invoice.finalized +invoice.paid invoice.payment_succeeded +invoice.payment_failed payment_intent.succeeded +``` + +Other event types are accepted but stored with `status = 'ignored'`. + +## Webhook signature verification + +Both entry points verify the Stripe signature in-module against the configured +`webhookSigningSecret` (HMAC-SHA256 over `${timestamp}.${rawBody}` via +`@spacetimedb/crypto`). Missing secrets produce a service-unavailable response: + +- `stripe_webhook_handler` (HTTP) - for direct Stripe -> STDB delivery. +- `ingest_stripe_webhook` (reducer) - for a relay forwarding the raw body + + `stripe-signature` header over the SDK; it verifies before mutating state. + +`replay_webhook_event` re-applies an already-stored event and is admin-gated. +The relay reducer also verifies that its separately supplied event metadata +matches the signed payload before using the event ID as its idempotency key. +Webhook application is atomic. Invalid event data rolls back the event row and +business-table changes so Stripe can redeliver the event. + +## Integration testing + +```bash +# Build + publish + happy paths, idempotency, signed-metadata checks, and authorization checks +pnpm run test:smoke + +# Real Stripe sandbox via Stripe CLI (requires `stripe login`) +pnpm run test:stripe:e2e +``` + +The smoke test publishes only to the dedicated `stripe-ts-smoke-test` database. +The Stripe CLI E2E suite likewise defaults to the dedicated `stripe-ts-e2e` +database, forwards the original signed body, and rotates only that database's +ephemeral listener secret. + +## Architecture notes + +- **valibot for runtime validation.** `vStripeEvent` is a `v.variant('type', [...])` over the 12 supported event types. `attemptToParse` returns a tagged result; `assertExhaustive` makes the typed `switch` compiler-checked. +- **SDK types, sync HTTP.** The `stripe` npm package supplies event types such as `Stripe.CustomerCreatedEvent`. Procedures use the synchronous `ctx.http.fetch` API through the request boundary in `submodule/http.ts`. +- **Compile-time SDK alignment.** `_align*` checks in `schema.ts` assert valibot output is structurally assignable to `Stripe.*Event`. If Stripe ships a breaking change, typecheck fails. +- **Idempotency.** Each webhook event is keyed by `event.id`; re-ingest is a no-op. `replay_webhook_event` applies the stored event state again. + +## Testing + +```bash +npm test --workspace @spacetimedb/stripe +npm run lint --workspace @spacetimedb/stripe +``` + +Credentialed sandbox coverage is described in **Integration testing** above. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-stripe-ts/example/.env.example b/spacetime-stripe-ts/example/.env.example new file mode 100644 index 00000000000..204512d2494 --- /dev/null +++ b/spacetime-stripe-ts/example/.env.example @@ -0,0 +1,32 @@ +# Stripe setup. Loaded by the authorized example server during startup. +STRIPE_SECRET_KEY=sk_test_... + +# Optional webhook signature verification and API-version override. +STRIPE_WEBHOOK_SECRET= +STRIPE_VERSION= + +# Opt in to creating/linking Stripe test prices during startup. +STRIPE_SYNC_PRICES=0 + +# Browser provider actions are enabled automatically only for a non-production +# loopback host. Otherwise, opt in only after adding authentication/rate limits. +STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS=0 + +# Server-owned Checkout success/cancel origin. Production requires HTTPS. +STRIPE_RETURN_BASE_URL=http://127.0.0.1:8787 + +# Keep this set to development for local use. Production disables automatic +# loopback-only provider actions unless they are explicitly authorized below. +NODE_ENV=development + +# Optional: override DB target. +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +STDB_DATABASE=spacetime-stripe-example +# Optional. When unset, the server creates a persistent local identity token in +# .stdb-server-token and the logged-in publishing identity authorizes it. +# STDB_SERVER_TOKEN= + +# Optional. +PORT=8787 +HOST=127.0.0.1 diff --git a/spacetime-stripe-ts/example/.gitignore b/spacetime-stripe-ts/example/.gitignore new file mode 100644 index 00000000000..52c85422453 --- /dev/null +++ b/spacetime-stripe-ts/example/.gitignore @@ -0,0 +1,11 @@ +# Node +node_modules/ +.env +.stdb-server-token + +# Generated / built outputs +src/codegen/ +public/app.js +public/app.js.map +dist/ +*.tsbuildinfo diff --git a/spacetime-stripe-ts/example/README.md b/spacetime-stripe-ts/example/README.md new file mode 100644 index 00000000000..f160ab2951a --- /dev/null +++ b/spacetime-stripe-ts/example/README.md @@ -0,0 +1,206 @@ +# Premium Store + +Premium Store demonstrates a host database that mounts +`@spacetimedb/stripe/submodule` and delegates customer, price, checkout, and +webhook operations through the `stripe` namespace. The browser can shop and create +Stripe Checkout sessions, but it cannot configure Stripe or mutate administrative +catalog state. + +## What this demonstrates + +- Mounting the Stripe component inside an application-owned store module. +- Keeping Stripe credentials in private module state. +- Using a narrow server API for customer lookup, price validation, and Checkout; + the browser never receives the privileged service identity. +- Seeding an application catalog independently of Stripe provider records. +- Creating or linking idempotent Stripe test prices during explicit server setup. +- Receiving Stripe webhooks through the module's native HTTP route. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server reachable as `local`. +- A logged-in CLI identity that publishes the database. +- A Stripe **test-mode** secret key (`sk_test_...`). +- Optional: the Stripe CLI or another tunnel for forwarding test webhooks. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-stripe-ts/example`: + +```powershell +pnpm install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +``` + +Set `STRIPE_SECRET_KEY=sk_test_...` in `.env`. Set +`STRIPE_SYNC_PRICES=1` for the first full checkout run; this creates or reuses +three Stripe test prices and writes their IDs to `store_product`. + +```powershell +pnpm run build:module:fresh +pnpm run dev +``` + +Open . Add a product to the cart and create a Stripe test +Checkout session. No charge occurs unless the Checkout page is completed with a +Stripe test payment method. + +`build:module:fresh` deletes and recreates only the local `spacetime-stripe-example` +database. Use `pnpm run build:module` to preserve existing data. + +## Use in your project + +This workspace tests the component source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/stripe @spacetimedb/crypto spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +service-identity, narrow Checkout API, caller-scoped billing views, and signed +webhook route. The product catalog and storefront are demonstration code. + +## Configuration + +| Variable | Default | Purpose | +| --------------------------------------- | ------------------------------------ | ------------------------------------------------------------- | +| `STRIPE_SECRET_KEY` | empty | Required for provider operations. Use a test-mode key. | +| `STRIPE_WEBHOOK_SECRET` | empty | Verifies incoming Stripe webhook signatures. | +| `STRIPE_VERSION` | component default | Optional Stripe API-version override. | +| `STRIPE_SYNC_PRICES` | `0` | Set to `1` to create/link missing test prices during startup. | +| `STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS` | automatic on non-production loopback | Explicit provider-action opt-in for other environments. | +| `STRIPE_RETURN_BASE_URL` | `http://127.0.0.1:8787` | Server-owned Checkout return origin. | +| `NODE_ENV` | empty | Set to `production` to disable development-only defaults. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser and server WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | CLI administration endpoint. Must match `STDB_URI`. | +| `STDB_DATABASE` | `spacetime-stripe-example` | Published database name. | +| `STDB_SERVER_TOKEN` | generated locally | Optional pre-provisioned server identity token. | +| `HOST` | `127.0.0.1` | Static-server bind address. | +| `PORT` | `8787` | Static-server port. | + +When no server token is supplied, the server persists one in the ignored +`.stdb-server-token` file. The logged-in publishing identity registers that server +identity in both the host store and mounted Stripe administrator registries. The +browser identity is never granted either role. + +## Startup behavior + +The example server performs the following bounded setup before accepting HTTP: + +1. Connect with the persistent server identity. +2. Authorize it through the logged-in CLI publishing identity. +3. Seed the default store catalog if it is empty. +4. Store Stripe configuration when `STRIPE_SECRET_KEY` is present. +5. Synchronize missing prices only when `STRIPE_SYNC_PRICES=1`. + +Price synchronization is opt-in because it creates test-mode objects in the linked +Stripe account. Existing prices use stable lookup keys and are reused. + +## Architecture + +```text +Browser storefront + -> public store_product subscription + -> same-origin /api checkout/customer/validation routes + -> authorized server identity + -> host checkout/customer/validation procedures + -> mounted stripe namespace + -> Stripe API + +Stripe + -> POST /route/stripe/webhook on the SpacetimeDB database + -> host router + -> mounted stripe webhook handler + +Authorized example server + -> private configuration and catalog setup during startup +``` + +The Node server exposes only browser-safe health and configuration routes: + +| Route | Purpose | +| ----------------- | ----------------------------------- | +| `GET /api/health` | Local health probe. | +| `GET /api/config` | Browser-safe database/setup status. | + +There are no HTTP administration endpoints. The settings panel contains buyer and +debugging controls only. + +## Webhooks + +Forward Stripe test events to the database's native route: + +```text +http://127.0.0.1:3000/v1/database/spacetime-stripe-example/route/stripe/webhook +``` + +Use the signing secret produced by the forwarding tool as +`STRIPE_WEBHOOK_SECRET`, then restart so the private component configuration is +updated. + +## Security and deployment boundaries + +- Never use a live-mode Stripe key for casual example testing. +- Stripe secrets and the persistent server token must never be committed or sent to + the browser. +- The server binds to loopback by default. +- Production deployments should provision an authenticated service identity + through deployment infrastructure. +- Browser provider actions are automatic only for a non-production loopback host. + Production and externally bound development servers default to disabled. Add + application authentication and rate limiting, then set + `STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS=1` deliberately. +- The server owns Checkout return URLs. Set `STRIPE_RETURN_BASE_URL` to the public + HTTPS origin in production; browser-supplied redirect URLs are ignored. +- Checkout success in the UI is a redirect result; authoritative fulfillment must + come from verified webhooks. + +## Verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For the provider-backed smoke test, set `STRIPE_SYNC_PRICES=1`, fresh-publish, +start the server, confirm three prices synchronize, add a product to the cart, and +create a test Checkout session. + +## Troubleshooting + +- **Products say “Sync price first”:** set `STRIPE_SYNC_PRICES=1` and restart with + a valid test key. +- **`stripe.not_authorized`:** publish with the logged-in CLI identity and restart; + both the store and Stripe namespaces must authorize the server identity. +- **Connection targets disagree:** make `STDB_URI`, `STDB_HTTP`, and the publish + target refer to the same server. +- **Webhook state is stale:** verify the forwarding URL and + `STRIPE_WEBHOOK_SECRET`. + +## Important files + +- `spacetimedb/src/submodule/operations.ts`: application catalog and Stripe + delegation. +- `server.ts`: safe startup configuration and server identity authorization. +- `src/app.ts`: typed browser-side SpacetimeDB adapter. +- `public/index.html`: storefront and buyer tools. +- `public/ui.js`: storefront state, rendering, and interaction handling. +- `public/styles.css`: storefront presentation. diff --git a/spacetime-stripe-ts/example/package.json b/spacetime-stripe-ts/example/package.json new file mode 100644 index 00000000000..c4fcc5bd399 --- /dev/null +++ b/spacetime-stripe-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-stripe-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build:codegen:app": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", + "build:codegen": "pnpm run build:codegen:app", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen:app && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen:app && pnpm run build:app", + "check": "tsc --noEmit", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run build:codegen && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/node": "^25.6.0", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3" + } +} diff --git a/spacetime-stripe-ts/example/public/assets/brand.svg b/spacetime-stripe-ts/example/public/assets/brand.svg new file mode 100644 index 00000000000..77cc436631a --- /dev/null +++ b/spacetime-stripe-ts/example/public/assets/brand.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spacetime-stripe-ts/example/public/assets/logo.svg b/spacetime-stripe-ts/example/public/assets/logo.svg new file mode 100644 index 00000000000..adaf36cbf9c --- /dev/null +++ b/spacetime-stripe-ts/example/public/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/spacetime-stripe-ts/example/public/index.html b/spacetime-stripe-ts/example/public/index.html new file mode 100644 index 00000000000..dd34614603f --- /dev/null +++ b/spacetime-stripe-ts/example/public/index.html @@ -0,0 +1,155 @@ + + + + + + + SpacetimeDB Premium Store + + + +
          +
          +
          + SpacetimeDB + Premium Store Test App +
          +
          + + +
          +
          + +
          +
          +

          Storefront

          +

          + Browse products and add them to cart. Open cart to checkout. +

          +
          +
          Loading catalog from SpacetimeDB...
          +
          +
          +
          + + +
          + + + + + +
          + + + + + + + + diff --git a/spacetime-stripe-ts/example/public/styles.css b/spacetime-stripe-ts/example/public/styles.css new file mode 100644 index 00000000000..cc0996a0bbc --- /dev/null +++ b/spacetime-stripe-ts/example/public/styles.css @@ -0,0 +1,1169 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=Source+Code+Pro:wght@400;500;600&display=swap'); + +:root { + /* Tokens match spacetime-web/spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: var(--font-inter); + color: var(--color-white); + background: var(--color-shade7); +} + +body::before { + content: none; +} + +/* Compact scrollbars matching the SpacetimeDB dashboard. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) var(--color-shade7); +} +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 2px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade3); +} +*::-webkit-scrollbar-corner { + background: var(--color-shade7); +} + +.shell { + width: min(1320px, calc(100% - 32px)); + margin: 14px auto 30px; +} + +.topnav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #0d1920, #0b1319); + padding: 9px 12px; + box-shadow: inset 0 1px 0 #26435166; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.brand-wordmark { + display: block; + height: 28px; +} + +.brand-sub { + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.topnav-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.pill-link, +.meta-pill { + border: 1px solid #27414e; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #c6d1e1; +} + +.meta-pill.good { + border-color: #31684c; + color: var(--color-green); +} + +.pill-link { + text-decoration: none; + transition: + border-color 120ms ease, + color 120ms ease; +} + +.pill-link:hover { + border-color: #3b6173; + color: #e6edf7; +} + +.cart-button { + width: auto; + border: 1px solid #2f5363; + border-radius: 999px; + background: #112433; + color: #d4deea; + padding: 7px 12px; + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.icon-button { + width: 34px; + height: 34px; + border: 1px solid #2f5363; + border-radius: 999px; + background: #112433; + color: #d4deea; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.icon-button svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.subbar { + margin-top: 10px; + border: 1px solid #1a3541; + border-radius: 12px; + background: linear-gradient(180deg, #10202bde, #0d1720de); + padding: 10px 12px; +} + +.status-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.status-pill { + border: 1px solid #27414e; + border-radius: 999px; + padding: 5px 11px; + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #c4d0e0; +} + +.status-pill.good { + border-color: #2f644a; + color: var(--color-green); +} + +.status-pill.warn { + border-color: #5b5737; + color: var(--color-yellow); +} + +.layout { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr; + gap: 14px; +} + +.panel { + border: 1px solid #17313d; + border-radius: 16px; + background: linear-gradient(180deg, #0f1d27e6, #0c161dde); + box-shadow: inset 0 1px 0 #2a435033; + padding: 14px; +} + +.panel h1, +.panel h2 { + margin: 0; + font-size: 31px; + letter-spacing: -0.03em; + line-height: 1.1; + font-weight: 500; +} + +.panel-sub { + margin: 5px 0 12px; + color: var(--color-n3); + font-size: 17px; +} + +.catalog-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.product-card { + border: 1px solid #203846; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #111f29, #0d1820); + padding: 12px; + display: flex; + flex-direction: column; + transition: + border-color 100ms ease, + transform 100ms ease, + box-shadow 100ms ease; +} + +.product-card:hover { + border-color: #2f5363; + transform: translateY(-1px); + box-shadow: 0 10px 22px #0000002e; +} + +.product-row { + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; + height: 100%; +} + +.product-media { + height: 96px; + border: 1px solid #264353; + border-radius: 10px; + background: + radial-gradient(circle at 24% 22%, #4cf49044, transparent 45%), + radial-gradient(circle at 75% 78%, #02befa33, transparent 44%), + linear-gradient(140deg, #112738, #0f1d29); +} + +.product-head { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.badge-row { + display: flex; + align-items: center; + gap: 6px; +} + +.product-copy { + display: grid; + gap: 6px; +} + +.product-rating { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #a8bdd4; + min-height: 18px; +} + +.product-stars { + color: #f59e0b; + letter-spacing: 0.04em; +} + +.card-actions { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid #1b3140; +} + +.btn.add-cart { + width: auto; + min-width: 140px; + background: linear-gradient(90deg, #4cf490, #02befa); + border-color: #4cf490; + color: #03131b; + font-weight: 700; +} + +.card-actions .btn { + font-size: 11px; + padding: 9px 10px; +} + +.card-stepper { + display: inline-flex; + align-items: center; + border: 2px solid #2f664d; + border-radius: 999px; + overflow: hidden; + background: #0f1f2a; + min-height: 40px; +} + +.stepper-btn { + width: 40px; + height: 36px; + border: 0; + background: transparent; + color: #d6e5f6; + font-size: 22px; + line-height: 1; + cursor: pointer; +} + +.stepper-btn:hover { + background: #16303d; +} + +.stepper-btn.is-trash { + font-size: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.stepper-btn .trash-icon { + width: 15px; + height: 15px; + stroke: #e8f1fb; + fill: none; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.stepper-count { + padding: 0 14px; + font-family: var(--font-ibm); + font-size: 12px; + letter-spacing: 0.04em; + color: #d6e5f6; +} + +.card-stepper.bump { + animation: stepper-bump 220ms ease; +} + +@keyframes stepper-bump { + 0% { + transform: scale(1); + } + 45% { + transform: scale(1.04); + } + 100% { + transform: scale(1); + } +} + +.cart-stepper .stepper-count { + min-width: 98px; + text-align: center; +} + +.card-top { + display: flex; + justify-content: flex-start; + align-items: baseline; + gap: 8px; + margin-top: 0; +} + +.merch-badge { + border: 1px solid #2f4f5f; + border-radius: 999px; + padding: 2px 8px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #9fb2c8; + width: fit-content; +} + +.merch-badge.sale { + border-color: #3b5c41; + color: #7ee6ad; + background: #4cf49014; +} + +.merch-badge.mode { + border-color: #2d4b5a; + color: #9fb2c8; + background: #10212b; +} + +.price-label { + font-size: 24px; + letter-spacing: -0.03em; + font-weight: 700; + line-height: 1; +} + +.price-stack { + display: inline-flex; + align-items: baseline; + gap: 10px; + text-align: left; + flex-wrap: wrap; +} + +.price-compare { + margin-top: 0; + font-size: 13px; + color: #8ea3bc; +} + +.price-compare s { + color: #7389a2; +} + +.product-title { + margin: 0; + font-size: 18px; + line-height: 1.25; + letter-spacing: -0.02em; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.product-description { + margin: 0; + color: var(--color-n3); + font-size: 12px; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.form-row { + display: flex; + flex-direction: column; + gap: 5px; +} + +.form-row.full { + grid-column: 1 / -1; +} + +.help-text { + margin-top: 6px; + color: #88a5ba; + font-size: 12px; +} + +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +label { + color: #9eb2cb; + font-size: 13px; +} + +input, +button, +textarea { + width: 100%; + border-radius: 8px; + box-sizing: border-box; + font-family: inherit; +} + +input, +textarea { + border: 1px solid #213947; + background: #0a1217; + color: var(--color-white); + padding: 11px 12px; +} + +input:focus, +textarea:focus { + outline: 1px solid #2c4e5f; +} + +.summary { + margin-top: 10px; + border: 1px solid #203846; + border-radius: 12px; + background: #0d1720; + padding: 10px; +} + +.summary-title { + font-size: 14px; + color: var(--color-n2); +} + +.summary-value { + margin-top: 4px; + font-size: 26px; + letter-spacing: -0.03em; +} + +.summary-meta { + margin-top: 6px; + color: #9ab0cb; + font-family: var(--font-ibm); + font-size: 12px; +} + +.cart-meta { + margin-top: 4px; + color: #9ab0cb; + font-family: var(--font-ibm); + font-size: 11px; +} + +.shop-actions { + margin-top: 12px; + display: flex; + gap: 8px; +} + +.price-status { + margin-top: 8px; + display: inline-flex; + align-items: center; + gap: 7px; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid #2f4552; + color: #b8c7d8; +} + +.price-status.good { + border-color: #336b50; + color: var(--color-green); +} + +.price-status.bad { + border-color: #65434f; + color: var(--color-orange); +} + +.price-status.warn { + border-color: #5e5b40; + color: var(--color-yellow); +} + +.actions { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.action-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.btn { + border: 1px solid #2c4957; + border-radius: 9px; + padding: 11px 12px; + cursor: pointer; + font-family: var(--font-ibm); + font-size: 12px; + letter-spacing: 0.11em; + text-transform: uppercase; + transition: + filter 120ms ease, + transform 80ms ease; +} + +.btn:active { + transform: translateY(1px); +} + +/* .btn.primary matches spacetimedb.com Button.module.css: + n3 bg, n8 text, white on hover, green on active, green focus outline. */ +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); + font-weight: 600; +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); + filter: none; +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} +.btn.primary:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.btn.secondary { + background: transparent; + color: #d2dce8; +} + +.btn.ghost { + background: #101f29; + color: #ccd8e8; +} + +.btn:hover { + filter: brightness(1.08); +} + +.btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.btn:disabled { + opacity: 0.56; + cursor: not-allowed; +} + +.dev-tools { + padding: 10px; +} + +.dev-tools .form-grid { + margin-bottom: 8px; +} + +textarea { + min-height: 170px; + resize: vertical; + font-family: var(--font-ibm); + font-size: 12px; + line-height: 1.35; +} + +#errorLine { + position: fixed; + top: 18px; + left: 50%; + transform: translateX(-50%); + z-index: 80; + pointer-events: none; + max-width: min(420px, calc(100% - 44px)); +} +#errorLine:empty { + display: none; +} +.error-line-msg { + pointer-events: auto; + padding: 10px 14px; + border-radius: var(--radius-lg); + font-size: 13px; + font-family: var(--font-ibm); + color: var(--color-orange); + background: rgba(255, 158, 158, 0.08); + border: 1px solid rgba(255, 158, 158, 0.45); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5); + animation: errorLineSlideIn 180ms ease; + cursor: pointer; +} +@keyframes errorLineSlideIn { + from { + opacity: 0; + transform: translateY(-12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.checkout-banner { + position: fixed; + left: 50%; + bottom: 22px; + transform: translate(-50%, 24px); + opacity: 0; + pointer-events: none; + z-index: 70; + max-width: min(560px, calc(100% - 32px)); + padding: 12px 18px; + border-radius: 12px; + font-size: 14px; + color: var(--color-white); + background: linear-gradient(180deg, #0f2a35, #0c1f28); + border: 1px solid #1c4856; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + gap: 12px; + transition: + transform 220ms ease, + opacity 220ms ease; +} +.checkout-banner.is-visible { + opacity: 1; + transform: translate(-50%, 0); + pointer-events: auto; +} +.checkout-banner.success { + border-color: #2f7d52; + box-shadow: + 0 18px 40px rgba(0, 0, 0, 0.5), + 0 0 0 1px #2f7d5240; +} +.checkout-banner.success .checkout-banner-dot { + background: var(--color-green); + box-shadow: 0 0 0 4px rgba(75, 244, 144, 0.18); +} +.checkout-banner.canceled { + border-color: #6a4f1c; +} +.checkout-banner.canceled .checkout-banner-dot { + background: var(--color-yellow); + box-shadow: 0 0 0 4px rgba(251, 220, 142, 0.18); +} +.checkout-banner-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} +.checkout-banner-text { + flex: 1 1 auto; + min-width: 0; + line-height: 1.45; +} +.checkout-banner-text strong { + font-weight: 600; +} +.checkout-banner button.checkout-banner-close { + flex: 0 0 auto; + width: 26px; + height: 26px; + padding: 0; + background: transparent; + border: 1px solid #2a4250; + color: var(--color-n3); + font-size: 14px; + line-height: 1; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.checkout-banner button.checkout-banner-close:hover { + border-color: #3a5666; + color: var(--color-white); +} + +.dev-tools-backdrop { + position: fixed; + inset: 0; + background: #04080bb8; + backdrop-filter: blur(2px); + z-index: 50; +} + +.dev-tools-popout { + position: fixed; + top: 0; + right: 0; + width: min(540px, 100%); + height: 100vh; + z-index: 60; + border-left: 1px solid #1f3845; + background: linear-gradient(180deg, #0f1d27f7, #0c161df5); + box-shadow: -16px 0 40px #00000066; + display: flex; + flex-direction: column; +} + +.dev-tools-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid #1f3845; +} + +.dev-tools-title { + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.09em; + font-size: 12px; + color: #9eb3cd; +} + +.dev-tools-close { + width: auto; + padding: 6px 10px; + font-size: 11px; +} + +.dev-tools-body { + overflow-y: auto; + padding: 2px 0 12px; +} + +.dev-tools-backdrop[hidden], +.dev-tools-popout[hidden] { + display: none; +} + +.cart-backdrop { + position: fixed; + inset: 0; + background: #04080bb8; + backdrop-filter: blur(2px); + z-index: 40; +} + +.cart-popout { + position: fixed; + top: 0; + right: 0; + width: min(560px, 100%); + height: 100vh; + z-index: 45; + border-left: 1px solid #1f3845; + background: linear-gradient(180deg, #0f1d27f7, #0c161df5); + box-shadow: -16px 0 40px #00000066; + display: flex; + flex-direction: column; +} + +.cart-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid #1f3845; +} + +.cart-title { + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.09em; + font-size: 12px; + color: #9eb3cd; +} + +.cart-body { + padding: 12px; + height: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} + +.cart-list { + display: grid; + gap: 8px; + flex: 1; + overflow-y: auto; + padding-right: 2px; + align-content: start; + grid-auto-rows: max-content; +} + +.cart-item { + border: 1px solid #1f3845; + border-radius: 10px; + background: #0d1820; + padding: 9px; +} + +.cart-item-top { + display: grid; + grid-template-columns: 44px 1fr auto; + gap: 10px; + align-items: center; +} + +.cart-item-thumb { + width: 44px; + height: 44px; + border-radius: 10px; + border: 1px solid #2a4757; + background: radial-gradient( + circle at 30% 28%, + #4cf49066, + #02befa2b 55%, + #0f1920 100% + ); +} + +.cart-item-content { + min-width: 0; +} + +.cart-item-name { + font-size: 16px; + font-weight: 600; + line-height: 1.2; +} + +.cart-item-meta { + font-family: var(--font-ibm); + font-size: 11px; + color: #9eb3cd; + margin-top: 4px; +} + +.cart-item-sub { + font-family: var(--font-ibm); + font-size: 11px; + color: #b8c6d7; + margin-top: 4px; +} + +.cart-item-controls { + justify-self: end; +} + +.cart-item-top .btn { + width: auto; + padding: 6px 10px; + font-size: 11px; +} + +.cart-empty { + border: 1px dashed #2d4656; + border-radius: 10px; + padding: 18px 14px; + color: #98abc2; + min-height: 180px; + display: grid; + align-content: center; + justify-items: center; + text-align: center; + gap: 8px; +} + +.cart-empty-title { + font-size: 18px; + letter-spacing: -0.02em; + color: #d7d8d9; +} + +.cart-empty-copy { + font-size: 13px; + color: #9ab0cb; + max-width: 260px; +} + +.cart-footer { + border-top: 1px solid #1f3845; + padding-top: 10px; + display: grid; + gap: 10px; +} + +.cart-summary { + display: flex; + align-items: center; + justify-content: space-between; + font-family: var(--font-ibm); + font-size: 11px; + color: #9ab0cb; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.cart-backdrop[hidden], +.cart-popout[hidden] { + display: none; +} + +.loading-card { + grid-column: 1 / -1; + border: 1px dashed #2d4656; + border-radius: 12px; + padding: 14px; + color: #98abc2; + font-family: var(--font-ibm); +} + +.skeleton-card { + border: 1px solid #203846; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #111f29, #0d1820); + padding: 12px; + display: grid; + gap: 8px; +} + +.skeleton-line { + border-radius: 7px; + background: linear-gradient(100deg, #132735 0%, #1c3342 38%, #132735 76%); + background-size: 220% 100%; + animation: skeleton-shimmer 1.25s linear infinite; +} + +.skeleton-media { + height: 96px; + border-radius: 10px; +} + +.skeleton-line.badges { + height: 18px; + width: 44%; +} + +.skeleton-line.title { + height: 22px; + width: 76%; +} + +.skeleton-line.desc { + height: 14px; + width: 92%; +} + +.skeleton-line.meta { + height: 14px; + width: 50%; +} + +.skeleton-line.price { + height: 28px; + width: 40%; +} + +.skeleton-line.actions { + height: 40px; + width: 100%; + margin-top: 8px; +} + +@keyframes skeleton-shimmer { + from { + background-position: 220% 0; + } + to { + background-position: -30% 0; + } +} + +@media (max-width: 980px) { + .layout { + grid-template-columns: 1fr; + } + .catalog-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .shell { + width: calc(100% - 20px); + } + .catalog-grid { + grid-template-columns: 1fr; + } + .form-grid { + grid-template-columns: 1fr; + } + .brand-wordmark { + height: 24px; + } + .product-media { + height: 120px; + } + .card-actions { + gap: 6px; + } +} diff --git a/spacetime-stripe-ts/example/public/ui.js b/spacetime-stripe-ts/example/public/ui.js new file mode 100644 index 00000000000..5aefee812e2 --- /dev/null +++ b/spacetime-stripe-ts/example/public/ui.js @@ -0,0 +1,745 @@ +const STORAGE_KEYS = { + cart: 'stdb.premiumStore.cart.v1', +}; + +const state = { + catalog: [], + cart: [], + checkoutPending: false, + animatedStepperKey: null, +}; + +const byId = id => document.getElementById(id); + +const ui = { + btnCart: byId('btnCart'), + btnSettings: byId('btnSettings'), + cartBackdrop: byId('cartBackdrop'), + cartPopout: byId('cartPopout'), + btnCartClose: byId('btnCartClose'), + btnContinueShopping: byId('btnContinueShopping'), + cartList: byId('cartList'), + cartSummary: byId('cartSummary'), + catalogGrid: byId('catalogGrid'), + email: byId('email'), + name: byId('name'), + userId: byId('userId'), + customerId: byId('customerId'), + btnCheckoutCart: byId('btnCheckoutCart'), + btnDevToolsClose: byId('btnDevToolsClose'), + btnCustomer: byId('btnCustomer'), + devToolsBackdrop: byId('devToolsBackdrop'), + devToolsPopout: byId('devToolsPopout'), + errorLine: byId('errorLine'), + log: byId('log'), + checkoutBanner: byId('checkoutBanner'), + checkoutBannerText: byId('checkoutBannerText'), + checkoutBannerClose: byId('checkoutBannerClose'), +}; + +let errorLineTimer = null; +function setError(message) { + if (errorLineTimer) { + clearTimeout(errorLineTimer); + errorLineTimer = null; + } + if (!message) { + ui.errorLine.innerHTML = ''; + return; + } + ui.errorLine.innerHTML = ''; + const pill = document.createElement('div'); + pill.className = 'error-line-msg'; + pill.textContent = message; + pill.addEventListener('click', () => setError('')); + ui.errorLine.appendChild(pill); + errorLineTimer = setTimeout(() => setError(''), 6000); +} + +let checkoutBannerTimer = null; +function showCheckoutBanner(kind, message) { + if (!ui.checkoutBanner) return; + ui.checkoutBanner.hidden = false; + ui.checkoutBanner.classList.remove('success', 'canceled'); + ui.checkoutBanner.classList.add(kind); + ui.checkoutBannerText.innerHTML = message; + void ui.checkoutBanner.offsetWidth; + ui.checkoutBanner.classList.add('is-visible'); + if (checkoutBannerTimer) clearTimeout(checkoutBannerTimer); + checkoutBannerTimer = setTimeout(hideCheckoutBanner, 8000); +} +function hideCheckoutBanner() { + if (!ui.checkoutBanner) return; + ui.checkoutBanner.classList.remove('is-visible'); + if (checkoutBannerTimer) { + clearTimeout(checkoutBannerTimer); + checkoutBannerTimer = null; + } +} +function consumePostCheckoutQueryFlags() { + const url = new URL(window.location.href); + const purchased = url.searchParams.get('purchased') === '1'; + const canceled = url.searchParams.get('canceled') === '1'; + if (!purchased && !canceled) return; + + if (purchased) { + state.cart = []; + state.animatedStepperKey = null; + persistCart(); + showCheckoutBanner( + 'success', + 'Checkout complete. Stripe confirmed the session.' + ); + } else { + showCheckoutBanner( + 'canceled', + 'Checkout canceled. No charge was made. Your cart is still here.' + ); + } + url.searchParams.delete('purchased'); + url.searchParams.delete('canceled'); + window.history.replaceState({}, '', url.toString()); +} + +function writeLog(message) { + const time = new Date().toLocaleTimeString(); + ui.log.value = `[${time}] ${message}\n\n` + ui.log.value; +} + +function setButtonLoading(button, isLoading, loadingText = 'Loading...') { + if (!button) return; + if (isLoading) { + if (!button.dataset.originalText) { + button.dataset.originalText = button.textContent || ''; + } + button.textContent = loadingText; + button.disabled = true; + return; + } + + if (button.dataset.originalText) { + button.textContent = button.dataset.originalText; + delete button.dataset.originalText; + } + button.disabled = false; +} + +function closeDevTools() { + ui.devToolsBackdrop.hidden = true; + ui.devToolsPopout.hidden = true; +} + +function openDevTools() { + ui.devToolsBackdrop.hidden = false; + ui.devToolsPopout.hidden = false; +} + +function openCart() { + ui.cartBackdrop.hidden = false; + ui.cartPopout.hidden = false; +} + +function missingPriceMessage(productName) { + return `Missing Stripe price ID for ${productName}. Set STRIPE_SYNC_PRICES=1 and restart the example server.`; +} + +function closeCart() { + ui.cartBackdrop.hidden = true; + ui.cartPopout.hidden = true; +} + +function parsePositiveInteger(value, fallback = 1) { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function parseAmountFromPriceLabel(priceLabel) { + const text = String(priceLabel ?? ''); + const match = text.match(/-?\d[\d,]*(?:\.\d{1,2})?/); + if (!match) return 0; + const normalized = match[0].replace(/,/g, ''); + const parsed = Number.parseFloat(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getMerchandising(item) { + const byId = { + 'orbital-starter-pack': { + badge: 'Best Seller', + rating: 4.8, + reviews: 214, + }, + 'warp-pass': { + badge: 'Limited Offer', + compareAt: 12, + rating: 4.6, + reviews: 129, + }, + 'fleet-command-bundle': { rating: 4.7, reviews: 88 }, + }; + return byId[item.id] || null; +} + +function formatUsd(amount) { + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `$${amount.toFixed(2)}`; + } +} + +async function api(path, payload) { + const response = await fetch(path, { + method: payload ? 'POST' : 'GET', + headers: payload ? { 'content-type': 'application/json' } : undefined, + body: payload ? JSON.stringify(payload) : undefined, + }); + const data = await response.json(); + if (!response.ok || data.ok === false) { + throw new Error(data.error || `Request failed (${response.status})`); + } + return data; +} + +function buildPostCheckoutUrl(flag) { + const url = new URL(window.location.origin); + url.searchParams.set(flag, '1'); + return url.toString(); +} + +function makeDefaultUserId() { + return `pilot_${Math.random().toString(36).slice(2, 8)}`; +} + +function seedDefaultBuyerDetails() { + ui.email.value = 'pilot@spacetime.dev'; + ui.name.value = 'Orbital Pilot'; + ui.userId.value = makeDefaultUserId(); +} + +function hydrateCart() { + try { + const raw = localStorage.getItem(STORAGE_KEYS.cart); + if (!raw) return; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return; + + state.cart = parsed + .map(item => ({ + id: String(item?.id || ''), + name: String(item?.name || ''), + priceLabel: String(item?.priceLabel || ''), + mode: item?.mode === 'subscription' ? 'subscription' : 'payment', + priceId: String(item?.priceId || ''), + quantity: parsePositiveInteger(item?.quantity, 1), + })) + .filter(item => item.id && item.name && item.priceId); + } catch { + state.cart = []; + } +} + +function persistCart() { + try { + localStorage.setItem(STORAGE_KEYS.cart, JSON.stringify(state.cart)); + } catch { + // Storage may be unavailable in private windows. + } +} + +function getCartEntry(itemId, priceId) { + return state.cart.find( + entry => entry.id === itemId && entry.priceId === priceId + ); +} + +function getCartQuantity(itemId, priceId) { + const entry = getCartEntry(itemId, priceId); + return entry ? entry.quantity : 0; +} + +function getCartKey(itemId, priceId) { + return `${itemId}::${priceId}`; +} + +function syncCartState() { + const count = state.cart.length; + const totalQty = state.cart.reduce((sum, item) => sum + item.quantity, 0); + const oneTimeSubtotal = state.cart.reduce((sum, item) => { + if (item.mode !== 'payment') return sum; + return sum + parseAmountFromPriceLabel(item.priceLabel) * item.quantity; + }, 0); + const recurringMonthly = state.cart.reduce((sum, item) => { + if (item.mode !== 'subscription') return sum; + return sum + parseAmountFromPriceLabel(item.priceLabel) * item.quantity; + }, 0); + + const cartModes = new Set(state.cart.map(entry => entry.mode)); + const isMixedMode = cartModes.size > 1; + const hasMissingPrice = state.cart.some(entry => !entry.priceId); + if (state.checkoutPending) { + ui.btnCheckoutCart.disabled = true; + ui.btnCheckoutCart.textContent = 'Redirecting to Stripe...'; + } else if (hasMissingPrice) { + ui.btnCheckoutCart.disabled = true; + ui.btnCheckoutCart.textContent = 'Sync Stripe prices first'; + } else if (isMixedMode) { + ui.btnCheckoutCart.disabled = true; + ui.btnCheckoutCart.textContent = "Can't mix one-time + subscription"; + } else { + ui.btnCheckoutCart.disabled = count === 0; + ui.btnCheckoutCart.textContent = + totalQty > 0 + ? `Checkout with Stripe (${totalQty})` + : 'Checkout with Stripe'; + } + if (ui.btnCart) ui.btnCart.textContent = `Cart (${totalQty})`; + if (ui.cartSummary) { + ui.cartSummary.innerHTML = + `Subtotal: ${formatUsd(oneTimeSubtotal)}` + + `Recurring: ${formatUsd(recurringMonthly)}/mo`; + } + persistCart(); + renderCartList(); + if (state.catalog.length > 0) renderCatalog(); +} + +function renderCartList() { + if (state.cart.length === 0) { + ui.cartList.innerHTML = ` +
          +
          Your cart is empty
          +
          Add items from the storefront to start checkout.
          +
          + `; + return; + } + + ui.cartList.innerHTML = state.cart + .map(item => { + const unitPrice = parseAmountFromPriceLabel(item.priceLabel); + const lineTotal = unitPrice * item.quantity; + const cartKey = getCartKey(item.id, item.priceId); + const stepperClass = + state.animatedStepperKey === cartKey + ? ' card-stepper bump cart-stepper' + : ' card-stepper cart-stepper'; + return ` +
          +
          + +
          +
          ${item.name}
          +
          ${item.mode === 'subscription' ? 'Subscription' : 'One-time payment'}
          +
          ${formatUsd(unitPrice)} x ${item.quantity} = ${formatUsd(lineTotal)}${item.mode === 'subscription' ? ' /mo' : ''}
          +
          +
          +
          + +
          ${item.quantity} in cart
          + +
          +
          +
          +
          + `; + }) + .join(''); + + for (const button of ui.cartList.querySelectorAll('.js-cart-plus')) { + button.addEventListener('click', () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const item = state.catalog.find( + product => product.id === productId && product.priceId === priceId + ); + if (!item) return; + addItemToCart(item, 1); + }); + } + + for (const button of ui.cartList.querySelectorAll('.js-cart-minus')) { + button.addEventListener('click', () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const index = state.cart.findIndex( + entry => entry.id === productId && entry.priceId === priceId + ); + if (index < 0) return; + state.cart[index].quantity -= 1; + if (state.cart[index].quantity <= 0) { + const removed = state.cart.splice(index, 1)[0]; + if (removed) + writeLog(`cart_remove: ${removed.id} qty=${removed.quantity}`); + } + syncCartState(); + }); + } +} + +function renderCatalog() { + if (state.catalog.length === 0) { + ui.catalogGrid.innerHTML = + '
          No catalog items found.
          '; + return; + } + + const cards = state.catalog + .map(item => { + const merch = getMerchandising(item); + const unitPrice = parseAmountFromPriceLabel(item.priceLabel); + const compareAt = + merch?.compareAt && merch.compareAt > unitPrice + ? merch.compareAt + : null; + const purchaseType = + item.mode === 'subscription' ? 'Subscription' : 'One-time'; + const rating = merch?.rating ?? 4.7; + const reviews = merch?.reviews ?? 100; + const stars = '★★★★★'; + const inCartQty = getCartQuantity(item.id, item.priceId); + const cartKey = getCartKey(item.id, item.priceId); + const stepperClass = + state.animatedStepperKey === cartKey + ? 'card-stepper bump' + : 'card-stepper'; + const isMissingPrice = !item.priceId; + return ` +
          +
          + +
          +
          + ${merch?.badge ? `${merch.badge}` : ''} + ${purchaseType} +
          +
          +

          ${item.name}

          +

          ${item.description}

          +
          +
          ${rating.toFixed(1)}${stars}(${reviews})
          +
          +
          +
          ${item.priceLabel}
          + ${compareAt ? `
          List: ${formatUsd(compareAt)}
          ` : ''} +
          +
          +
          + ${ + inCartQty > 0 + ? ` +
          + +
          ${inCartQty} in cart
          + +
          + ` + : isMissingPrice + ? '' + : `` + } +
          +
          +
          +
          + `; + }) + .join(''); + + ui.catalogGrid.innerHTML = cards; + for (const button of ui.catalogGrid.querySelectorAll('.js-add-to-cart')) { + button.addEventListener('click', async () => { + const productId = button.getAttribute('data-product-id') || ''; + const item = state.catalog.find(product => product.id === productId); + if (!item) return; + setError(''); + addItemToCart(item, 1); + await validatePriceForItem(item).catch(error => setError(error.message)); + }); + } + + for (const button of ui.catalogGrid.querySelectorAll('.js-card-plus')) { + button.addEventListener('click', async () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const item = state.catalog.find( + product => product.id === productId && product.priceId === priceId + ); + if (!item) return; + setError(''); + addItemToCart(item, 1); + await validatePriceForItem(item).catch(error => setError(error.message)); + }); + } + + for (const button of ui.catalogGrid.querySelectorAll('.js-card-minus')) { + button.addEventListener('click', () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const index = state.cart.findIndex( + entry => entry.id === productId && entry.priceId === priceId + ); + if (index < 0) return; + state.cart[index].quantity -= 1; + if (state.cart[index].quantity <= 0) { + const removed = state.cart.splice(index, 1)[0]; + if (removed) + writeLog(`cart_remove: ${removed.id} qty=${removed.quantity}`); + } + syncCartState(); + }); + } +} + +function renderCatalogSkeleton(count = 6) { + const cards = Array.from( + { length: count }, + () => ` + + ` + ).join(''); + ui.catalogGrid.innerHTML = cards; +} + +window.addEventListener('stdb:catalog', event => { + const products = event.detail?.products ?? []; + const sorted = [...products].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) + ); + state.catalog = sorted; + const catalogById = new Map(sorted.map(item => [item.id, item])); + const previousCartCount = state.cart.length; + state.cart = state.cart + .filter(entry => { + const item = catalogById.get(entry.id); + return item && item.priceId && item.priceId === entry.priceId; + }) + .map(entry => { + const item = catalogById.get(entry.id); + return { + ...entry, + name: item.name, + priceLabel: item.priceLabel, + mode: item.mode, + }; + }); + if (state.cart.length !== previousCartCount) { + setError( + 'Your saved cart contained outdated Stripe prices and was refreshed.' + ); + } + syncCartState(); +}); + +window.addEventListener('stdb:connState', event => { + const detail = event.detail || {}; + if (detail.state === 'connected') { + writeLog('stdb: connected'); + return; + } + if (detail.state === 'error') { + const message = detail.detail || 'SpacetimeDB connection failed.'; + setError(message); + writeLog(`stdb: error: ${message}`); + return; + } + writeLog('stdb: connecting'); +}); + +async function validatePriceForItem(item) { + if (!item?.priceId) { + writeLog('price validation: missing price id'); + return; + } + if (!window.stdb) { + writeLog('price validation: STDB not connected yet'); + return; + } + const validation = await window.stdb.validatePrice(item.priceId); + if (validation.valid) { + writeLog(`price valid: ${item.priceId} (active=${validation.active})`); + } else { + writeLog(`price invalid: ${validation.message || 'not found'}`); + } +} + +async function getOrCreateCustomer() { + setError(''); + if (!window.stdb) { + throw new Error('STDB not connected yet. Try again.'); + } + const result = await window.stdb.getOrCreateCustomer({ + userId: ui.userId.value, + email: ui.email.value || undefined, + name: ui.name.value || undefined, + }); + if (result.customerId) ui.customerId.value = result.customerId; + writeLog(`get_or_create_customer: ${JSON.stringify(result)}`); +} + +async function createCheckoutForCart(cartItems, triggerButton) { + if (!cartItems || cartItems.length === 0) throw new Error('Cart is empty.'); + const missingPrice = cartItems.find(entry => !entry.priceId); + if (missingPrice) { + throw new Error(missingPriceMessage(missingPrice.name)); + } + + const modes = Array.from(new Set(cartItems.map(entry => entry.mode))); + if (modes.length > 1) { + throw new Error( + 'Stripe checkout cannot mix one-time and subscription items in the same session. ' + + 'Please remove one type and check out separately.' + ); + } + const mode = modes[0]; + + const successUrl = buildPostCheckoutUrl('purchased'); + const cancelUrl = buildPostCheckoutUrl('canceled'); + + state.checkoutPending = true; + syncCartState(); + setButtonLoading(triggerButton, true, 'Redirecting...'); + + try { + if (!window.stdb) { + throw new Error('STDB not connected yet. Try again.'); + } + const result = await window.stdb.createCheckoutSession({ + items: cartItems.map(entry => ({ + priceId: entry.priceId, + quantity: entry.quantity, + })), + customerId: ui.customerId.value || undefined, + mode, + successUrl, + cancelUrl, + }); + writeLog(`create_checkout_session: ${JSON.stringify(result)}`); + + if (!result.url) { + throw new Error('Stripe checkout URL missing from session response.'); + } + window.location.assign(result.url); + } finally { + setButtonLoading(triggerButton, false); + state.checkoutPending = false; + syncCartState(); + } +} + +function addItemToCart(item, quantity) { + if (!item) return; + if (!item.priceId) { + const message = missingPriceMessage(item.name); + setError(message); + writeLog(message); + return; + } + state.animatedStepperKey = getCartKey(item.id, item.priceId); + const existing = state.cart.find( + entry => entry.id === item.id && entry.priceId === item.priceId + ); + if (existing) { + existing.quantity += quantity; + } else { + state.cart.push({ + id: item.id, + name: item.name, + priceLabel: item.priceLabel, + mode: item.mode, + priceId: item.priceId, + quantity, + }); + } + syncCartState(); + writeLog(`cart_add: ${item.id} qty=${quantity}`); + setTimeout(() => { + if (state.animatedStepperKey === getCartKey(item.id, item.priceId)) { + state.animatedStepperKey = null; + if (state.catalog.length > 0) renderCatalog(); + } + }, 260); +} + +async function createCheckoutCartNext() { + setError(''); + if (state.cart.length === 0) throw new Error('Cart is empty.'); + for (const entry of state.cart) { + const item = state.catalog.find(product => product.id === entry.id); + if (!item) + throw new Error(`Cart item ${entry.id} is absent from the catalog.`); + } + await createCheckoutForCart(state.cart, ui.btnCheckoutCart); +} + +function wireEvents() { + if (ui.btnCart) ui.btnCart.addEventListener('click', openCart); + ui.btnCartClose.addEventListener('click', closeCart); + ui.btnContinueShopping.addEventListener('click', closeCart); + ui.cartBackdrop.addEventListener('click', closeCart); + + if (ui.btnSettings) { + ui.btnSettings.addEventListener('click', openDevTools); + } + + ui.btnCustomer.addEventListener('click', () => { + getOrCreateCustomer().catch(error => setError(error.message)); + }); + + ui.btnCheckoutCart.addEventListener('click', () => { + createCheckoutCartNext().catch(error => setError(error.message)); + }); + + ui.btnDevToolsClose.addEventListener('click', closeDevTools); + ui.devToolsBackdrop.addEventListener('click', closeDevTools); + + if (ui.checkoutBannerClose) { + ui.checkoutBannerClose.addEventListener('click', hideCheckoutBanner); + } + + document.addEventListener('keydown', event => { + const key = String(event.key || '').toLowerCase(); + if ((event.ctrlKey || event.metaKey) && event.shiftKey && key === 'd') { + event.preventDefault(); + openDevTools(); + return; + } + if (event.key === 'Escape' && !ui.cartPopout.hidden) { + closeCart(); + return; + } + if (event.key === 'Escape' && !ui.devToolsPopout.hidden) { + closeDevTools(); + } + }); +} + +async function boot() { + renderCatalogSkeleton(); + await api('/api/config'); + seedDefaultBuyerDetails(); + hydrateCart(); + wireEvents(); + consumePostCheckoutQueryFlags(); + syncCartState(); + if (state.catalog.length > 0) renderCatalog(); +} + +boot().catch(error => { + setError(error.message); + writeLog(`boot error: ${error.message}`); +}); diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts new file mode 100644 index 00000000000..3685bb2edcd --- /dev/null +++ b/spacetime-stripe-ts/example/server.ts @@ -0,0 +1,417 @@ +// Example server: static files plus a narrow provider-action boundary. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { DbConnection, type ErrorContext } from './src/codegen/app'; +import { + discardStoredServerToken, + grantServerIdentity, + loadServerToken, + saveServerToken, +} from '../../tools/example-server-identity'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// App .env wins. Falls back to spacetime-stripe-ts/.env for package-level defaults. +dotenv.config({ path: path.resolve(__dirname, '.env') }); +dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); + +const PORT = Number.parseInt(process.env.PORT ?? '8787', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const STDB_DATABASE = process.env.STDB_DATABASE ?? 'spacetime-stripe-example'; +const NODE_ENV = (process.env.NODE_ENV ?? '').replace(/^['"]|['"]$/g, ''); +const IS_PRODUCTION = NODE_ENV === 'production'; +const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime'; +const SYNC_PRICES_ON_START = process.env.STRIPE_SYNC_PRICES === '1'; +const IS_LOOPBACK_HOST = + HOST === '127.0.0.1' || HOST === 'localhost' || HOST === '::1'; +const ALLOW_BROWSER_PROVIDER_ACTIONS = + (!IS_PRODUCTION && IS_LOOPBACK_HOST) || + process.env.STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS === '1'; +const DEFAULT_RETURN_HOST = + HOST === '0.0.0.0' || HOST === '::' ? '127.0.0.1' : HOST; +const STRIPE_RETURN_BASE_URL = + process.env.STRIPE_RETURN_BASE_URL?.trim() || + `http://${DEFAULT_RETURN_HOST}:${PORT}`; +const SERVER_TOKEN_PATH = path.resolve(__dirname, '.stdb-server-token'); + +let stdb: DbConnection | null = null; +let stripeConfigured = false; + +type SyncPriceResult = { + productId: string; + priceId: string; + action: 'created' | 'linked' | 'kept'; +}; + +type ConnectedServer = { + connection: DbConnection; + identity: string; +}; + +class RequestError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requiredString( + value: unknown, + field: string, + maxLength: number +): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maxLength + ) { + throw new RequestError(400, `invalid_${field}`); + } + return value; +} + +function optionalString( + value: unknown, + field: string, + maxLength: number +): string | undefined { + if (value === undefined || value === null || value === '') return undefined; + return requiredString(value, field, maxLength); +} + +function providerActionsReady(): void { + if (!ALLOW_BROWSER_PROVIDER_ACTIONS) { + throw new RequestError(403, 'browser_provider_actions_disabled'); + } + if (!stripeConfigured) throw new RequestError(503, 'stripe_not_configured'); +} + +function sendRouteError(route: string, error: unknown, res: Response): void { + if (error instanceof RequestError) { + res.status(error.status).json({ error: error.message }); + return; + } + console.error( + `[stripe] ${route} failed: ${error instanceof Error ? error.message : String(error)}` + ); + res.status(502).json({ error: `${route}_failed` }); +} + +function returnUrl(flag: 'purchased' | 'canceled'): string { + const url = new URL('/', STRIPE_RETURN_BASE_URL); + if (IS_PRODUCTION && url.protocol !== 'https:') { + throw new RequestError(500, 'stripe_return_url_requires_https'); + } + url.searchParams.set(flag, '1'); + return url.toString(); +} + +function connectAttempt(token: string | undefined): Promise { + return new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(STDB_URI) + .withDatabaseName(STDB_DATABASE) + .onConnect((connection, identity, nextToken) => { + if (!process.env.STDB_SERVER_TOKEN?.trim()) { + saveServerToken(SERVER_TOKEN_PATH, nextToken); + } + resolve({ connection, identity: identity.toHexString() }); + }) + .onDisconnect((_ctx, err) => { + console.error( + `[stdb] disconnected: ${err?.message ?? 'unknown'}. exiting` + ); + process.exit(1); + }) + .onConnectError((_ctx: ErrorContext, err) => reject(err)); + if (token) builder = builder.withToken(token); + builder.build(); + }); +} + +async function connectStdb(): Promise { + const stored = loadServerToken( + SERVER_TOKEN_PATH, + process.env.STDB_SERVER_TOKEN + ); + try { + return await connectAttempt(stored.token); + } catch (error) { + if (stored.source !== 'file') throw error; + discardStoredServerToken(SERVER_TOKEN_PATH); + console.warn( + '[stdb] stored server token was rejected; creating a new identity' + ); + return connectAttempt(undefined); + } +} + +function requireStdb(): DbConnection { + if (!stdb) throw new Error('STDB not connected yet'); + return stdb; +} + +async function syncStripePricesFromCatalog(): Promise { + const raw = await requireStdb().procedures.syncStoreProductsWithStripe({}); + return JSON.parse(raw) as SyncPriceResult[]; +} + +async function configureStripeFromEnv(): Promise< + 'configured' | 'already-configured' +> { + const secretKey = process.env.STRIPE_SECRET_KEY?.trim(); + if (!secretKey) { + throw new Error( + 'STRIPE_SECRET_KEY is not set in spacetime-stripe-ts/example/.env.' + ); + } + + await requireStdb().procedures.configureStripe({ + secretKey, + stripeVersion: process.env.STRIPE_VERSION || undefined, + webhookSigningSecret: process.env.STRIPE_WEBHOOK_SECRET || undefined, + }); + stripeConfigured = true; + return 'configured'; +} + +const app = express(); +app.use(express.json({ limit: '512kb' })); +app.use(express.static(path.join(__dirname, 'public'), staticOptions())); + +function staticOptions() { + if (IS_PRODUCTION) return {}; + return { + etag: false, + maxAge: 0, + setHeaders: (res: Response) => { + res.setHeader('Cache-Control', 'no-store'); + }, + }; +} + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, database: STDB_DATABASE }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + const envStripeSecret = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; + res.json({ + stdbUri: STDB_URI, + database: STDB_DATABASE, + hasStripeSecretKey: envStripeSecret.length > 0, + stripeConfigured, + adminEndpointsEnabled: false, + browserProviderActionsEnabled: ALLOW_BROWSER_PROVIDER_ACTIONS, + syncPricesOnStart: SYNC_PRICES_ON_START, + }); +}); + +app.post('/api/customer', async (req: Request, res: Response) => { + try { + providerActionsReady(); + if (!isRecord(req.body)) throw new RequestError(400, 'invalid_body'); + const userId = requiredString(req.body.userId, 'user_id', 128); + const email = optionalString(req.body.email, 'email', 320); + const name = optionalString(req.body.name, 'name', 200); + const result = await requireStdb().procedures.getOrCreateStoreCustomer({ + userId, + email, + name, + }); + res.json(result); + } catch (error) { + sendRouteError('customer', error, res); + } +}); + +app.post('/api/checkout', async (req: Request, res: Response) => { + try { + providerActionsReady(); + if (!isRecord(req.body) || !Array.isArray(req.body.items)) { + throw new RequestError(400, 'invalid_body'); + } + if (req.body.items.length === 0 || req.body.items.length > 20) { + throw new RequestError(400, 'invalid_items'); + } + + const mode = requiredString(req.body.mode, 'mode', 32); + if (mode !== 'payment' && mode !== 'subscription') { + throw new RequestError(400, 'invalid_mode'); + } + const customerId = optionalString(req.body.customerId, 'customer_id', 255); + if (customerId && !/^cus_[A-Za-z0-9]+$/.test(customerId)) { + throw new RequestError(400, 'invalid_customer_id'); + } + + const catalog = [...requireStdb().db.storeProduct.iter()]; + const items = req.body.items.map((value, index) => { + if (!isRecord(value)) + throw new RequestError(400, `invalid_item_${index}`); + const priceId = requiredString(value.priceId, `price_id_${index}`, 255); + const quantity = value.quantity; + if ( + typeof quantity !== 'number' || + !Number.isSafeInteger(quantity) || + quantity < 1 || + quantity > 99 + ) { + throw new RequestError(400, `invalid_quantity_${index}`); + } + const product = catalog.find( + row => row.active && row.stripePriceId === priceId && row.mode === mode + ); + if (!product) + throw new RequestError(400, `price_not_in_active_catalog_${index}`); + return { priceId, quantity: BigInt(quantity) }; + }); + + const result = await requireStdb().procedures.createStoreCheckoutSession({ + items, + customerId, + mode, + successUrl: returnUrl('purchased'), + cancelUrl: returnUrl('canceled'), + metadataJson: undefined, + subscriptionMetadataJson: undefined, + paymentIntentMetadataJson: undefined, + }); + res.json(result); + } catch (error) { + sendRouteError('checkout', error, res); + } +}); + +app.post('/api/validate-price', async (req: Request, res: Response) => { + try { + providerActionsReady(); + if (!isRecord(req.body)) throw new RequestError(400, 'invalid_body'); + const priceId = requiredString(req.body.priceId, 'price_id', 255); + const inCatalog = [...requireStdb().db.storeProduct.iter()].some( + row => row.active && row.stripePriceId === priceId + ); + if (!inCatalog) throw new RequestError(400, 'price_not_in_active_catalog'); + const result = await requireStdb().procedures.validateStoreStripePrice({ + priceId, + }); + res.json({ + ...result, + unitAmount: + result.unitAmount === undefined ? undefined : Number(result.unitAmount), + }); + } catch (error) { + sendRouteError('validate_price', error, res); + } +}); + +app.get('/api/webhook-event-count', async (_req: Request, res: Response) => { + try { + providerActionsReady(); + const count = await requireStdb().procedures.getStoreWebhookEventCount({}); + res.json({ count: Number(count) }); + } catch (error) { + sendRouteError('webhook_event_count', error, res); + } +}); + +async function seedCatalogIfEmpty(conn: DbConnection): Promise { + await new Promise((resolve, reject) => { + let resolved = false; + conn + .subscriptionBuilder() + .onApplied(() => { + if (resolved) return; + resolved = true; + resolve(); + }) + .onError((ctx: ErrorContext) => { + if (resolved) return; + resolved = true; + reject(new Error(`catalog probe failed: ${ctx.event}`)); + }) + .subscribe(['SELECT * FROM store_product']); + }); + + const count = conn.db.storeProduct.count(); + if (count === 0n) { + await conn.reducers.seedDefaultStoreProducts({ force: undefined }); + console.log('Seeded default store products (catalog was empty).'); + } +} + +(async () => { + console.log( + `[stdb] connecting to ${STDB_URI} (database=${STDB_DATABASE}) ...` + ); + try { + const connected = await connectStdb(); + stdb = connected.connection; + grantServerIdentity({ + spacetimeBin: SPACETIME_BIN, + server: STDB_HTTP, + database: STDB_DATABASE, + procedure: 'add_admin_identity', + identity: connected.identity, + }); + grantServerIdentity({ + spacetimeBin: SPACETIME_BIN, + server: STDB_HTTP, + database: STDB_DATABASE, + procedure: 'stripe.add_admin_identity', + identity: connected.identity, + }); + console.log(`[stdb] connected as authorized server ${connected.identity}`); + } catch (err) { + console.error( + `[stdb] connection failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[stdb] is the SpacetimeDB host running and the example module published?' + ); + process.exit(1); + } + + try { + await seedCatalogIfEmpty(stdb); + } catch (err) { + console.warn( + `[stdb] could not check/seed catalog: ${err instanceof Error ? err.message : String(err)}` + ); + } + + if (process.env.STRIPE_SECRET_KEY?.trim()) { + try { + await configureStripeFromEnv(); + console.log('[stripe] config loaded from server environment'); + if (SYNC_PRICES_ON_START) { + const results = await syncStripePricesFromCatalog(); + console.log(`[stripe] synchronized ${results.length} catalog prices`); + } + } catch (err) { + console.error( + `[stripe] setup failed: ${err instanceof Error ? err.message : String(err)}` + ); + process.exit(1); + } + } else { + console.warn( + '[stripe] STRIPE_SECRET_KEY is not set; checkout remains unavailable' + ); + } + + app.listen(PORT, HOST, () => { + console.log(`Premium store test app running at http://${HOST}:${PORT}`); + }); +})(); diff --git a/spacetime-stripe-ts/example/spacetimedb/LICENSE.txt b/spacetime-stripe-ts/example/spacetimedb/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-stripe-ts/example/spacetimedb/README.md b/spacetime-stripe-ts/example/spacetimedb/README.md new file mode 100644 index 00000000000..34d68db1554 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/README.md @@ -0,0 +1,30 @@ +# Premium Store store module (stripe-ts example) + +Per-app STDB module for `spacetime-stripe-example`. Owns the storefront's product catalog (`store_product`) and admin gating, and mounts the Stripe primitives from [`stripe-ts`](../../) under the `stripe` submodule. + +This module exists to demonstrate how a real consumer integrates `stripe-ts`: the consumer brings their own STDB module for app-specific tables, mounts the submodule, and delegates to it through `ctx.as.stripe`. + +## Tables + +- `store_product`: public catalog rows +- `store_admin_identity`: private admin allowlist; fresh publishes seed the database owner from `init` + +## Procedures + +- `upsert_store_product`: admin-gated +- `seed_default_store_products({ force})`: admin-gated; idempotent unless `force` +- `set_store_product_price` / `clear_store_product_price`: admin-gated; links a Stripe price ID +- `list_store_products_json`: public read +- `add_admin_identity` / `remove_admin_identity` + +## Publishing + +```bash +spacetime publish --server http://127.0.0.1:3000 --yes spacetime-stripe-example +``` + +The parent test app's `pnpm run dev` calls this for you. + +## License + +[BSL 1.1](./LICENSE.txt), same as SpacetimeDB. diff --git a/spacetime-stripe-ts/example/spacetimedb/package.json b/spacetime-stripe-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..9e2b4de8251 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/package.json @@ -0,0 +1,20 @@ +{ + "name": "spacetime-stripe-example-module", + "version": "0.1.0", + "license": "BUSL-1.1", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-stripe-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-stripe-example" + }, + "dependencies": { + "@spacetimedb/stripe": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-stripe-ts/example/spacetimedb/src/index.ts b/spacetime-stripe-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..be30a418f7e --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,9 @@ +export { default, init } from './submodule/schema'; +export * from './submodule/operations'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; +export { + health, + echo, + stripe_webhook_handler, + router, +} from './submodule/webhooks'; diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/auth.ts b/spacetime-stripe-ts/example/spacetimedb/src/submodule/auth.ts new file mode 100644 index 00000000000..01e6b2840c6 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/submodule/auth.ts @@ -0,0 +1,79 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { throwSenderError } from './utils'; + +// Admin gate. Fresh publishes seed the owner via init. Public reducers never +// bootstrap admin state from "first caller wins". +type Sender = WriteCtx['sender']; +type ModuleTimestamp = WriteCtx['timestamp']; + +export type AdminVerdict = 'admin' | 'denied'; + +export function isAdmin(ctx: WriteCtx, sender: Sender): boolean { + return ctx.db.storeAdminIdentity.identity.find(sender) != null; +} + +export function adminVerdict(ctx: WriteCtx, sender: Sender): AdminVerdict { + return isAdmin(ctx, sender) ? 'admin' : 'denied'; +} + +export function denyIfNotAdmin(verdict: AdminVerdict): void { + if (verdict === 'denied') throwSenderError('store.not_authorized'); +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError('store.not_authorized'); +} + +// For owner-gated repair/setup code only. Do not call from a public bootstrap path. +export function seedAdmin( + ctx: WriteCtx, + sender: Sender, + timestamp: ModuleTimestamp +) { + if (ctx.db.storeAdminIdentity.identity.find(sender) != null) return; + ctx.db.storeAdminIdentity.insert({ + identity: sender, + addedAtMicros: timestamp.microsSinceUnixEpoch, + }); +} + +export const add_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + if (tx.db.storeAdminIdentity.identity.find(identity) == null) { + tx.db.storeAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + }); + return {}; + } +); + +export const remove_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + const existing = tx.db.storeAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.storeAdminIdentity.count() <= 1n) { + throwSenderError('store.cannot_remove_last_admin'); + } + tx.db.storeAdminIdentity.delete(existing); + }); + return {}; + } +); diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts b/spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts new file mode 100644 index 00000000000..f764d522e66 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts @@ -0,0 +1,593 @@ +import { + Range, + spacetimedb, + t, + type ModuleTimestamp, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import * as stripe from '@spacetimedb/stripe/submodule'; +import { requireAdmin } from './auth'; +import { stringArrayFromJson, throwSenderError } from './utils'; + +const DEFAULT_STORE_PRODUCTS: Array<{ + productId: string; + name: string; + description: string; + mode: string; + priceLabel: string; + perks: string[]; + sortOrder: bigint; +}> = [ + { + productId: 'orbital-starter-pack', + name: 'Orbital Starter Pack', + description: 'One-time booster crate for first-wave pilots.', + mode: 'payment', + priceLabel: '$19.00', + perks: [ + 'Orbital pilot badge', + 'Nebula ship skin', + 'Priority support queue', + ], + sortOrder: 10n, + }, + { + productId: 'warp-pass', + name: 'Warp Pass', + description: 'Monthly command-tier subscription.', + mode: 'subscription', + priceLabel: '$9.00 / month', + perks: [ + 'Expanded cargo slots', + 'Telemetry dashboard', + 'Beta sector access', + ], + sortOrder: 20n, + }, + { + productId: 'fleet-command-bundle', + name: 'Fleet Command Bundle', + description: 'Multi-seat bundle for your squad.', + mode: 'payment', + priceLabel: '$49.00', + perks: [ + '5 pilot bundle', + 'Faction banner cosmetic', + 'Shared vault upgrade', + ], + sortOrder: 30n, + }, +]; + +const stripeHttpResponse = t.object('StoreStripeHttpResponse', { + status: t.u16(), + body: t.string(), +}); + +const storeValidateStripePriceResult = t.object( + 'StoreValidateStripePriceResult', + { + valid: t.bool(), + status: t.u16(), + active: t.option(t.bool()), + currency: t.option(t.string()), + unitAmount: t.option(t.i64()), + livemode: t.option(t.bool()), + type: t.option(t.string()), + message: t.option(t.string()), + code: t.option(t.string()), + errorType: t.option(t.string()), + } +); + +const storeGetOrCreateCustomerResult = t.object( + 'StoreGetOrCreateCustomerResult', + { + customerId: t.string(), + isNew: t.bool(), + } +); + +const storeCheckoutLineItem = t.object('StoreCheckoutLineItem', { + priceId: t.string(), + quantity: t.i64(), +}); + +const storeCheckoutSessionResult = t.object('StoreCheckoutSessionResult', { + sessionId: t.string(), + url: t.option(t.string()), +}); + +function parseAmountCents(priceLabel: string): bigint { + const match = /(\d+)(?:\.(\d{1,2}))?/.exec(priceLabel); + if (!match) { + throwSenderError(`store.invalid_price_label:${priceLabel}`); + } + const whole = BigInt(Number.parseInt(match[1], 10)); + const fraction = BigInt( + match[2] ? Number.parseInt(match[2].padEnd(2, '0'), 10) : 0 + ); + return whole * 100n + fraction; +} + +function getLookupKey(databaseIdentity: string, productId: string): string { + const safeDb = databaseIdentity.replace(/[^A-Za-z0-9_-]/g, '_'); + const safeProduct = productId.replace(/[^A-Za-z0-9_-]/g, '_'); + return `stdb_${safeDb}_${safeProduct}`; +} + +function encodeFormComponent(value: string): string { + return encodeURIComponent(value).replace(/%20/g, '+'); +} + +function formBody(pairs: Array<[string, string | undefined]>): string { + return pairs + .filter((pair): pair is [string, string] => pair[1] !== undefined) + .map( + ([key, value]) => + `${encodeFormComponent(key)}=${encodeFormComponent(value)}` + ) + .join('&'); +} + +function callStripe( + ctx: ProcedureModuleCtx, + method: string, + path: string, + body?: string +) { + return stripe.stripe_api_request(ctx.as.stripe, { + method, + path, + formBody: body, + idempotencyKey: undefined, + }) as { status: number; body: string }; +} + +type StripeObject = Record; + +function isObject(value: unknown): value is StripeObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseStripeResponse(response: { + status: number; + body: string; +}): StripeObject { + let parsed: unknown; + try { + parsed = JSON.parse(response.body || '{}'); + } catch { + throwSenderError(`store.stripe_invalid_json:${response.status}`); + } + if (!isObject(parsed)) + throwSenderError(`store.stripe_invalid_response:${response.status}`); + if (response.status < 200 || response.status >= 300) { + const error = isObject(parsed.error) ? parsed.error : undefined; + const message = + typeof error?.message === 'string' + ? error.message + : `Stripe API returned ${response.status}`; + throwSenderError(`store.stripe_api_error:${message}`); + } + return parsed; +} + +function stripeObjectId(value: StripeObject, kind: string): string { + if (typeof value.id !== 'string' || value.id.length === 0) { + throwSenderError(`store.stripe_invalid_${kind}`); + } + return value.id; +} + +function findStripePriceByLookupKey( + ctx: ProcedureModuleCtx, + lookupKey: string +): StripeObject | undefined { + const query = formBody([ + ['lookup_keys[]', lookupKey], + ['active', 'true'], + ['limit', '1'], + ]); + const parsed = parseStripeResponse( + callStripe(ctx, 'GET', `/v1/prices?${query}`) + ); + if (!Array.isArray(parsed.data)) return undefined; + const first = parsed.data[0]; + return isObject(first) ? first : undefined; +} + +function createStripeProduct( + ctx: ProcedureModuleCtx, + args: { + productId: string; + name: string; + description: string; + databaseIdentity: string; + } +) { + return parseStripeResponse( + callStripe( + ctx, + 'POST', + '/v1/products', + formBody([ + ['name', args.name], + ['description', args.description], + ['metadata[stdb_product_id]', args.productId], + ['metadata[stdb_db]', args.databaseIdentity], + ]) + ) + ); +} + +function createStripePrice( + ctx: ProcedureModuleCtx, + args: { + stripeProductId: string; + productId: string; + mode: string; + priceLabel: string; + lookupKey: string; + databaseIdentity: string; + } +) { + const pairs: Array<[string, string | undefined]> = [ + ['product', args.stripeProductId], + ['currency', 'usd'], + ['unit_amount', String(parseAmountCents(args.priceLabel))], + ['lookup_key', args.lookupKey], + ['metadata[stdb_product_id]', args.productId], + ['metadata[stdb_db]', args.databaseIdentity], + ]; + if (args.mode === 'subscription') { + pairs.push(['recurring[interval]', 'month']); + } + return parseStripeResponse( + callStripe(ctx, 'POST', '/v1/prices', formBody(pairs)) + ); +} + +function upsertStoreProduct( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + productId: string; + name: string; + description: string; + mode: string; + priceLabel: string; + stripePriceId: string | undefined; + perksJson: string | undefined; + active: boolean; + sortOrder: bigint; + } +) { + const existing = ctx.db.storeProduct.productId.find(args.productId); + const row = { + productId: args.productId, + name: args.name, + description: args.description, + mode: args.mode, + priceLabel: args.priceLabel, + stripePriceId: args.stripePriceId, + perksJson: args.perksJson, + active: args.active, + sortOrder: args.sortOrder, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.storeProduct.insert(row); + return; + } + if (ctx.db.storeProduct.productId.update) { + ctx.db.storeProduct.productId.update(row); + } else { + ctx.db.storeProduct.delete(existing); + ctx.db.storeProduct.insert(row); + } +} + +export const upsert_store_product = spacetimedb.reducer( + { + productId: t.string(), + name: t.string(), + description: t.string(), + mode: t.string(), + priceLabel: t.string(), + stripePriceId: t.option(t.string()), + perksJson: t.option(t.string()), + active: t.option(t.bool()), + sortOrder: t.option(t.i64()), + }, + (ctx, args) => { + const tx = ctx; + requireAdmin(tx, ctx.sender); + upsertStoreProduct(tx, ctx.timestamp, { + productId: args.productId, + name: args.name, + description: args.description, + mode: args.mode, + priceLabel: args.priceLabel, + stripePriceId: args.stripePriceId, + perksJson: args.perksJson, + active: args.active ?? true, + sortOrder: args.sortOrder ?? 0n, + }); + } +); + +export const seed_default_store_products = spacetimedb.reducer( + { force: t.option(t.bool()) }, + (ctx, args) => { + const force = args.force ?? false; + const tx = ctx; + requireAdmin(tx, ctx.sender); + const hasAnyProducts = tx.db.storeProduct.count() > 0n; + if (hasAnyProducts && !force) return; + + for (const product of DEFAULT_STORE_PRODUCTS) { + upsertStoreProduct(tx, ctx.timestamp, { + productId: product.productId, + name: product.name, + description: product.description, + mode: product.mode, + priceLabel: product.priceLabel, + stripePriceId: undefined, + perksJson: JSON.stringify(product.perks), + active: true, + sortOrder: product.sortOrder, + }); + } + } +); + +export const list_store_products_json = spacetimedb.procedure( + {}, + t.string(), + ctx => + ctx.withTx(tx => { + const rows = [ + ...tx.db.storeProduct.byActiveSort.filter([true, new Range()]), + ]; + const mapped = rows.map(row => ({ + id: row.productId, + name: row.name, + description: row.description, + mode: row.mode, + priceLabel: row.priceLabel, + priceId: row.stripePriceId ?? '', + perks: stringArrayFromJson(row.perksJson), + sortOrder: Number(row.sortOrder), + })); + return JSON.stringify(mapped); + }) +); + +export const configure_stripe = spacetimedb.procedure( + { + secretKey: t.string(), + stripeVersion: t.option(t.string()), + webhookSigningSecret: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + const verdict = ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + return true; + }); + void verdict; + return stripe.set_stripe_config(ctx.as.stripe, { + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + webhookSigningSecret: args.webhookSigningSecret, + }); + } +); + +export const store_stripe_api_request = spacetimedb.procedure( + { + method: t.string(), + path: t.string(), + formBody: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + stripeHttpResponse, + (ctx, args) => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + return stripe.stripe_api_request(ctx.as.stripe, { + method: args.method, + path: args.path, + formBody: args.formBody, + idempotencyKey: args.idempotencyKey, + }) as { status: number; body: string }; + } +); + +export const validate_store_stripe_price = spacetimedb.procedure( + { priceId: t.string() }, + storeValidateStripePriceResult, + (ctx, args) => + stripe.validate_stripe_price(ctx.as.stripe, { + priceId: args.priceId, + }) as { + valid: boolean; + status: number; + active: boolean | undefined; + currency: string | undefined; + unitAmount: bigint | undefined; + livemode: boolean | undefined; + type: string | undefined; + message: string | undefined; + code: string | undefined; + errorType: string | undefined; + } +); + +export const get_store_webhook_event_count = spacetimedb.procedure( + {}, + t.i64(), + ctx => stripe.get_webhook_event_count(ctx.as.stripe, {}) as bigint +); + +export const get_or_create_store_customer = spacetimedb.procedure( + { + userId: t.string(), + email: t.option(t.string()), + name: t.option(t.string()), + }, + storeGetOrCreateCustomerResult, + (ctx, args) => + stripe.get_or_create_customer(ctx.as.stripe, { + userId: args.userId, + email: args.email, + name: args.name, + }) as { customerId: string; isNew: boolean } +); + +export const create_store_checkout_session = spacetimedb.procedure( + { + items: t.array(storeCheckoutLineItem), + customerId: t.option(t.string()), + mode: t.string(), + successUrl: t.string(), + cancelUrl: t.string(), + metadataJson: t.option(t.string()), + subscriptionMetadataJson: t.option(t.string()), + paymentIntentMetadataJson: t.option(t.string()), + }, + storeCheckoutSessionResult, + (ctx, args) => + stripe.create_checkout_session(ctx.as.stripe, { + items: args.items, + customerId: args.customerId, + mode: args.mode, + successUrl: args.successUrl, + cancelUrl: args.cancelUrl, + metadataJson: args.metadataJson, + subscriptionMetadataJson: args.subscriptionMetadataJson, + paymentIntentMetadataJson: args.paymentIntentMetadataJson, + }) as { sessionId: string; url: string | undefined } +); + +export const sync_store_products_with_stripe = spacetimedb.procedure( + {}, + t.string(), + ctx => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + const databaseIdentity = ctx.databaseIdentity.toHexString(); + const rows = ctx.withTx(tx => [ + ...tx.db.storeProduct.byActiveSort.filter([true, new Range()]), + ]); + const results: Array<{ + productId: string; + priceId: string; + action: string; + }> = []; + + for (const row of rows) { + if (row.stripePriceId) { + results.push({ + productId: row.productId, + priceId: row.stripePriceId, + action: 'kept', + }); + continue; + } + + const lookupKey = getLookupKey(databaseIdentity, row.productId); + const existing = findStripePriceByLookupKey(ctx, lookupKey); + const price = + existing ?? + (() => { + const product = createStripeProduct(ctx, { + productId: row.productId, + name: row.name, + description: row.description, + databaseIdentity, + }); + return createStripePrice(ctx, { + stripeProductId: stripeObjectId(product, 'product'), + productId: row.productId, + mode: row.mode, + priceLabel: row.priceLabel, + lookupKey, + databaseIdentity, + }); + })(); + + ctx.withTx(tx => { + const current = tx.db.storeProduct.productId.find(row.productId); + if (!current) + throwSenderError(`store.product_not_found:${row.productId}`); + upsertStoreProduct(tx, ctx.timestamp, { + productId: current.productId, + name: current.name, + description: current.description, + mode: current.mode, + priceLabel: current.priceLabel, + stripePriceId: stripeObjectId(price, 'price'), + perksJson: current.perksJson, + active: current.active, + sortOrder: current.sortOrder, + }); + }); + results.push({ + productId: row.productId, + priceId: stripeObjectId(price, 'price'), + action: existing ? 'linked' : 'created', + }); + } + + return JSON.stringify(results); + } +); + +export const set_store_product_price = spacetimedb.reducer( + { productId: t.string(), stripePriceId: t.string() }, + (ctx, args) => { + const tx = ctx; + requireAdmin(tx, ctx.sender); + const existing = tx.db.storeProduct.productId.find(args.productId); + if (!existing) { + throwSenderError(`store.product_not_found:${args.productId}`); + } + upsertStoreProduct(tx, ctx.timestamp, { + productId: existing.productId, + name: existing.name, + description: existing.description, + mode: existing.mode, + priceLabel: existing.priceLabel, + stripePriceId: args.stripePriceId, + perksJson: existing.perksJson, + active: existing.active, + sortOrder: existing.sortOrder, + }); + } +); + +export const clear_store_product_price = spacetimedb.reducer( + { productId: t.string() }, + (ctx, args) => { + const tx = ctx; + requireAdmin(tx, ctx.sender); + const existing = tx.db.storeProduct.productId.find(args.productId); + if (!existing) { + throwSenderError(`store.product_not_found:${args.productId}`); + } + upsertStoreProduct(tx, ctx.timestamp, { + productId: existing.productId, + name: existing.name, + description: existing.description, + mode: existing.mode, + priceLabel: existing.priceLabel, + stripePriceId: undefined, + perksJson: existing.perksJson, + active: existing.active, + sortOrder: existing.sortOrder, + }); + } +); diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/schema.ts b/spacetime-stripe-ts/example/spacetimedb/src/submodule/schema.ts new file mode 100644 index 00000000000..f185a45e5bc --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/submodule/schema.ts @@ -0,0 +1,91 @@ +import { + schema, + table, + t, + Range, + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as stripe from '@spacetimedb/stripe/submodule'; + +export const storeProductRow = { + productId: t.string().primaryKey(), + name: t.string(), + description: t.string(), + mode: t.string(), + priceLabel: t.string(), + stripePriceId: t.option(t.string()), + perksJson: t.option(t.string()), + active: t.bool(), + sortOrder: t.i64(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +// Identities allowed to mutate the catalog. Fresh publishes seed the owner via init. +export const storeAdminIdentityRow = { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), +}; + +export const storeProductTable = table( + { + name: 'store_product', + public: true, + indexes: [ + { + accessor: 'byActiveSort', + algorithm: 'btree', + columns: ['active', 'sortOrder', 'productId'], + }, + { + accessor: 'byModeSort', + algorithm: 'btree', + columns: ['mode', 'sortOrder', 'productId'], + }, + { + accessor: 'byStripePriceId', + algorithm: 'btree', + columns: ['stripePriceId'], + }, + ], + }, + storeProductRow +); + +export const storeAdminIdentityTable = table( + { name: 'store_admin_identity', public: false, indexes: [] }, + storeAdminIdentityRow +); + +export const spacetimedb = schema({ + stripe, + storeProduct: storeProductTable, + storeAdminIdentity: storeAdminIdentityTable, +}); + +export const init = spacetimedb.init(ctx => { + installStore(ctx); + stripe.installStripe(ctx.as.stripe); +}); + +export default spacetimedb; + +export { Range, SenderError, t }; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx< + typeof spacetimedb.schemaType +>; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; +export type ModuleTimestamp = ReducerModuleCtx['timestamp']; + +export function installStore(ctx: ReducerModuleCtx) { + if (ctx.db.storeAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.storeAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/utils.ts b/spacetime-stripe-ts/example/spacetimedb/src/submodule/utils.ts new file mode 100644 index 00000000000..6dd1c48261c --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/submodule/utils.ts @@ -0,0 +1,24 @@ +import { SenderError } from 'spacetimedb/server'; + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function safeJsonParse(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return undefined; + } +} + +export function stringArrayFromJson(value: string | undefined): string[] { + if (!value) return []; + const parsed = safeJsonParse(value); + if (!Array.isArray(parsed)) return []; + const out: string[] = []; + for (const item of parsed) { + if (typeof item === 'string') out.push(item); + } + return out; +} diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/webhooks.ts b/spacetime-stripe-ts/example/spacetimedb/src/submodule/webhooks.ts new file mode 100644 index 00000000000..a59e099bd86 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/submodule/webhooks.ts @@ -0,0 +1,41 @@ +// HTTP handlers for the store module. + +import { Router, SyncResponse } from 'spacetimedb/server'; +import { handle_stripe_webhook } from '@spacetimedb/stripe/submodule'; +import { spacetimedb } from './schema'; + +export const stripe_webhook_handler = spacetimedb.httpHandler((ctx, req) => + handle_stripe_webhook(ctx.as.stripe, req) +); + +export const health = spacetimedb.httpHandler((ctx, _req) => { + const count = ctx.withTx(tx => tx.db.storeProduct.count()); + return new SyncResponse( + JSON.stringify({ + ok: true, + catalogRows: Number(count), + at: ctx.timestamp.microsSinceUnixEpoch.toString(), + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +}); + +export const echo = spacetimedb.httpHandler((_ctx, req) => { + const body = req.text(); + return new SyncResponse( + JSON.stringify({ + method: req.method, + uri: req.uri, + bodyLength: body.length, + bodyPreview: body.slice(0, 200), + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +}); + +export const router = spacetimedb.httpRouter( + new Router() + .get('/health', health) + .post('/echo', echo) + .post('/stripe/webhook', stripe_webhook_handler) +); diff --git a/spacetime-stripe-ts/example/spacetimedb/tsconfig.json b/spacetime-stripe-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..4b599551afe --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-stripe-ts/example/src/app.ts b/spacetime-stripe-ts/example/src/app.ts new file mode 100644 index 00000000000..57732de8a21 --- /dev/null +++ b/spacetime-stripe-ts/example/src/app.ts @@ -0,0 +1,220 @@ +import { + DbConnection, + type EventContext, + type ErrorContext, +} from './codegen/app'; + +declare global { + interface Window { + stdb?: { + getOrCreateCustomer: (args: { + userId: string; + email?: string; + name?: string; + }) => Promise<{ customerId: string; isNew: boolean }>; + createCheckoutSession: (args: { + items: Array<{ priceId: string; quantity: number }>; + customerId?: string; + mode: 'payment' | 'subscription' | string; + successUrl: string; + cancelUrl: string; + metadataJson?: string; + subscriptionMetadataJson?: string; + paymentIntentMetadataJson?: string; + }) => Promise<{ sessionId: string; url?: string }>; + validatePrice: (priceId: string) => Promise<{ + valid: boolean; + active?: boolean; + message?: string; + code?: string; + errorType?: string; + }>; + getWebhookEventCount: () => Promise; + }; + } +} + +type StoreProductRow = { + productId: string; + name: string; + description: string; + mode: string; + priceLabel: string; + stripePriceId: string | undefined; + perksJson: string | undefined; + active: boolean; + sortOrder: bigint; + createdAt: { microsSinceUnixEpoch: bigint }; + updatedAt: { microsSinceUnixEpoch: bigint }; +}; + +interface ServerConfig { + stdbUri: string; + database: string; +} + +const products = new Map(); + +function parsePerks(json: string | undefined): string[] { + if (!json) return []; + try { + const parsed = JSON.parse(json); + return Array.isArray(parsed) + ? parsed.filter(x => typeof x === 'string') + : []; + } catch { + return []; + } +} + +function broadcastCatalog() { + const sorted = [...products.values()] + .filter(p => p.active) + .sort((a, b) => { + const so = Number(a.sortOrder - b.sortOrder); + return so !== 0 ? so : a.productId.localeCompare(b.productId); + }); + window.dispatchEvent( + new CustomEvent('stdb:catalog', { + detail: { + products: sorted.map(p => ({ + id: p.productId, + name: p.name, + description: p.description, + mode: p.mode, + priceLabel: p.priceLabel, + priceId: p.stripePriceId ?? '', + perks: parsePerks(p.perksJson), + sortOrder: Number(p.sortOrder), + active: p.active, + })), + }, + }) + ); +} + +function updateConnState( + state: 'connecting' | 'connected' | 'error', + detail?: string +) { + window.dispatchEvent( + new CustomEvent('stdb:connState', { detail: { state, detail } }) + ); +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + return (await r.json()) as ServerConfig; +} + +function connectApp(config: ServerConfig): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout( + () => reject(new Error(`Timed out connecting to ${config.stdbUri}`)), + 10000 + ); + DbConnection.builder() + .withUri(config.stdbUri) + .withDatabaseName(config.database) + .withCompression('none') + .onConnect(c => { + window.clearTimeout(timeout); + resolve(c); + }) + .onDisconnect((_ctx, err) => { + window.clearTimeout(timeout); + updateConnState('error', err?.message ?? 'disconnected'); + }) + .onConnectError((_ctx, err) => { + window.clearTimeout(timeout); + updateConnState('error', 'connect failed (app)'); + reject(err); + }) + .build(); + }); +} + +async function api(path: string, body?: unknown): Promise { + const response = await fetch(path, { + method: body === undefined ? 'GET' : 'POST', + headers: + body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload: unknown = await response.json().catch(() => ({})); + if (!response.ok) { + const message = + isRecord(payload) && typeof payload.error === 'string' + ? payload.error + : `${path} returned ${response.status}`; + throw new Error(message); + } + return payload as T; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function main() { + updateConnState('connecting'); + let conn: DbConnection; + try { + const config = await loadServerConfig(); + conn = await connectApp(config); + } catch (err) { + console.error('STDB connect failed:', err); + updateConnState('error', err instanceof Error ? err.message : String(err)); + return; + } + + conn.db.storeProduct.onInsert((_ctx: EventContext, row: StoreProductRow) => { + products.set(row.productId, row); + broadcastCatalog(); + }); + conn.db.storeProduct.onUpdate( + (_ctx: EventContext, _o: StoreProductRow, n: StoreProductRow) => { + products.set(n.productId, n); + broadcastCatalog(); + } + ); + conn.db.storeProduct.onDelete((_ctx: EventContext, row: StoreProductRow) => { + products.delete(row.productId); + broadcastCatalog(); + }); + + window.stdb = { + getOrCreateCustomer: args => api('/api/customer', args), + createCheckoutSession: args => + api('/api/checkout', { + items: args.items, + customerId: args.customerId, + mode: args.mode, + }), + validatePrice: priceId => api('/api/validate-price', { priceId }), + getWebhookEventCount: () => + api<{ count: number }>('/api/webhook-event-count').then( + result => result.count + ), + }; + + conn + .subscriptionBuilder() + .onApplied(() => { + products.clear(); + for (const row of conn.db.storeProduct.iter() as Iterable) { + products.set(row.productId, row); + } + broadcastCatalog(); + updateConnState('connected'); + window.dispatchEvent(new CustomEvent('stdb:ready')); + }) + .onError((ctx: ErrorContext) => { + console.error('catalog sub error', ctx.event); + updateConnState('error', String(ctx.event)); + }) + .subscribe(['SELECT * FROM store_product']); +} + +main(); diff --git a/spacetime-stripe-ts/example/tsconfig.json b/spacetime-stripe-ts/example/tsconfig.json new file mode 100644 index 00000000000..9f5cdd8aac1 --- /dev/null +++ b/spacetime-stripe-ts/example/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "server.ts"], + "exclude": ["node_modules", "public", "dist"] +} diff --git a/spacetime-stripe-ts/package.json b/spacetime-stripe-ts/package.json new file mode 100644 index 00000000000..2840d93e6b3 --- /dev/null +++ b/spacetime-stripe-ts/package.json @@ -0,0 +1,73 @@ +{ + "name": "@spacetimedb/stripe", + "description": "Stripe catalog, checkout, customer, subscription, and webhook primitives for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-stripe-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-stripe-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "stripe", + "payments", + "webhooks" + ], + "scripts": { + "build": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test-unit.ts", + "generate-ts": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-stripe", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-stripe", + "test:smoke": "tsx scripts/test-stripe-smoke.ts", + "test:stripe:e2e": "tsx scripts/test-stripe-e2e.ts", + "test:gate:local": "spacetime build && spacetime publish --server local --yes spacetime-stripe && tsx scripts/test-stripe-e2e.ts --skip-build-publish --skip-checkout-trigger --events customer.created --kill-existing-stripe-listeners" + }, + "dependencies": { + "@spacetimedb/crypto": "workspace:^", + "stripe": "^22.1.0", + "valibot": "^1.4.2" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^25.6.0", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-stripe-ts/scripts/test-stripe-e2e.ts b/spacetime-stripe-ts/scripts/test-stripe-e2e.ts new file mode 100644 index 00000000000..4e9540086a1 --- /dev/null +++ b/spacetime-stripe-ts/scripts/test-stripe-e2e.ts @@ -0,0 +1,763 @@ +import * as http from 'node:http'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { existsSync } from 'node:fs'; + +type Options = { + server: string; + database: string; + relayPort: number; + stripeCliPath: string; + stripeProjectName?: string; + stripeApiKey?: string; + events: string[]; + deliveryWaitSeconds: number; + listenerWarmupSeconds: number; + skipBuildPublish: boolean; + skipCheckoutTrigger: boolean; + killExistingStripeListeners: boolean; +}; + +type CmdResult = { + code: number; + stdout: string; + stderr: string; +}; + +type ProcessedRelayEvent = { + eventId: string; + eventType: string; + objectId?: string; +}; + +const DEFAULT_EVENTS = [ + 'customer.created', + 'customer.subscription.created', + 'payment_intent.succeeded', + 'checkout.session.completed', + 'invoice.paid', +]; +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +function step(name: string) { + process.stdout.write(`\n==> ${name}\n`); +} + +function info(line: string) { + process.stdout.write(` ${line}\n`); +} + +function tailLines(text: string, count: number) { + return text.split(/\r?\n/).filter(Boolean).slice(-count); +} + +function normalizeFlag(flag: string) { + return flag.replace(/^-+/, '').toLowerCase(); +} + +function parseArgs(argv: string[]): Options { + const opts: Options = { + server: 'local', + database: 'stripe-ts-e2e', + relayPort: 12111, + stripeCliPath: 'stripe', + events: [...DEFAULT_EVENTS], + deliveryWaitSeconds: 20, + listenerWarmupSeconds: 3, + skipBuildPublish: false, + skipCheckoutTrigger: false, + killExistingStripeListeners: false, + }; + + const takeValue = (i: number, flag: string) => { + if (i + 1 >= argv.length) { + throw new Error(`Missing value for --${flag}`); + } + return argv[i + 1]!; + }; + + for (let i = 0; i < argv.length; i++) { + const raw = argv[i]!; + if (raw === '--') continue; + if (!raw.startsWith('-')) continue; + const flag = normalizeFlag(raw); + + if (flag === 'help' || flag === 'h') { + process.stdout.write(`Usage: + pnpm run test:stripe:e2e -- [options] + +Options: + --server + --database + --relay-port + --stripe-cli-path + --stripe-project-name + --stripe-api-key + --events + --delivery-wait-seconds + --listener-warmup-seconds + --skip-build-publish + --skip-checkout-trigger + --kill-existing-stripe-listeners +`); + process.exit(0); + } + + if (flag === 'skipbuildpublish' || flag === 'skip-build-publish') { + opts.skipBuildPublish = true; + continue; + } + if (flag === 'skipcheckouttrigger' || flag === 'skip-checkout-trigger') { + opts.skipCheckoutTrigger = true; + continue; + } + if ( + flag === 'killexistingstripelisteners' || + flag === 'kill-existing-stripe-listeners' + ) { + opts.killExistingStripeListeners = true; + continue; + } + + const value = takeValue(i, flag); + i++; + + switch (flag) { + case 'server': + opts.server = value; + break; + case 'database': + opts.database = value; + break; + case 'relayport': + case 'relay-port': + opts.relayPort = Number.parseInt(value, 10); + break; + case 'stripeclipath': + case 'stripe-cli-path': + opts.stripeCliPath = value; + break; + case 'stripeprojectname': + case 'stripe-project-name': + opts.stripeProjectName = value; + break; + case 'stripeapikey': + case 'stripe-api-key': + opts.stripeApiKey = value; + break; + case 'events': + opts.events = value + .split(',') + .map(v => v.trim()) + .filter(Boolean); + break; + case 'deliverywaitseconds': + case 'delivery-wait-seconds': + opts.deliveryWaitSeconds = Number.parseInt(value, 10); + break; + case 'listenerwarmupseconds': + case 'listener-warmup-seconds': + opts.listenerWarmupSeconds = Number.parseInt(value, 10); + break; + default: + throw new Error(`Unknown flag: ${raw}`); + } + } + + if (!Number.isFinite(opts.relayPort) || opts.relayPort <= 0) { + throw new Error('relay port must be a positive integer'); + } + if ( + !Number.isFinite(opts.deliveryWaitSeconds) || + opts.deliveryWaitSeconds <= 0 + ) { + throw new Error('delivery wait seconds must be a positive integer'); + } + if ( + !Number.isFinite(opts.listenerWarmupSeconds) || + opts.listenerWarmupSeconds < 0 + ) { + throw new Error('listener warmup seconds must be >= 0'); + } + if (opts.events.length === 0) { + throw new Error('at least one event is required'); + } + + return opts; +} + +function spawnCapture( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options?.cwd, + env: options?.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' && file.endsWith('.cmd'), + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { + stdout += chunk.toString(); + }); + child.stderr.on('data', chunk => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', code => { + resolve({ code: code ?? -1, stdout, stderr }); + }); + }); +} + +function spawnInherit( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options?.cwd, + env: options?.env, + stdio: 'inherit', + shell: process.platform === 'win32' && file.endsWith('.cmd'), + windowsHide: false, + }); + child.on('error', reject); + child.on('close', code => resolve(code ?? -1)); + }); +} + +async function runChecked( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +) { + info([file, ...args].join(' ')); + const code = await spawnInherit(file, args, options); + if (code !== 0) { + throw new Error(`Command failed with exit code ${code}: ${file}`); + } +} + +async function checkCommand(file: string, args: string[]) { + const out = await spawnCapture(file, args); + if (out.code !== 0) { + throw new Error(`Required command not runnable: ${file}`); + } +} + +async function getWebhookEventCount(server: string, database: string) { + return getSqlCount( + server, + database, + 'select count(*) as c from stripe_webhook_event' + ); +} + +async function getSqlCount(server: string, database: string, query: string) { + const result = await spawnCapture('spacetime', [ + 'sql', + '--server', + server, + database, + query, + ]); + if (result.code !== 0) { + throw new Error( + `Failed SQL query.\n${query}\n${result.stderr || result.stdout}` + ); + } + const merged = `${result.stdout}\n${result.stderr}`; + const lines = merged.split(/\r?\n/); + const numberLine = lines.find(line => /^\s*\d+\s*$/.test(line)); + if (!numberLine) { + throw new Error( + `Could not parse SQL count output.\nQuery: ${query}\n${merged}` + ); + } + return Number.parseInt(numberLine.trim(), 10); +} + +function sqlStringLiteral(value: string) { + return `'${value.replace(/'/g, "''")}'`; +} + +async function assertCountAtLeast( + server: string, + database: string, + query: string, + min: number, + label: string +) { + const count = await getSqlCount(server, database, query); + if (count < min) { + throw new Error( + `Assertion failed: ${label}. Expected >= ${min}, got ${count}. Query: ${query}` + ); + } +} + +async function assertEventWorkflowState( + server: string, + database: string, + event: ProcessedRelayEvent +) { + const eventIdLiteral = sqlStringLiteral(event.eventId); + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_webhook_event where event_id = ${eventIdLiteral}`, + 1, + `webhook event row exists for ${event.eventId}` + ); + + if (!event.objectId) return; + const objectIdLiteral = sqlStringLiteral(event.objectId); + switch (event.eventType) { + case 'customer.created': + case 'customer.updated': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_customer where stripe_customer_id = ${objectIdLiteral}`, + 1, + `customer row exists for ${event.objectId}` + ); + return; + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_subscription where stripe_subscription_id = ${objectIdLiteral}`, + 1, + `subscription row exists for ${event.objectId}` + ); + return; + case 'checkout.session.completed': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_checkout_session where stripe_checkout_session_id = ${objectIdLiteral}`, + 1, + `checkout session row exists for ${event.objectId}` + ); + return; + case 'payment_intent.succeeded': + // Invoice-attached and recent-subscription payment intents are + // ignored to avoid duplicate or orphan payment rows. The + // aggregate assertion in main verifies that at least one standalone + // trigger produced a payment row. + return; + case 'invoice.created': + case 'invoice.finalized': + case 'invoice.paid': + case 'invoice.payment_succeeded': + case 'invoice.payment_failed': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_invoice where stripe_invoice_id = ${objectIdLiteral}`, + 1, + `invoice row exists for ${event.objectId}` + ); + return; + default: + return; + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const moduleRoot = process.cwd(); + const relayOutput: string[] = []; + let stripeListen: ReturnType | undefined; + let stripeStdout = ''; + let stripeStderr = ''; + let relayServer: http.Server | undefined; + let relayHits = 0; + const processedEvents: ProcessedRelayEvent[] = []; + let triggeredEvents: string[] = []; + + try { + step('Check required CLI tools'); + await checkCommand('spacetime', ['--help']); + if (!options.skipBuildPublish) { + await checkCommand(pnpmCommand, ['--version']); + } + if (options.stripeCliPath === 'stripe') { + await checkCommand('stripe', ['version']); + } else if (!existsSync(options.stripeCliPath)) { + throw new Error(`Stripe CLI not found at path: ${options.stripeCliPath}`); + } + + step('Clean stale Stripe listeners'); + if (options.killExistingStripeListeners) { + const kill = await spawnCapture('taskkill', [ + '/F', + '/IM', + 'stripe.exe', + '/T', + ]); + if (kill.code === 0) { + info('stopped existing stripe.exe processes.'); + } else { + info('no existing stripe.exe processes to stop (or no permission).'); + } + } else { + info('skipped (pass --kill-existing-stripe-listeners to force cleanup).'); + } + + if (!options.skipBuildPublish) { + step('Build module'); + await runChecked(pnpmCommand, ['run', 'build'], { cwd: moduleRoot }); + + step('Publish module locally'); + await runChecked( + 'spacetime', + ['publish', '--server', options.server, '--yes', options.database], + { cwd: moduleRoot } + ); + } + + step('Read current webhook event count'); + const beforeCount = await getWebhookEventCount( + options.server, + options.database + ); + const beforePaymentCount = await getSqlCount( + options.server, + options.database, + 'select count(*) as c from stripe_payment' + ); + info(`Current webhook rows: ${beforeCount}`); + + step('Start local relay endpoint'); + relayServer = http.createServer( + async (req: http.IncomingMessage, res: http.ServerResponse) => { + if (req.method !== 'POST' || req.url !== '/stripe-webhook/') { + res.statusCode = 404; + res.end('not found'); + return; + } + + const chunks: Buffer[] = []; + const signatureHeader = req.headers['stripe-signature']; + req.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk))); + await once(req, 'end'); + relayHits++; + + let event: Record; + let rawBody: string; + try { + rawBody = Buffer.concat(chunks).toString('utf8'); + const parsed: unknown = JSON.parse(rawBody); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('event must be an object'); + } + event = parsed as Record; + } catch (err) { + const msg = `relay error status=400 msg=invalid-json: ${String(err)}`; + relayOutput.push(msg); + res.statusCode = 400; + res.end('invalid json'); + return; + } + + if (typeof event.id !== 'string' || typeof event.type !== 'string') { + const msg = 'relay error status=400 msg=missing-event-id-or-type'; + relayOutput.push(msg); + res.statusCode = 400; + res.end('invalid event'); + return; + } + if (typeof signatureHeader !== 'string' || !signatureHeader) { + relayOutput.push( + `relay event id=${event.id} type=${event.type} status=400 missing-signature` + ); + res.statusCode = 400; + res.end('missing signature'); + return; + } + + const callArgs = [ + 'call', + '--server', + options.server, + options.database, + 'ingest_stripe_webhook', + JSON.stringify(String(event.id)), + JSON.stringify(String(event.type)), + event.livemode === true ? 'true' : 'false', + JSON.stringify(rawBody), + JSON.stringify({ some: signatureHeader }), + ]; + + const ingest = await spawnCapture('spacetime', callArgs); + if (ingest.code !== 0) { + const msg = `relay event id=${event.id} type=${event.type} status=500 ingest-failed`; + relayOutput.push(msg); + relayOutput.push( + tailLines(`${ingest.stdout}\n${ingest.stderr}`, 20).join('\n') + ); + res.statusCode = 500; + res.end('ingest failed'); + return; + } + + processedEvents.push({ + eventId: String(event.id), + eventType: String(event.type), + objectId: (() => { + const data = + typeof event.data === 'object' && event.data !== null + ? (event.data as Record) + : undefined; + const object = + typeof data?.object === 'object' && data.object !== null + ? (data.object as Record) + : undefined; + return typeof object?.id === 'string' ? object.id : undefined; + })(), + }); + const msg = `relay event id=${event.id} type=${event.type} status=200`; + relayOutput.push(msg); + res.statusCode = 200; + res.end('ok'); + } + ); + + await new Promise((resolve, reject) => { + relayServer!.once('error', reject); + relayServer!.listen(options.relayPort, '127.0.0.1', () => resolve()); + }); + info( + `Relay listening on http://127.0.0.1:${options.relayPort}/stripe-webhook/` + ); + + step('Start stripe listener process'); + const eventsCsv = options.events.join(','); + const forwardUrl = `http://127.0.0.1:${options.relayPort}/stripe-webhook/`; + const listenArgs = [ + 'listen', + '--events', + eventsCsv, + '--forward-to', + forwardUrl, + ]; + if (options.stripeProjectName) { + listenArgs.push('--project-name', options.stripeProjectName); + } + if (options.stripeApiKey) { + listenArgs.push('--api-key', options.stripeApiKey); + } + + stripeListen = spawn(options.stripeCliPath, listenArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + windowsHide: true, + }); + if (!stripeListen.stdout || !stripeListen.stderr) { + throw new Error('Failed to capture stripe listen stdout/stderr streams.'); + } + stripeListen.stdout.on('data', chunk => { + stripeStdout += chunk.toString(); + }); + stripeListen.stderr.on('data', chunk => { + stripeStderr += chunk.toString(); + }); + + const readyDeadline = Date.now() + 30_000; + while (Date.now() < readyDeadline) { + if (stripeListen.exitCode !== null) { + throw new Error( + `stripe listen exited early.\nSTDOUT:\n${stripeStdout}\nSTDERR:\n${stripeStderr}` + ); + } + if ( + stripeStdout.includes('Ready!') || + stripeStdout.includes('webhook signing secret') || + stripeStderr.includes('Ready!') || + stripeStderr.includes('webhook signing secret') + ) { + break; + } + await sleep(250); + } + if ( + !stripeStdout.includes('Ready!') && + !stripeStderr.includes('Ready!') && + !stripeStdout.includes('webhook signing secret') && + !stripeStderr.includes('webhook signing secret') + ) { + throw new Error( + `stripe listen did not become ready.\nSTDOUT:\n${stripeStdout}\nSTDERR:\n${stripeStderr}` + ); + } + + info(`stripe listen PID: ${stripeListen.pid ?? 'unknown'}`); + info(`forwarding to: ${forwardUrl}`); + info(`relay port: ${options.relayPort}`); + const listenerOutput = `${stripeStdout}\n${stripeStderr}`; + const signingSecret = listenerOutput.match(/whsec_[A-Za-z0-9]+/)?.[0]; + if (!signingSecret) { + throw new Error( + 'stripe listen became ready without reporting a webhook signing secret' + ); + } + step('Configure the listener webhook secret in the module'); + let configureSecret = await spawnCapture('spacetime', [ + 'call', + '--server', + options.server, + options.database, + 'set_stripe_webhook_signing_secret', + JSON.stringify(signingSecret), + ]); + const configureOutput = `${configureSecret.stdout}\n${configureSecret.stderr}`; + if ( + configureSecret.code !== 0 && + configureOutput.includes('config_not_set') + ) { + configureSecret = await spawnCapture('spacetime', [ + 'call', + '--server', + options.server, + options.database, + 'set_stripe_config', + JSON.stringify('sk_test_e2e_webhook_only'), + 'null', + JSON.stringify({ some: signingSecret }), + ]); + } + if (configureSecret.code !== 0) { + throw new Error( + `could not configure the Stripe listener secret: ${tailLines(`${configureSecret.stdout}\n${configureSecret.stderr}`, 10).join('\n')}` + ); + } + info('listener signing secret configured (value redacted).'); + if (options.listenerWarmupSeconds > 0) { + info(`warming listener for ${options.listenerWarmupSeconds}s...`); + await sleep(options.listenerWarmupSeconds * 1000); + } + + const preflight = await fetch(forwardUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + info(`relay preflight HTTP status: ${preflight.status}`); + + step('Trigger test events through Stripe CLI'); + const eventsToRun = options.skipCheckoutTrigger + ? options.events.filter( + eventName => eventName !== 'checkout.session.completed' + ) + : options.events; + triggeredEvents = [...eventsToRun]; + for (const eventName of eventsToRun) { + const triggerArgs = ['trigger', eventName]; + if (options.stripeProjectName) { + triggerArgs.push('--project-name', options.stripeProjectName); + } + if (options.stripeApiKey) { + triggerArgs.push('--api-key', options.stripeApiKey); + } + await runChecked(options.stripeCliPath, triggerArgs); + await sleep(1000); + } + + step('Validate webhook rows increased'); + if (relayOutput.length > 0) { + process.stdout.write('\n'); + info('relay output (tail):'); + for (const line of relayOutput.slice(-60)) { + info(` ${line}`); + } + } else { + info('relay output: no inbound webhook requests observed.'); + } + + const waitDeadline = Date.now() + options.deliveryWaitSeconds * 1000; + let afterCount = beforeCount; + while (Date.now() < waitDeadline) { + await sleep(1000); + afterCount = await getWebhookEventCount(options.server, options.database); + if (afterCount > beforeCount) break; + } + const delta = afterCount - beforeCount; + info(`Before: ${beforeCount}`); + info(`After: ${afterCount}`); + info(`Delta: ${delta}`); + + if (delta <= 0) { + process.stdout.write('\n'); + info('stripe listen stdout (tail):'); + for (const line of tailLines(stripeStdout, 80)) info(` ${line}`); + process.stdout.write('\n'); + info('stripe listen stderr (tail):'); + for (const line of tailLines(stripeStderr, 80)) info(` ${line}`); + process.stdout.write('\n'); + info(`relay request count: ${relayHits}`); + throw new Error( + 'No new webhook events were ingested. Expected delta > 0.' + ); + } + + for (const eventType of triggeredEvents) { + if (!processedEvents.some(event => event.eventType === eventType)) { + throw new Error( + `Assertion failed: did not observe forwarded event type ${eventType} in relay output.` + ); + } + } + for (const event of processedEvents) { + await assertEventWorkflowState(options.server, options.database, event); + } + if (triggeredEvents.includes('payment_intent.succeeded')) { + const afterPaymentCount = await getSqlCount( + options.server, + options.database, + 'select count(*) as c from stripe_payment' + ); + if (afterPaymentCount <= beforePaymentCount) { + throw new Error( + 'Assertion failed: no standalone payment_intent.succeeded event produced a payment row.' + ); + } + } + info( + `workflow assertions passed for ${processedEvents.length} forwarded event(s).` + ); + + process.stdout.write('\nStripe E2E test passed.\n'); + } finally { + if (stripeListen && stripeListen.exitCode === null && stripeListen.pid) { + try { + await spawnCapture('taskkill', [ + '/PID', + String(stripeListen.pid), + '/T', + '/F', + ]); + } catch { + // Ignore cleanup failures. + } + } + if (relayServer) { + await new Promise(resolve => relayServer!.close(() => resolve())); + } + } +} + +main().catch(err => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/spacetime-stripe-ts/scripts/test-stripe-smoke.ts b/spacetime-stripe-ts/scripts/test-stripe-smoke.ts new file mode 100644 index 00000000000..e1bc02a985e --- /dev/null +++ b/spacetime-stripe-ts/scripts/test-stripe-smoke.ts @@ -0,0 +1,398 @@ +// Synthetic-payload smoke test; locks in webhook behavior without Stripe CLI/keys. + +import { spawn } from 'node:child_process'; +import { createHmac } from 'node:crypto'; + +type Options = { + server: string; + database: string; + skipBuildPublish: boolean; +}; + +function parseArgs(argv: string[]): Options { + const opts: Options = { + server: 'local', + // Dedicated database so the smoke test never overwrites dev module config with placeholders. + database: 'stripe-ts-smoke-test', + skipBuildPublish: false, + }; + for (let i = 0; i < argv.length; i++) { + const raw = argv[i]!; + const flag = raw.replace(/^-+/, '').toLowerCase(); + if (flag === 'skip-build-publish') opts.skipBuildPublish = true; + if (flag === 'server') opts.server = argv[++i]!; + if (flag === 'database') opts.database = argv[++i]!; + } + return opts; +} + +function step(name: string) { + process.stdout.write(`\n==> ${name}\n`); +} + +function run( + cmd: string, + args: string[] +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise(resolve => { + const child = spawn(cmd, args, { shell: false }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', d => (stdout += String(d))); + child.stderr?.on('data', d => (stderr += String(d))); + child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); + child.on('error', err => + resolve({ code: 1, stdout, stderr: stderr + String(err) }) + ); + }); +} + +async function call( + opts: Options, + name: string, + args: string[] +): Promise { + const result = await run('spacetime', [ + 'call', + '--server', + opts.server, + opts.database, + name, + ...args, + ]); + if (result.code !== 0) { + throw new Error( + `spacetime call ${name} failed: code=${result.code}\nstderr: ${result.stderr}\nstdout: ${result.stdout}` + ); + } + return result.stdout; +} + +// Expects the call to fail. Returns combined stderr/stdout for assertion. +async function expectCallFails( + opts: Options, + name: string, + args: string[], + anonymous = false +): Promise { + const result = await run('spacetime', [ + 'call', + ...(anonymous ? ['--anonymous'] : []), + '--server', + opts.server, + opts.database, + name, + ...args, + ]); + if (result.code === 0) { + throw new Error( + `expected ${name} to fail but it succeeded:\nstdout: ${result.stdout}` + ); + } + return result.stderr + result.stdout; +} + +const q = (s: string) => JSON.stringify(s); +const some = (s: string) => JSON.stringify({ some: s }); +const STRIPE_WEBHOOK_SECRET = 'whsec_smoke_test_secret'; + +function stripeSignature(rawBody: string): string { + const ts = Math.floor(Date.now() / 1000); + const digest = createHmac('sha256', STRIPE_WEBHOOK_SECRET) + .update(`${ts}.${rawBody}`) + .digest('hex'); + return `t=${ts},v1=${digest}`; +} + +async function ingest( + opts: Options, + args: { + eventId: string; + eventType: string; + livemode?: boolean; + payload: object; + } +) { + const payloadJson = JSON.stringify(args.payload); + await call(opts, 'ingest_stripe_webhook', [ + q(args.eventId), + q(args.eventType), + String(args.livemode ?? false), + q(payloadJson), + some(stripeSignature(payloadJson)), + ]); +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (!opts.skipBuildPublish) { + step('spacetime build'); + const build = await run('spacetime', ['build']); + if (build.code !== 0) { + process.stderr.write(build.stderr); + throw new Error('build failed'); + } + + step(`spacetime publish --server ${opts.server} ${opts.database}`); + const publish = await run('spacetime', [ + 'publish', + '--server', + opts.server, + '--yes', + '--delete-data', + opts.database, + ]); + if (publish.code !== 0) { + process.stderr.write(publish.stderr); + throw new Error('publish failed'); + } + } + + // Procedures needing Stripe secret should refuse cleanly before config. + step('negative: get_or_create_customer before config, expect failure'); + const preBootstrap = await expectCallFails(opts, 'get_or_create_customer', [ + q('u_no_config_yet'), + 'null', + 'null', + ]); + if (!preBootstrap.toLowerCase().includes('config')) { + throw new Error( + `expected error to mention config; got: ${preBootstrap.slice(0, 400)}` + ); + } + + step('set_stripe_config'); + await call(opts, 'set_stripe_config', [ + q('sk_test_smoke_placeholder'), + 'null', + some(STRIPE_WEBHOOK_SECRET), + ]); + + step('negative: anonymous callers cannot read or mutate Stripe state'); + for (const [name, args] of [ + ['get_customer', [q('cus_smoke_1')]], + ['create_customer', ['null', 'null', 'null', 'null']], + [ + 'upsert_customer', + [q('cus_anonymous'), 'null', 'null', 'null', 'null', 'null'], + ], + ] as const) { + const unauthorized = await expectCallFails(opts, name, [...args], true); + if (!unauthorized.toLowerCase().includes('not_authorized')) { + throw new Error( + `expected ${name} to reject a non-admin caller: ${unauthorized.slice(0, 400)}` + ); + } + } + + step('negative: signed webhook metadata must match supplied metadata'); + const mismatchPayload = JSON.stringify({ + id: 'evt_smoke_signed_id', + type: 'customer.created', + data: { object: { id: 'cus_should_not_exist' } }, + }); + const mismatch = await expectCallFails(opts, 'ingest_stripe_webhook', [ + q('evt_smoke_forged_id'), + q('customer.deleted'), + 'false', + q(mismatchPayload), + some(stripeSignature(mismatchPayload)), + ]); + if (!mismatch.toLowerCase().includes('metadata')) { + throw new Error( + `expected signed metadata mismatch failure: ${mismatch.slice(0, 400)}` + ); + } + + step('ingest customer.created, expect customer row'); + await ingest(opts, { + eventId: 'evt_smoke_cust_1', + eventType: 'customer.created', + payload: { + id: 'evt_smoke_cust_1', + type: 'customer.created', + data: { + object: { + id: 'cus_smoke_1', + email: 'smoke@example.com', + name: 'Smoke Test', + metadata: { userId: 'u_smoke_1' }, + }, + }, + }, + }); + const customer = await call(opts, 'get_customer', [q('cus_smoke_1')]); + if (!customer.includes('smoke@example.com')) { + throw new Error( + `customer.created did not produce expected row: ${customer}` + ); + } + + step( + 'ingest customer.subscription.created, expect subscription row + metadata' + ); + await ingest(opts, { + eventId: 'evt_smoke_sub_1', + eventType: 'customer.subscription.created', + payload: { + id: 'evt_smoke_sub_1', + type: 'customer.subscription.created', + data: { + object: { + id: 'sub_smoke_1', + customer: 'cus_smoke_1', + status: 'active', + current_period_end: 1735000000, + cancel_at_period_end: false, + items: { + data: [ + { + current_period_end: 1735000000, + quantity: 1, + price: { id: 'price_smoke_1' }, + }, + ], + }, + metadata: { userId: 'u_smoke_1', orgId: 'o_smoke_1' }, + }, + }, + }, + }); + const sub = await call(opts, 'get_subscription', [q('sub_smoke_1')]); + if (!sub.includes('"active"') || !sub.includes('o_smoke_1')) { + throw new Error( + `subscription.created did not produce expected row: ${sub}` + ); + } + + step('ingest checkout.session.completed, expect session row'); + await ingest(opts, { + eventId: 'evt_smoke_chk_1', + eventType: 'checkout.session.completed', + payload: { + id: 'evt_smoke_chk_1', + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_smoke_1', + mode: 'subscription', + customer: 'cus_smoke_1', + metadata: { userId: 'u_smoke_1' }, + }, + }, + }, + }); + const session = await call(opts, 'get_checkout_session', [q('cs_smoke_1')]); + if (!session.includes('"complete"') || !session.includes('subscription')) { + throw new Error( + `checkout.session.completed did not produce expected row: ${session}` + ); + } + + step('ingest invoice.created, expect invoice row'); + await ingest(opts, { + eventId: 'evt_smoke_inv_1', + eventType: 'invoice.created', + payload: { + id: 'evt_smoke_inv_1', + type: 'invoice.created', + data: { + object: { + id: 'in_smoke_1', + customer: 'cus_smoke_1', + subscription: 'sub_smoke_1', + status: 'open', + amount_due: 999, + amount_paid: 0, + created: 1735000000, + }, + }, + }, + }); + let invoice = await call(opts, 'list_invoices', [q('cus_smoke_1')]); + if (!invoice.includes('in_smoke_1')) { + throw new Error(`invoice.created did not produce row: ${invoice}`); + } + + step('ingest invoice.paid, expect status=paid + carry-over fields'); + await ingest(opts, { + eventId: 'evt_smoke_inv_2', + eventType: 'invoice.paid', + payload: { + id: 'evt_smoke_inv_2', + type: 'invoice.paid', + data: { + object: { + id: 'in_smoke_1', + customer: 'cus_smoke_1', + status: 'paid', + amount_paid: 999, + }, + }, + }, + }); + invoice = await call(opts, 'list_invoices', [q('cus_smoke_1')]); + if (!invoice.includes('"paid"')) { + throw new Error(`invoice.paid did not flip status: ${invoice}`); + } + + step('ingest payment_intent.succeeded standalone, expect payment row'); + await ingest(opts, { + eventId: 'evt_smoke_pay_1', + eventType: 'payment_intent.succeeded', + payload: { + id: 'evt_smoke_pay_1', + type: 'payment_intent.succeeded', + data: { + object: { + id: 'pi_smoke_1', + customer: 'cus_smoke_2', + amount: 1999, + currency: 'usd', + status: 'succeeded', + created: 1735000100, + metadata: { userId: 'u_smoke_2' }, + }, + }, + }, + }); + const payment = await call(opts, 'get_payment', [q('pi_smoke_1')]); + if (!payment.includes('1999') || !payment.includes('"usd"')) { + throw new Error(`payment_intent.succeeded did not produce row: ${payment}`); + } + + step('negative: replay unknown event_id, expect failure'); + const replayMissing = await expectCallFails(opts, 'replay_webhook_event', [ + q('evt_does_not_exist_xyz'), + ]); + if (!replayMissing.toLowerCase().includes('not_found')) { + throw new Error( + `expected not_found error; got: ${replayMissing.slice(0, 400)}` + ); + } + + step('verify idempotency: re-ingest evt_smoke_cust_1, row unchanged'); + await ingest(opts, { + eventId: 'evt_smoke_cust_1', + eventType: 'customer.created', + payload: { + id: 'evt_smoke_cust_1', + type: 'customer.created', + data: { object: { id: 'cus_smoke_1', email: 'CHANGED@example.com' } }, + }, + }); + const customerAfter = await call(opts, 'get_customer', [q('cus_smoke_1')]); + if (!customerAfter.includes('smoke@example.com')) { + throw new Error(`replay broke customer row: ${customerAfter}`); + } + + step('done: stripe smoke test passed'); +} + +main().catch(err => { + process.stderr.write( + `\nSMOKE TEST FAILED: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); +}); diff --git a/spacetime-stripe-ts/scripts/test-unit.ts b/spacetime-stripe-ts/scripts/test-unit.ts new file mode 100644 index 00000000000..1c6645a8cc0 --- /dev/null +++ b/spacetime-stripe-ts/scripts/test-unit.ts @@ -0,0 +1,100 @@ +import * as assert from 'node:assert/strict'; +import { buildStripeHttpRequest } from '../src/submodule/http.ts'; +import { parseStripeEventMetadata } from '../src/submodule/webhook-metadata.ts'; +import { + validateWebhookRequestBody, + validateWebhookRequestHeaders, +} from '../src/submodule/webhook-request.ts'; + +const customerEvent = { + id: 'evt_1', + type: 'customer.created', + livemode: false, + data: { + object: { + id: 'cus_1', + email: 'customer@example.com', + name: 'Customer', + metadata: { userId: 'user-1' }, + }, + }, +}; + +assert.deepEqual(parseStripeEventMetadata(JSON.stringify(customerEvent)), { + eventId: 'evt_1', + eventType: 'customer.created', + livemode: false, +}); +assert.equal(parseStripeEventMetadata('{bad json'), undefined); +assert.equal(parseStripeEventMetadata('{"id":"evt_1"}'), undefined); + +const request = buildStripeHttpRequest({ + method: 'post', + path: '/v1/customers', + secretKey: 'sk_test_secret', + stripeVersion: '2025-01-01', + formBody: 'email=user%40example.com', + idempotencyKey: 'customer-user-1', +}); +assert.equal(request.url, 'https://api.stripe.com/v1/customers'); +assert.equal(request.method, 'POST'); +assert.equal(request.headers.Authorization, 'Bearer sk_test_secret'); +assert.throws( + () => + buildStripeHttpRequest({ + method: 'GET', + path: 'https://attacker.example/collect', + secretKey: 'sk_test_secret', + stripeVersion: undefined, + formBody: undefined, + idempotencyKey: undefined, + }), + /stripe\.request_path_invalid/ +); +assert.throws( + () => + buildStripeHttpRequest({ + method: 'GET', + path: '/v1/customers\u007fblocked', + secretKey: 'sk_test_secret', + stripeVersion: undefined, + formBody: undefined, + idempotencyKey: undefined, + }), + /stripe\.request_path_invalid/ +); +assert.throws( + () => + buildStripeHttpRequest({ + method: 'TRACE', + path: '/v1/customers', + secretKey: 'sk_test_secret', + stripeVersion: undefined, + formBody: undefined, + idempotencyKey: undefined, + }), + /stripe\.request_method_invalid/ +); + +assert.deepEqual(validateWebhookRequestHeaders('GET', null, undefined), { + status: 405, + error: 'method not allowed', +}); +assert.equal( + validateWebhookRequestHeaders('POST', String(1024 * 1024 + 1), undefined) + ?.status, + 413 +); +assert.equal( + validateWebhookRequestHeaders('POST', null, 'x'.repeat(8193))?.status, + 431 +); +assert.equal(validateWebhookRequestHeaders('POST', null, 'short'), undefined); +assert.equal(validateWebhookRequestBody('')?.status, 400); +assert.equal( + validateWebhookRequestBody('x'.repeat(1024 * 1024 + 1))?.status, + 413 +); +assert.equal(validateWebhookRequestBody('{}'), undefined); + +console.log('stripe unit tests passed'); diff --git a/spacetime-stripe-ts/src/index.ts b/spacetime-stripe-ts/src/index.ts new file mode 100644 index 00000000000..db9d5fe318f --- /dev/null +++ b/spacetime-stripe-ts/src/index.ts @@ -0,0 +1,53 @@ +export { default, init } from './submodule/schema'; +export { + upsert_customer, + upsert_subscription, + update_payment_customer, + update_subscription_quantity_internal, + ingest_stripe_webhook, + replay_webhook_event, +} from './submodule/operations'; +export { + validate_stripe_price, + get_remote_checkout_session, + get_webhook_event_count, + stripe_api_request, + create_customer, + create_or_update_customer, + update_subscription_metadata, + get_or_create_customer, + create_checkout_session, + create_customer_portal_session, + cancel_subscription, + reactivate_subscription, + update_subscription_quantity, +} from './submodule/operations/billing'; +export { + get_customer, + get_customer_by_email, + get_customer_by_user_id, + get_subscription, + list_subscriptions, + list_subscriptions_with_creation_time, + get_subscription_by_org_id, + list_subscriptions_by_org_id, + list_subscriptions_by_user_id, + get_payment, + list_payments, + list_payments_by_user_id, + list_payments_by_org_id, + list_invoices, + list_invoices_by_org_id, + list_invoices_by_user_id, + get_checkout_session, + list_checkout_sessions, +} from './submodule/operations/queries'; +export { stripe_webhook_handler } from './submodule/operations/webhook'; + +export { + set_stripe_config, + set_stripe_webhook_signing_secret, + get_stripe_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; +export { stripeWebhookRouter } from './submodule/router'; diff --git a/spacetime-stripe-ts/src/submodule.ts b/spacetime-stripe-ts/src/submodule.ts new file mode 100644 index 00000000000..83ba1dc49b3 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule.ts @@ -0,0 +1,23 @@ +export { default } from './submodule/schema'; +export { installStripe } from './submodule/install'; +export { + upsert_customer, + upsert_subscription, + update_payment_customer, + update_subscription_quantity_internal, + ingest_stripe_webhook, + replay_webhook_event, +} from './submodule/operations'; +export * from './submodule/operations/billing'; +export * from './submodule/operations/queries'; +export { + handle_stripe_webhook, + stripe_webhook_handler, +} from './submodule/operations/webhook'; + +export { + set_stripe_config, + set_stripe_webhook_signing_secret, + get_stripe_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; diff --git a/spacetime-stripe-ts/src/submodule/auth.ts b/spacetime-stripe-ts/src/submodule/auth.ts new file mode 100644 index 00000000000..cdaa3e2dbad --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/auth.ts @@ -0,0 +1,79 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { throwSenderError } from './utils'; + +// Admin gate. Fresh publishes seed the owner via init. Public submodule calls +// never bootstrap admin state from "first caller wins". +type Sender = WriteCtx['sender']; +type ModuleTimestamp = WriteCtx['timestamp']; + +export type AdminVerdict = 'admin' | 'denied'; + +export function isAdmin(ctx: WriteCtx, sender: Sender): boolean { + return ctx.db.stripeAdminIdentity.identity.find(sender) != null; +} + +export function adminVerdict(ctx: WriteCtx, sender: Sender): AdminVerdict { + return isAdmin(ctx, sender) ? 'admin' : 'denied'; +} + +export function denyIfNotAdmin(verdict: AdminVerdict): void { + if (verdict === 'denied') throwSenderError('stripe.not_authorized'); +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError('stripe.not_authorized'); +} + +// For owner-gated repair/setup code only. Do not call from a public bootstrap path. +export function seedAdmin( + ctx: WriteCtx, + sender: Sender, + timestamp: ModuleTimestamp +) { + if (ctx.db.stripeAdminIdentity.identity.find(sender) != null) return; + ctx.db.stripeAdminIdentity.insert({ + identity: sender, + addedAtMicros: timestamp.microsSinceUnixEpoch, + }); +} + +export const add_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + if (tx.db.stripeAdminIdentity.identity.find(identity) == null) { + tx.db.stripeAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + }); + return {}; + } +); + +export const remove_admin_identity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + const existing = tx.db.stripeAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.stripeAdminIdentity.count() <= 1n) { + throwSenderError('stripe.cannot_remove_last_admin'); + } + tx.db.stripeAdminIdentity.delete(existing); + }); + return {}; + } +); diff --git a/spacetime-stripe-ts/src/submodule/config.ts b/spacetime-stripe-ts/src/submodule/config.ts new file mode 100644 index 00000000000..38fc0d211d8 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/config.ts @@ -0,0 +1,140 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { adminVerdict, denyIfNotAdmin } from './auth'; +import { throwSenderError } from './utils'; + +export type StripeConfig = { + secretKey: string; + stripeVersion: string | undefined; + webhookSigningSecret: string | undefined; +}; + +export function loadConfigOrThrow(ctx: WriteCtx): StripeConfig { + const row = ctx.db.stripeConfig.singleton.find(true); + if (!row) { + throwSenderError( + 'stripe.config_not_set: call set_stripe_config(...) first' + ); + } + return { + secretKey: row.secretKey, + stripeVersion: row.stripeVersion, + webhookSigningSecret: row.webhookSigningSecret, + }; +} + +export function loadConfigOrThrowFromProcedure( + ctx: ProcedureModuleCtx +): StripeConfig { + return ctx.withTx(tx => loadConfigOrThrow(tx)); +} + +function upsertConfig( + ctx: WriteCtx, + args: { + secretKey: string; + stripeVersion: string | undefined; + webhookSigningSecret: string | undefined; + } +) { + const existing = ctx.db.stripeConfig.singleton.find(true); + const row = { + singleton: true, + secretKey: args.secretKey, + stripeVersion: args.stripeVersion ?? existing?.stripeVersion, + webhookSigningSecret: + args.webhookSigningSecret ?? existing?.webhookSigningSecret, + updatedAt: ctx.timestamp, + }; + if (!existing) { + ctx.db.stripeConfig.insert(row); + return; + } + if (ctx.db.stripeConfig.singleton.update) { + ctx.db.stripeConfig.singleton.update(row); + } else { + ctx.db.stripeConfig.delete(existing); + ctx.db.stripeConfig.insert(row); + } +} + +export const set_stripe_config = spacetimedb.procedure( + { + secretKey: t.string(), + stripeVersion: t.option(t.string()), + webhookSigningSecret: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + upsertConfig(tx, { + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + webhookSigningSecret: args.webhookSigningSecret, + }); + }); + return {}; + } +); + +export const set_stripe_webhook_signing_secret = spacetimedb.procedure( + { webhookSigningSecret: t.string() }, + t.unit(), + (ctx, { webhookSigningSecret }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + const secret = webhookSigningSecret.trim(); + if (!secret) throwSenderError('stripe.invalid_webhook_signing_secret'); + ctx.withTx(tx => { + const existing = tx.db.stripeConfig.singleton.find(true); + if (!existing) { + throwSenderError( + 'stripe.config_not_set: call set_stripe_config(...) first' + ); + } + tx.db.stripeConfig.singleton.update({ + ...existing, + webhookSigningSecret: secret, + updatedAt: ctx.timestamp, + }); + }); + return {}; + } +); + +export const get_stripe_config_status = spacetimedb.procedure( + {}, + t.object('StripeConfigStatus', { + isConfigured: t.bool(), + hasWebhookSecret: t.bool(), + stripeVersion: t.option(t.string()), + secretKeyLength: t.u16(), + }), + ctx => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + return ctx.withTx(tx => { + const row = tx.db.stripeConfig.singleton.find(true); + if (!row) { + return { + isConfigured: false, + hasWebhookSecret: false, + stripeVersion: undefined, + secretKeyLength: 0, + }; + } + return { + isConfigured: true, + hasWebhookSecret: row.webhookSigningSecret !== undefined, + stripeVersion: row.stripeVersion, + secretKeyLength: row.secretKey.length, + }; + }); + } +); diff --git a/spacetime-stripe-ts/src/submodule/http.ts b/spacetime-stripe-ts/src/submodule/http.ts new file mode 100644 index 00000000000..3ce4ba8ca98 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/http.ts @@ -0,0 +1,79 @@ +const STRIPE_API_ORIGIN = 'https://api.stripe.com'; +const ALLOWED_METHODS = new Set(['GET', 'POST', 'DELETE']); +const MAX_PATH_LENGTH = 2048; +const MAX_FORM_BODY_LENGTH = 64 * 1024; +const MAX_IDEMPOTENCY_KEY_LENGTH = 255; + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +export type StripeHttpRequest = { + method: string; + url: string; + headers: Record; + body: string | undefined; +}; + +function validatePath(path: string): string { + const normalized = path.trim(); + if (!normalized.startsWith('/v1/')) { + throw new Error('stripe.request_path_invalid'); + } + if ( + normalized.startsWith('//') || + normalized.includes('\\') || + normalized.includes('#') + ) { + throw new Error('stripe.request_path_invalid'); + } + if (normalized.length > MAX_PATH_LENGTH || hasControlCharacter(normalized)) { + throw new Error('stripe.request_path_invalid'); + } + return normalized; +} + +export function buildStripeHttpRequest(args: { + method: string; + path: string; + secretKey: string; + stripeVersion: string | undefined; + formBody: string | undefined; + idempotencyKey: string | undefined; +}): StripeHttpRequest { + const method = args.method.trim().toUpperCase(); + if (!ALLOWED_METHODS.has(method)) { + throw new Error('stripe.request_method_invalid'); + } + + const path = validatePath(args.path); + const body = args.formBody?.length ? args.formBody : undefined; + if (body !== undefined && body.length > MAX_FORM_BODY_LENGTH) { + throw new Error('stripe.request_body_too_large'); + } + if ( + args.idempotencyKey && + args.idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH + ) { + throw new Error('stripe.idempotency_key_too_long'); + } + + const headers: Record = { + Authorization: `Bearer ${args.secretKey}`, + }; + if (args.stripeVersion) headers['Stripe-Version'] = args.stripeVersion; + if (args.idempotencyKey) headers['Idempotency-Key'] = args.idempotencyKey; + if (body !== undefined) + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + + return { + method, + url: `${STRIPE_API_ORIGIN}${path}`, + headers, + body, + }; +} diff --git a/spacetime-stripe-ts/src/submodule/install.ts b/spacetime-stripe-ts/src/submodule/install.ts new file mode 100644 index 00000000000..80e67faf4a8 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/install.ts @@ -0,0 +1,9 @@ +import type { ReducerModuleCtx } from './schema'; + +export function installStripe(ctx: ReducerModuleCtx) { + if (ctx.db.stripeAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.stripeAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-stripe-ts/src/submodule/limits.ts b/spacetime-stripe-ts/src/submodule/limits.ts new file mode 100644 index 00000000000..daa5fd01a6c --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/limits.ts @@ -0,0 +1,3 @@ +export const MAX_WEBHOOK_BODY_LENGTH = 1024 * 1024; +export const MAX_WEBHOOK_HEADER_LENGTH = 8192; +export const MAX_WEBHOOK_METADATA_LENGTH = 255; diff --git a/spacetime-stripe-ts/src/submodule/operations.ts b/spacetime-stripe-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..552432d964d --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations.ts @@ -0,0 +1,949 @@ +import { + SenderError, + t, + WebhookEventStatus, + type WebhookEventStatusValue, + spacetimedb, + vStripeEvent, + vStripeIdResponse, + extractExpandableId, + extractExpandableIdOrNull, + type ParsedStripeEvent, + type ProcedureModuleCtx, + type ReducerModuleCtx, + type TransactionModuleCtx, + type WriteCtx, + type JsonRecord, + type ModuleTimestamp, +} from './schema'; +import { verifyStripeSignature } from '@spacetimedb/crypto'; +import { adminVerdict, denyIfNotAdmin, requireAdmin } from './auth'; +import { parseStripeEventMetadata } from './webhook-metadata'; +import { buildStripeHttpRequest } from './http'; +import { + MAX_WEBHOOK_BODY_LENGTH, + MAX_WEBHOOK_HEADER_LENGTH, + MAX_WEBHOOK_METADATA_LENGTH, +} from './limits'; +import { + assertExhaustive, + attemptToParse, + safeJsonParse, + summarizeIssues, +} from './utils'; + +export function requireProcedureAdmin(ctx: ProcedureModuleCtx): void { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); +} + +export function withAdminTx( + ctx: ProcedureModuleCtx, + read: (tx: TransactionModuleCtx) => T +): T { + requireProcedureAdmin(ctx); + return ctx.withTx(read); +} + +const MAX_QUERY_ROWS = 1000; + +export function takeRows(rows: Iterable, limit = MAX_QUERY_ROWS): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +export function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null; +} + +export function maybeString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function maybeBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +export function maybeNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + +export function maybeInt(value: unknown): number | undefined { + const parsed = maybeNumber(value); + return parsed === undefined || !Number.isInteger(parsed) ? undefined : parsed; +} + +export function maybeBigIntFromUnknown(value: unknown): bigint | undefined { + const parsed = maybeInt(value); + return parsed === undefined ? undefined : BigInt(parsed); +} + +export function maybeId(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (!isRecord(value)) return undefined; + return maybeString(value.id); +} + +export function maybeJson(value: string): unknown | undefined { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +export function stripeErrorSuffix(body: string): string { + const parsed = maybeJson(body); + if (isRecord(parsed)) { + const errorPayload = isRecord(parsed.error) ? parsed.error : undefined; + if (errorPayload) { + const type = maybeString(errorPayload.type); + const code = maybeString(errorPayload.code); + const message = maybeString(errorPayload.message); + const requestLogUrl = maybeString(errorPayload.request_log_url); + const parts: string[] = []; + if (type) parts.push(`type=${type}`); + if (code) parts.push(`code=${code}`); + if (message) + parts.push(`msg=${message.replace(/\s+/g, ' ').slice(0, 240)}`); + if (requestLogUrl) parts.push(`log=${requestLogUrl}`); + if (parts.length > 0) return `:${parts.join('|')}`; + } + } + + const compact = body.replace(/\s+/g, ' ').trim(); + if (!compact) return ''; + return `:body=${compact.slice(0, 240)}`; +} + +export function toJsonString(value: unknown): string | undefined { + if (value === undefined) return undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +export function metadataInfo(metadata: unknown): { + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; +} { + if (!isRecord(metadata)) { + return { metadataJson: undefined, orgId: undefined, userId: undefined }; + } + return { + metadataJson: toJsonString(metadata), + orgId: maybeString(metadata.orgId), + userId: maybeString(metadata.userId), + }; +} + +export function coerceMetadataFromJson(metadataJson: string | undefined) { + if (!metadataJson) { + return { metadataJson: undefined, orgId: undefined, userId: undefined }; + } + const parsed = maybeJson(metadataJson); + const details = metadataInfo(parsed); + return { + metadataJson: details.metadataJson ?? metadataJson, + orgId: details.orgId, + userId: details.userId, + }; +} + +export function deriveCancelAtPeriodEnd( + cancelAtUnix: bigint | undefined, + currentPeriodEndUnix: bigint +): boolean { + if (cancelAtUnix === undefined || currentPeriodEndUnix <= 0n) return false; + const tolerance = 5n * 60n; + const delta = + cancelAtUnix > currentPeriodEndUnix + ? cancelAtUnix - currentPeriodEndUnix + : currentPeriodEndUnix - cancelAtUnix; + return delta <= tolerance; +} + +export function formPairsToBody( + pairs: Array<[string, string | undefined]> +): string { + const encoded: string[] = []; + for (const [key, value] of pairs) { + if (value === undefined) continue; + encoded.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + return encoded.join('&'); +} + +export function metadataJsonToFormPairs( + keyPrefix: string, + metadataJson: string | undefined +) { + if (!metadataJson) return [] as Array<[string, string]>; + const parsed = maybeJson(metadataJson); + if (!isRecord(parsed)) return [] as Array<[string, string]>; + + const out: Array<[string, string]> = []; + for (const [k, raw] of Object.entries(parsed)) { + if (raw === undefined || raw === null) continue; + out.push([`${keyPrefix}[${k}]`, String(raw)]); + } + return out; +} + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function upsertCustomer( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeCustomerId: string; + appUserId: string | undefined; + email: string | undefined; + name: string | undefined; + metadataJson: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripeCustomer.stripeCustomerId.find( + args.stripeCustomerId + ); + const row = { + stripeCustomerId: args.stripeCustomerId, + appUserId: args.appUserId ?? existing?.appUserId, + email: args.email ?? existing?.email, + name: args.name ?? existing?.name, + metadataJson: args.metadataJson ?? existing?.metadataJson, + userId: args.userId ?? existing?.userId, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeCustomer.insert(row); + return; + } + if (ctx.db.stripeCustomer.stripeCustomerId.update) { + ctx.db.stripeCustomer.stripeCustomerId.update(row); + } else { + ctx.db.stripeCustomer.delete(existing); + ctx.db.stripeCustomer.insert(row); + } +} + +export function upsertSubscription( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeSubscriptionId: string; + stripeCustomerId: string; + status: string; + currentPeriodEndUnix: bigint; + cancelAtPeriodEnd: boolean; + cancelAtUnix: bigint | undefined; + quantity: bigint | undefined; + priceId: string | undefined; + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripeSubscription.stripeSubscriptionId.find( + args.stripeSubscriptionId + ); + const row = { + stripeSubscriptionId: args.stripeSubscriptionId, + stripeCustomerId: args.stripeCustomerId, + status: args.status, + currentPeriodEndUnix: args.currentPeriodEndUnix, + cancelAtPeriodEnd: args.cancelAtPeriodEnd, + cancelAtUnix: args.cancelAtUnix, + quantity: args.quantity, + priceId: args.priceId ?? existing?.priceId, + metadataJson: args.metadataJson ?? existing?.metadataJson, + orgId: args.orgId ?? existing?.orgId, + userId: args.userId ?? existing?.userId, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeSubscription.insert(row); + return; + } + if (ctx.db.stripeSubscription.stripeSubscriptionId.update) { + ctx.db.stripeSubscription.stripeSubscriptionId.update(row); + } else { + ctx.db.stripeSubscription.delete(existing); + ctx.db.stripeSubscription.insert(row); + } +} + +export function upsertCheckoutSession( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeCheckoutSessionId: string; + stripeCustomerId: string | undefined; + status: string; + mode: string; + metadataJson: string | undefined; + } +) { + const existing = ctx.db.stripeCheckoutSession.stripeCheckoutSessionId.find( + args.stripeCheckoutSessionId + ); + const row = { + stripeCheckoutSessionId: args.stripeCheckoutSessionId, + stripeCustomerId: args.stripeCustomerId ?? existing?.stripeCustomerId, + status: args.status, + mode: args.mode, + metadataJson: args.metadataJson ?? existing?.metadataJson, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeCheckoutSession.insert(row); + return; + } + if (ctx.db.stripeCheckoutSession.stripeCheckoutSessionId.update) { + ctx.db.stripeCheckoutSession.stripeCheckoutSessionId.update(row); + } else { + ctx.db.stripeCheckoutSession.delete(existing); + ctx.db.stripeCheckoutSession.insert(row); + } +} + +export function upsertPayment( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripePaymentIntentId: string; + stripeCustomerId: string | undefined; + amount: bigint; + currency: string; + status: string; + createdUnix: bigint; + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripePayment.stripePaymentIntentId.find( + args.stripePaymentIntentId + ); + const row = { + stripePaymentIntentId: args.stripePaymentIntentId, + stripeCustomerId: args.stripeCustomerId ?? existing?.stripeCustomerId, + amount: args.amount, + currency: args.currency, + status: args.status, + createdUnix: args.createdUnix, + metadataJson: args.metadataJson ?? existing?.metadataJson, + orgId: args.orgId ?? existing?.orgId, + userId: args.userId ?? existing?.userId, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripePayment.insert(row); + return; + } + if (ctx.db.stripePayment.stripePaymentIntentId.update) { + ctx.db.stripePayment.stripePaymentIntentId.update(row); + } else { + ctx.db.stripePayment.delete(existing); + ctx.db.stripePayment.insert(row); + } +} + +export function upsertInvoice( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeInvoiceId: string; + stripeCustomerId: string; + stripeSubscriptionId: string | undefined; + status: string; + amountDue: bigint; + amountPaid: bigint; + createdUnix: bigint; + orgId: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripeInvoice.stripeInvoiceId.find( + args.stripeInvoiceId + ); + const row = { + stripeInvoiceId: args.stripeInvoiceId, + stripeCustomerId: args.stripeCustomerId, + stripeSubscriptionId: + args.stripeSubscriptionId ?? existing?.stripeSubscriptionId, + status: args.status, + amountDue: args.amountDue, + amountPaid: args.amountPaid, + createdUnix: args.createdUnix, + orgId: args.orgId ?? existing?.orgId, + userId: args.userId ?? existing?.userId, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeInvoice.insert(row); + return; + } + if (ctx.db.stripeInvoice.stripeInvoiceId.update) { + ctx.db.stripeInvoice.stripeInvoiceId.update(row); + } else { + ctx.db.stripeInvoice.delete(existing); + ctx.db.stripeInvoice.insert(row); + } +} + +export function updateWebhookStatus( + ctx: ReducerModuleCtx, + eventId: string, + status: WebhookEventStatusValue, + errorMessage: string | undefined +) { + const existing = ctx.db.stripeWebhookEvent.eventId.find(eventId); + if (!existing) return; + + const isTerminal = + status.tag === 'Processed' || + status.tag === 'Ignored' || + status.tag === 'Failed'; + const updated = { + ...existing, + status, + errorMessage, + processedAt: isTerminal ? ctx.timestamp : existing.processedAt, + }; + + if (ctx.db.stripeWebhookEvent.eventId.update) { + ctx.db.stripeWebhookEvent.eventId.update(updated); + } else { + ctx.db.stripeWebhookEvent.delete(existing); + ctx.db.stripeWebhookEvent.insert(updated); + } +} + +export function metadataInfoFromRecord( + metadata: Record | null | undefined +): { + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; +} { + if (!metadata) + return { metadataJson: undefined, orgId: undefined, userId: undefined }; + return { + metadataJson: toJsonString(metadata), + orgId: metadata.orgId, + userId: metadata.userId, + }; +} + +export function toBigIntOrZero(n: number | undefined | null): bigint { + return n === undefined || n === null ? 0n : BigInt(n); +} + +export function toBigIntOrUndefined( + n: number | undefined | null +): bigint | undefined { + return n === undefined || n === null ? undefined : BigInt(n); +} + +const HANDLED_EVENT_TYPES: ReadonlySet = new Set([ + 'customer.created', + 'customer.updated', + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'checkout.session.completed', + 'invoice.created', + 'invoice.finalized', + 'invoice.paid', + 'invoice.payment_succeeded', + 'invoice.payment_failed', + 'payment_intent.succeeded', +]); + +export function applyStripeEvent( + ctx: ReducerModuleCtx, + payloadJson: string +): { status: WebhookEventStatusValue; error: string | undefined } { + const parsedJson = safeJsonParse(payloadJson); + if (parsedJson === undefined) { + return { status: WebhookEventStatus.Failed, error: 'invalid JSON payload' }; + } + + const result = attemptToParse(vStripeEvent, parsedJson); + if (result.kind === 'error') { + // Distinguish unhandled type (ignore) from handled-but-malformed (fail). + const eventTypeRaw = + typeof parsedJson === 'object' && parsedJson !== null + ? (parsedJson as Record).type + : undefined; + const isHandledType = + typeof eventTypeRaw === 'string' && HANDLED_EVENT_TYPES.has(eventTypeRaw); + if (!isHandledType) { + return { status: WebhookEventStatus.Ignored, error: undefined }; + } + return { + status: WebhookEventStatus.Failed, + error: `payload validation failed: ${summarizeIssues(result.issues)}`, + }; + } + + return { status: dispatchEvent(ctx, result.data), error: undefined }; +} + +type ParsedInvoiceObject = Extract< + ParsedStripeEvent, + { type: 'invoice.paid' } +>['data']['object']; + +function syncInvoiceEvent( + ctx: ReducerModuleCtx, + obj: ParsedInvoiceObject, + status: string +): WebhookEventStatusValue { + const existing = ctx.db.stripeInvoice.stripeInvoiceId.find(obj.id); + const payloadCustomerId = extractExpandableIdOrNull(obj.customer); + const customerId = existing?.stripeCustomerId ?? payloadCustomerId; + if (customerId === null) return WebhookEventStatus.Failed; + const payloadSubscriptionId = + obj.subscription === undefined + ? undefined + : (extractExpandableIdOrNull(obj.subscription) ?? undefined); + const subscriptionId = + existing?.stripeSubscriptionId ?? payloadSubscriptionId; + const subscription = subscriptionId + ? ctx.db.stripeSubscription.stripeSubscriptionId.find(subscriptionId) + : undefined; + upsertInvoice(ctx, ctx.timestamp, { + stripeInvoiceId: obj.id, + stripeCustomerId: customerId, + stripeSubscriptionId: subscriptionId, + status, + amountDue: toBigIntOrUndefined(obj.amount_due) ?? existing?.amountDue ?? 0n, + amountPaid: + toBigIntOrUndefined(obj.amount_paid) ?? existing?.amountPaid ?? 0n, + createdUnix: + toBigIntOrUndefined(obj.created) ?? existing?.createdUnix ?? 0n, + orgId: existing?.orgId ?? subscription?.orgId, + userId: existing?.userId ?? subscription?.userId, + }); + return WebhookEventStatus.Processed; +} + +function dispatchEvent( + ctx: ReducerModuleCtx, + event: ParsedStripeEvent +): WebhookEventStatusValue { + switch (event.type) { + case 'customer.created': + case 'customer.updated': { + const obj = event.data.object; + const meta = metadataInfoFromRecord(obj.metadata); + upsertCustomer(ctx, ctx.timestamp, { + stripeCustomerId: obj.id, + appUserId: undefined, + email: obj.email ?? undefined, + name: obj.name ?? undefined, + metadataJson: meta.metadataJson, + userId: meta.userId, + }); + return WebhookEventStatus.Processed; + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const obj = event.data.object; + const status = + event.type === 'customer.subscription.deleted' + ? 'canceled' + : obj.status; + const customerId = extractExpandableId(obj.customer); + const firstItem = obj.items?.data[0]; + const currentPeriodEnd = + toBigIntOrUndefined(firstItem?.current_period_end) ?? + toBigIntOrUndefined(obj.current_period_end) ?? + 0n; + const cancelAtUnix = toBigIntOrUndefined(obj.cancel_at); + const cancelAtPeriodEnd = + obj.cancel_at_period_end ?? + deriveCancelAtPeriodEnd(cancelAtUnix, currentPeriodEnd); + const meta = metadataInfoFromRecord(obj.metadata); + upsertSubscription(ctx, ctx.timestamp, { + stripeSubscriptionId: obj.id, + stripeCustomerId: customerId, + status, + currentPeriodEndUnix: currentPeriodEnd, + cancelAtPeriodEnd, + cancelAtUnix, + quantity: toBigIntOrUndefined(firstItem?.quantity), + priceId: firstItem?.price?.id, + metadataJson: meta.metadataJson, + orgId: meta.orgId, + userId: meta.userId, + }); + return WebhookEventStatus.Processed; + } + case 'checkout.session.completed': { + const obj = event.data.object; + const meta = metadataInfoFromRecord(obj.metadata); + const customerId = + obj.customer === undefined + ? undefined + : (extractExpandableIdOrNull(obj.customer) ?? undefined); + upsertCheckoutSession(ctx, ctx.timestamp, { + stripeCheckoutSessionId: obj.id, + stripeCustomerId: customerId, + status: 'complete', + mode: obj.mode ?? 'payment', + metadataJson: meta.metadataJson, + }); + return WebhookEventStatus.Processed; + } + case 'invoice.created': + case 'invoice.finalized': { + const obj = event.data.object; + return syncInvoiceEvent(ctx, obj, obj.status ?? 'open'); + } + case 'invoice.paid': + case 'invoice.payment_succeeded': { + return syncInvoiceEvent(ctx, event.data.object, 'paid'); + } + case 'invoice.payment_failed': { + return syncInvoiceEvent(ctx, event.data.object, 'open'); + } + case 'payment_intent.succeeded': { + const obj = event.data.object; + // Invoice events own invoice-attached payment state. + const invoiceId = + obj.invoice === undefined + ? null + : extractExpandableIdOrNull(obj.invoice); + if (invoiceId !== null && invoiceId !== undefined) + return WebhookEventStatus.Ignored; + + const customerId = + obj.customer === undefined + ? null + : extractExpandableIdOrNull(obj.customer); + const meta = metadataInfoFromRecord(obj.metadata); + upsertPayment(ctx, ctx.timestamp, { + stripePaymentIntentId: obj.id, + stripeCustomerId: customerId ?? undefined, + amount: toBigIntOrZero(obj.amount), + currency: obj.currency ?? 'unknown', + status: obj.status ?? 'succeeded', + createdUnix: toBigIntOrZero(obj.created), + metadataJson: meta.metadataJson, + orgId: meta.orgId, + userId: meta.userId, + }); + return WebhookEventStatus.Processed; + } + default: + return assertExhaustive(event); + } +} + +export function callStripe( + ctx: ProcedureModuleCtx, + args: { + method: string; + path: string; + secretKey: string; + stripeVersion: string | undefined; + formBody: string | undefined; + idempotencyKey: string | undefined; + } +) { + let request; + try { + request = buildStripeHttpRequest(args); + } catch (error) { + throw new SenderError( + error instanceof Error ? error.message : 'stripe.request_invalid' + ); + } + const response = ctx.http.fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + }); + return { + status: response.status, + body: response.text(), + }; +} + +export function createCustomerInStripeAndSync( + ctx: ProcedureModuleCtx, + args: { + secretKey: string; + stripeVersion: string | undefined; + email: string | undefined; + name: string | undefined; + metadataJson: string | undefined; + idempotencyKey: string | undefined; + } +): string { + const formPairs: Array<[string, string | undefined]> = [ + ['email', args.email], + ['name', args.name], + ]; + for (const [k, v] of metadataJsonToFormPairs('metadata', args.metadataJson)) { + formPairs.push([k, v]); + } + + const result = callStripe(ctx, { + method: 'POST', + path: '/v1/customers', + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + idempotencyKey: args.idempotencyKey + ? `create_customer_${args.idempotencyKey}` + : undefined, + formBody: formPairsToBody(formPairs), + }); + if (result.status < 200 || result.status >= 300) { + throwSenderError( + `stripe.create_customer_failed:${result.status}${stripeErrorSuffix(result.body)}` + ); + } + + const parsedBody = safeJsonParse(result.body); + const idResult = attemptToParse(vStripeIdResponse, parsedBody); + if (idResult.kind === 'error') { + throwSenderError( + `stripe.create_customer_invalid_response:${summarizeIssues(idResult.issues)}` + ); + } + const customerId = idResult.data.id; + + const details = coerceMetadataFromJson(args.metadataJson); + ctx.withTx(tx => { + upsertCustomer(tx, ctx.timestamp, { + stripeCustomerId: customerId, + appUserId: undefined, + email: args.email, + name: args.name, + metadataJson: details.metadataJson, + userId: details.userId, + }); + }); + return customerId; +} + +export const upsert_customer = spacetimedb.reducer( + { + stripeCustomerId: t.string(), + appUserId: t.option(t.string()), + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + userId: t.option(t.string()), + }, + (ctx, args) => { + requireAdmin(ctx, ctx.sender); + upsertCustomer(ctx, ctx.timestamp, { + stripeCustomerId: args.stripeCustomerId, + appUserId: args.appUserId, + email: args.email, + name: args.name, + metadataJson: args.metadataJson, + userId: args.userId, + }); + } +); + +export const upsert_subscription = spacetimedb.reducer( + { + stripeSubscriptionId: t.string(), + stripeCustomerId: t.string(), + status: t.string(), + currentPeriodEndUnix: t.i64(), + cancelAtPeriodEnd: t.bool(), + cancelAtUnix: t.option(t.i64()), + quantity: t.option(t.i64()), + priceId: t.option(t.string()), + metadataJson: t.option(t.string()), + orgId: t.option(t.string()), + userId: t.option(t.string()), + }, + (ctx, args) => { + requireAdmin(ctx, ctx.sender); + upsertSubscription(ctx, ctx.timestamp, { + stripeSubscriptionId: args.stripeSubscriptionId, + stripeCustomerId: args.stripeCustomerId, + status: args.status, + currentPeriodEndUnix: args.currentPeriodEndUnix, + cancelAtPeriodEnd: args.cancelAtPeriodEnd, + cancelAtUnix: args.cancelAtUnix, + quantity: args.quantity, + priceId: args.priceId, + metadataJson: args.metadataJson, + orgId: args.orgId, + userId: args.userId, + }); + } +); + +export const update_payment_customer = spacetimedb.reducer( + { + stripePaymentIntentId: t.string(), + stripeCustomerId: t.string(), + }, + (ctx, { stripePaymentIntentId, stripeCustomerId }) => { + requireAdmin(ctx, ctx.sender); + const existing = ctx.db.stripePayment.stripePaymentIntentId.find( + stripePaymentIntentId + ); + if (!existing || existing.stripeCustomerId) return; + upsertPayment(ctx, ctx.timestamp, { + stripePaymentIntentId: existing.stripePaymentIntentId, + stripeCustomerId, + amount: existing.amount, + currency: existing.currency, + status: existing.status, + createdUnix: existing.createdUnix, + metadataJson: existing.metadataJson, + orgId: existing.orgId, + userId: existing.userId, + }); + } +); + +export const update_subscription_quantity_internal = spacetimedb.reducer( + { + stripeSubscriptionId: t.string(), + quantity: t.i64(), + }, + (ctx, { stripeSubscriptionId, quantity }) => { + requireAdmin(ctx, ctx.sender); + const existing = + ctx.db.stripeSubscription.stripeSubscriptionId.find(stripeSubscriptionId); + if (!existing) return; + upsertSubscription(ctx, ctx.timestamp, { + stripeSubscriptionId: existing.stripeSubscriptionId, + stripeCustomerId: existing.stripeCustomerId, + status: existing.status, + currentPeriodEndUnix: existing.currentPeriodEndUnix, + cancelAtPeriodEnd: existing.cancelAtPeriodEnd, + cancelAtUnix: existing.cancelAtUnix, + quantity, + priceId: existing.priceId, + metadataJson: existing.metadataJson, + orgId: existing.orgId, + userId: existing.userId, + }); + } +); + +export const ingest_stripe_webhook = spacetimedb.reducer( + { + eventId: t.string(), + eventType: t.string(), + livemode: t.bool(), + payloadJson: t.string(), + signatureHeader: t.option(t.string()), + }, + (ctx, { eventId, eventType, livemode, payloadJson, signatureHeader }) => { + if ( + eventId.length === 0 || + eventId.length > MAX_WEBHOOK_METADATA_LENGTH || + eventType.length === 0 || + eventType.length > MAX_WEBHOOK_METADATA_LENGTH + ) { + throwSenderError('stripe.webhook_metadata_invalid'); + } + if (payloadJson.length > MAX_WEBHOOK_BODY_LENGTH) { + throwSenderError('stripe.webhook_payload_too_large'); + } + if ((signatureHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH) { + throwSenderError('stripe.webhook_signature_too_large'); + } + + const cfg = ctx.db.stripeConfig.singleton.find(true); + if (!cfg?.webhookSigningSecret) { + throwSenderError('stripe.webhook_secret_not_configured'); + } + const nowSeconds = Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n); + const sigOk = verifyStripeSignature({ + rawBody: payloadJson, + signatureHeader: signatureHeader ?? '', + secret: cfg.webhookSigningSecret, + nowSeconds, + }); + if (!sigOk) throwSenderError('stripe.webhook_signature_mismatch'); + + const signedMetadata = parseStripeEventMetadata(payloadJson); + if (!signedMetadata) + throwSenderError('stripe.webhook_payload_missing_metadata'); + if ( + eventId !== signedMetadata.eventId || + eventType !== signedMetadata.eventType || + livemode !== signedMetadata.livemode + ) { + throwSenderError('stripe.webhook_metadata_mismatch'); + } + + const existing = ctx.db.stripeWebhookEvent.eventId.find( + signedMetadata.eventId + ); + if (existing) return; + + ctx.db.stripeWebhookEvent.insert({ + eventId: signedMetadata.eventId, + eventType: signedMetadata.eventType, + livemode: signedMetadata.livemode, + signatureHeader, + payloadJson, + status: WebhookEventStatus.Received, + errorMessage: undefined, + receivedAt: ctx.timestamp, + processedAt: undefined, + }); + + const outcome = applyStripeEvent(ctx, payloadJson); + updateWebhookStatus( + ctx, + signedMetadata.eventId, + outcome.status, + outcome.error + ); + } +); + +export const replay_webhook_event = spacetimedb.reducer( + { eventId: t.string() }, + (ctx, { eventId }) => { + // Administrators may run this operation over stored events. + requireAdmin(ctx, ctx.sender); + const event = ctx.db.stripeWebhookEvent.eventId.find(eventId); + if (!event) throwSenderError(`stripe.webhook_event_not_found:${eventId}`); + const outcome = applyStripeEvent(ctx, event.payloadJson); + updateWebhookStatus(ctx, eventId, outcome.status, outcome.error); + } +); + +// Validate a Stripe price ID by hitting GET /v1/prices/:id with the module's stored secret. diff --git a/spacetime-stripe-ts/src/submodule/operations/billing.ts b/spacetime-stripe-ts/src/submodule/operations/billing.ts new file mode 100644 index 00000000000..63a8495cafa --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations/billing.ts @@ -0,0 +1,676 @@ +import { + t, + spacetimedb, + stripeHttpResponse, + checkoutSessionResult, + createCustomerResult, + getOrCreateCustomerResult, + portalSessionResult, + vStripeCheckoutSessionResponse, + vStripeBillingPortalSessionResponse, + type ProcedureModuleCtx, + type JsonRecord, +} from '../schema'; +import { loadConfigOrThrowFromProcedure } from '../config'; +import { adminVerdict, denyIfNotAdmin } from '../auth'; +import { attemptToParse, safeJsonParse, summarizeIssues } from '../utils'; + +import { + requireProcedureAdmin, + withAdminTx, + isRecord, + maybeString, + maybeBoolean, + maybeBigIntFromUnknown, + maybeId, + maybeJson, + stripeErrorSuffix, + metadataInfo, + coerceMetadataFromJson, + deriveCancelAtPeriodEnd, + formPairsToBody, + metadataJsonToFormPairs, + throwSenderError, + upsertCustomer, + upsertSubscription, + callStripe, + createCustomerInStripeAndSync, +} from '../operations'; + +export const validate_stripe_price = spacetimedb.procedure( + { priceId: t.string() }, + t.object('ValidateStripePriceResult', { + valid: t.bool(), + status: t.u16(), + active: t.option(t.bool()), + currency: t.option(t.string()), + unitAmount: t.option(t.i64()), + livemode: t.option(t.bool()), + type: t.option(t.string()), + message: t.option(t.string()), + code: t.option(t.string()), + errorType: t.option(t.string()), + }), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callStripe(ctx, { + method: 'GET', + path: `/v1/prices/${args.priceId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + const parsed = safeJsonParse(response.body); + const isOk = response.status >= 200 && response.status < 300; + if (!isOk) { + const err = + isRecord(parsed) && isRecord(parsed.error) ? parsed.error : undefined; + return { + valid: false, + status: response.status, + active: undefined, + currency: undefined, + unitAmount: undefined, + livemode: undefined, + type: undefined, + message: + maybeString(err?.message) ?? `Stripe returned ${response.status}.`, + code: maybeString(err?.code), + errorType: maybeString(err?.type), + }; + } + const data = isRecord(parsed) ? parsed : {}; + return { + valid: true, + status: response.status, + active: maybeBoolean(data.active), + currency: maybeString(data.currency), + unitAmount: maybeBigIntFromUnknown(data.unit_amount), + livemode: maybeBoolean(data.livemode), + type: maybeString(data.type), + message: undefined, + code: undefined, + errorType: undefined, + }; + } +); + +// Fetch a Stripe checkout session by id. +export const get_remote_checkout_session = spacetimedb.procedure( + { sessionId: t.string() }, + t.object('RemoteCheckoutSessionResult', { + ok: t.bool(), + status: t.u16(), + sessionId: t.option(t.string()), + paymentStatus: t.option(t.string()), + sessionStatus: t.option(t.string()), + mode: t.option(t.string()), + amountTotal: t.option(t.i64()), + currency: t.option(t.string()), + customerId: t.option(t.string()), + paymentIntentId: t.option(t.string()), + message: t.option(t.string()), + code: t.option(t.string()), + errorType: t.option(t.string()), + }), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callStripe(ctx, { + method: 'GET', + path: `/v1/checkout/sessions/${args.sessionId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + const parsed = safeJsonParse(response.body); + const isOk = response.status >= 200 && response.status < 300; + if (!isOk) { + const err = + isRecord(parsed) && isRecord(parsed.error) ? parsed.error : undefined; + return { + ok: false, + status: response.status, + sessionId: undefined, + paymentStatus: undefined, + sessionStatus: undefined, + mode: undefined, + amountTotal: undefined, + currency: undefined, + customerId: undefined, + paymentIntentId: undefined, + message: + maybeString(err?.message) ?? `Stripe returned ${response.status}.`, + code: maybeString(err?.code), + errorType: maybeString(err?.type), + }; + } + const data = isRecord(parsed) ? parsed : {}; + return { + ok: true, + status: response.status, + sessionId: maybeString(data.id) ?? args.sessionId, + paymentStatus: maybeString(data.payment_status), + sessionStatus: maybeString(data.status), + mode: maybeString(data.mode), + amountTotal: maybeBigIntFromUnknown(data.amount_total), + currency: maybeString(data.currency), + customerId: maybeId(data.customer), + paymentIntentId: maybeId(data.payment_intent), + message: undefined, + code: undefined, + errorType: undefined, + }; + } +); + +// Cheap count of stripe_webhook_event rows; exposes the metric without leaking payloads. +export const get_webhook_event_count = spacetimedb.procedure({}, t.i64(), ctx => + withAdminTx(ctx, tx => BigInt(tx.db.stripeWebhookEvent.count())) +); + +export const stripe_api_request = spacetimedb.procedure( + { + method: t.string(), + path: t.string(), + formBody: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + stripeHttpResponse, + (ctx, args) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + const cfg = loadConfigOrThrowFromProcedure(ctx); + return callStripe(ctx, { + method: args.method, + path: args.path, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + formBody: args.formBody, + idempotencyKey: args.idempotencyKey, + }); + } +); + +export const create_customer = spacetimedb.procedure( + { + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + createCustomerResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const customerId = createCustomerInStripeAndSync(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + email: args.email, + name: args.name, + metadataJson: args.metadataJson, + idempotencyKey: args.idempotencyKey, + }); + return { customerId }; + } +); + +export const create_or_update_customer = spacetimedb.procedure( + { + stripeCustomerId: t.string(), + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + }, + t.string(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const details = coerceMetadataFromJson(args.metadataJson); + ctx.withTx(tx => { + upsertCustomer(tx, ctx.timestamp, { + stripeCustomerId: args.stripeCustomerId, + appUserId: undefined, + email: args.email, + name: args.name, + metadataJson: details.metadataJson, + userId: details.userId, + }); + }); + return args.stripeCustomerId; + } +); + +export const update_subscription_metadata = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + metadataJson: t.string(), + orgId: t.option(t.string()), + userId: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const parsedDetails = coerceMetadataFromJson(args.metadataJson); + ctx.withTx(tx => { + const existing = tx.db.stripeSubscription.stripeSubscriptionId.find( + args.stripeSubscriptionId + ); + if (!existing) { + throwSenderError( + `stripe.subscription_not_found:${args.stripeSubscriptionId}` + ); + } + upsertSubscription(tx, ctx.timestamp, { + stripeSubscriptionId: existing.stripeSubscriptionId, + stripeCustomerId: existing.stripeCustomerId, + status: existing.status, + currentPeriodEndUnix: existing.currentPeriodEndUnix, + cancelAtPeriodEnd: existing.cancelAtPeriodEnd, + cancelAtUnix: existing.cancelAtUnix, + quantity: existing.quantity, + priceId: existing.priceId, + metadataJson: parsedDetails.metadataJson ?? args.metadataJson, + orgId: args.orgId ?? parsedDetails.orgId ?? existing.orgId, + userId: args.userId ?? parsedDetails.userId ?? existing.userId, + }); + }); + return {}; + } +); +export const get_or_create_customer = spacetimedb.procedure( + { + userId: t.string(), + email: t.option(t.string()), + name: t.option(t.string()), + }, + getOrCreateCustomerResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const existingByUser = ctx.withTx(tx => { + for (const customer of tx.db.stripeCustomer.byUserId.filter(args.userId)) + return customer; + return undefined; + }); + if (existingByUser) { + return { customerId: existingByUser.stripeCustomerId, isNew: false }; + } + + if (args.email) { + const existingByEmail = ctx.withTx(tx => { + for (const customer of tx.db.stripeCustomer.byEmail.filter(args.email)) + return customer; + return undefined; + }); + if (existingByEmail) { + return { customerId: existingByEmail.stripeCustomerId, isNew: false }; + } + } + + const existingSub = ctx.withTx(tx => { + for (const sub of tx.db.stripeSubscription.byUserId.filter(args.userId)) + return sub; + return undefined; + }); + if (existingSub) { + return { customerId: existingSub.stripeCustomerId, isNew: false }; + } + + const existingPayment = ctx.withTx(tx => { + for (const payment of tx.db.stripePayment.byUserId.filter(args.userId)) { + if (payment.userId === args.userId && payment.stripeCustomerId) + return payment; + } + return undefined; + }); + if (existingPayment?.stripeCustomerId) { + return { customerId: existingPayment.stripeCustomerId, isNew: false }; + } + + const cfg = loadConfigOrThrowFromProcedure(ctx); + const metadataJson = JSON.stringify({ userId: args.userId }); + const customerId = createCustomerInStripeAndSync(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + email: args.email, + name: args.name, + metadataJson, + idempotencyKey: args.userId, + }); + return { customerId, isNew: true }; + } +); + +// Stripe enforces one mode per session; all items must share mode. +export const create_checkout_session = spacetimedb.procedure( + { + items: t.array( + t.object('CheckoutLineItem', { + priceId: t.string(), + quantity: t.i64(), + }) + ), + customerId: t.option(t.string()), + mode: t.string(), + successUrl: t.string(), + cancelUrl: t.string(), + metadataJson: t.option(t.string()), + subscriptionMetadataJson: t.option(t.string()), + paymentIntentMetadataJson: t.option(t.string()), + }, + checkoutSessionResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + if (args.items.length === 0) { + throwSenderError('stripe.checkout_session_requires_items'); + } + const formPairs: Array<[string, string | undefined]> = [ + ['mode', args.mode], + ['success_url', args.successUrl], + ['cancel_url', args.cancelUrl], + ['customer', args.customerId], + ]; + args.items.forEach((item, i) => { + formPairs.push([`line_items[${i}][price]`, item.priceId]); + formPairs.push([`line_items[${i}][quantity]`, String(item.quantity)]); + }); + for (const [k, v] of metadataJsonToFormPairs( + 'metadata', + args.metadataJson + )) { + formPairs.push([k, v]); + } + if (args.mode === 'subscription') { + for (const [k, v] of metadataJsonToFormPairs( + 'subscription_data[metadata]', + args.subscriptionMetadataJson + )) { + formPairs.push([k, v]); + } + } + if (args.mode === 'payment') { + for (const [k, v] of metadataJsonToFormPairs( + 'payment_intent_data[metadata]', + args.paymentIntentMetadataJson + )) { + formPairs.push([k, v]); + } + } + + const response = callStripe(ctx, { + method: 'POST', + path: '/v1/checkout/sessions', + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: formPairsToBody(formPairs), + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.checkout_session_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + + const sessionResult = attemptToParse( + vStripeCheckoutSessionResponse, + safeJsonParse(response.body) + ); + if (sessionResult.kind === 'error') { + throwSenderError( + `stripe.checkout_session_invalid_response:${summarizeIssues(sessionResult.issues)}` + ); + } + return { + sessionId: sessionResult.data.id, + url: sessionResult.data.url ?? undefined, + }; + } +); + +export const create_customer_portal_session = spacetimedb.procedure( + { + customerId: t.string(), + returnUrl: t.string(), + }, + portalSessionResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callStripe(ctx, { + method: 'POST', + path: '/v1/billing_portal/sessions', + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: formPairsToBody([ + ['customer', args.customerId], + ['return_url', args.returnUrl], + ]), + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.portal_session_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + + const portalResult = attemptToParse( + vStripeBillingPortalSessionResponse, + safeJsonParse(response.body) + ); + if (portalResult.kind === 'error') { + throwSenderError( + `stripe.portal_session_invalid_response:${summarizeIssues(portalResult.issues)}` + ); + } + return { url: portalResult.data.url }; + } +); + +function patchSubscriptionFromStripe( + ctx: ProcedureModuleCtx, + args: { + secretKey: string; + stripeVersion: string | undefined; + stripeSubscriptionId: string; + formBody: string; + } +) { + const response = callStripe(ctx, { + method: 'POST', + path: `/v1/subscriptions/${args.stripeSubscriptionId}`, + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + idempotencyKey: undefined, + formBody: args.formBody, + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.subscription_update_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + const parsed = maybeJson(response.body); + if (!isRecord(parsed)) + throwSenderError('stripe.subscription_update_invalid_response'); + return parsed; +} + +function syncSubscriptionObjectFromStripe( + ctx: ProcedureModuleCtx, + stripeSubscription: JsonRecord +) { + const subscriptionId = maybeString(stripeSubscription.id); + const customerId = maybeId(stripeSubscription.customer); + const status = maybeString(stripeSubscription.status); + if (!subscriptionId || !customerId || !status) { + throwSenderError('stripe.subscription_payload_missing_fields'); + } + + const items = isRecord(stripeSubscription.items) + ? stripeSubscription.items + : undefined; + const firstItem = Array.isArray(items?.data) ? items.data[0] : undefined; + const first = isRecord(firstItem) ? firstItem : undefined; + const price = isRecord(first?.price) ? first.price : undefined; + + const currentPeriodEnd = + maybeBigIntFromUnknown(first?.current_period_end) ?? + maybeBigIntFromUnknown(stripeSubscription.current_period_end) ?? + 0n; + const cancelAtUnix = maybeBigIntFromUnknown(stripeSubscription.cancel_at); + const cancelAtPeriodEnd = + maybeBoolean(stripeSubscription.cancel_at_period_end) ?? + deriveCancelAtPeriodEnd(cancelAtUnix, currentPeriodEnd); + const quantity = maybeBigIntFromUnknown(first?.quantity); + const meta = metadataInfo(stripeSubscription.metadata); + + ctx.withTx(tx => { + upsertSubscription(tx, ctx.timestamp, { + stripeSubscriptionId: subscriptionId, + stripeCustomerId: customerId, + status, + currentPeriodEndUnix: currentPeriodEnd, + cancelAtPeriodEnd, + cancelAtUnix, + quantity, + priceId: maybeString(price?.id), + metadataJson: meta.metadataJson, + orgId: meta.orgId, + userId: meta.userId, + }); + }); +} + +export const cancel_subscription = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + cancelAtPeriodEnd: t.option(t.bool()), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const atPeriodEnd = args.cancelAtPeriodEnd ?? true; + const stripeSubscription = atPeriodEnd + ? patchSubscriptionFromStripe(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + stripeSubscriptionId: args.stripeSubscriptionId, + formBody: formPairsToBody([['cancel_at_period_end', 'true']]), + }) + : (() => { + const response = callStripe(ctx, { + method: 'DELETE', + path: `/v1/subscriptions/${args.stripeSubscriptionId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.subscription_cancel_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + const parsed = maybeJson(response.body); + if (!isRecord(parsed)) { + throwSenderError('stripe.subscription_cancel_invalid_response'); + } + return parsed; + })(); + + syncSubscriptionObjectFromStripe(ctx, stripeSubscription); + return {}; + } +); + +export const reactivate_subscription = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const stripeSubscription = patchSubscriptionFromStripe(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + stripeSubscriptionId: args.stripeSubscriptionId, + formBody: formPairsToBody([['cancel_at_period_end', 'false']]), + }); + syncSubscriptionObjectFromStripe(ctx, stripeSubscription); + return {}; + } +); + +export const update_subscription_quantity = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + quantity: t.i64(), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const getResponse = callStripe(ctx, { + method: 'GET', + path: `/v1/subscriptions/${args.stripeSubscriptionId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + if (getResponse.status < 200 || getResponse.status >= 300) { + throwSenderError( + `stripe.subscription_lookup_failed:${getResponse.status}${stripeErrorSuffix(getResponse.body)}` + ); + } + + const existing = maybeJson(getResponse.body); + if (!isRecord(existing)) + throwSenderError('stripe.subscription_lookup_invalid_response'); + const items = isRecord(existing.items) ? existing.items : undefined; + const firstItem = Array.isArray(items?.data) ? items.data[0] : undefined; + const firstRecord = isRecord(firstItem) ? firstItem : undefined; + const subscriptionItemId = maybeString(firstRecord?.id); + if (!subscriptionItemId) + throwSenderError('stripe.subscription_missing_line_items'); + + const updateResponse = callStripe(ctx, { + method: 'POST', + path: `/v1/subscription_items/${subscriptionItemId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: formPairsToBody([['quantity', String(args.quantity)]]), + }); + if (updateResponse.status < 200 || updateResponse.status >= 300) { + throwSenderError( + `stripe.subscription_item_update_failed:${updateResponse.status}${stripeErrorSuffix(updateResponse.body)}` + ); + } + + ctx.withTx(tx => { + const localSub = tx.db.stripeSubscription.stripeSubscriptionId.find( + args.stripeSubscriptionId + ); + if (!localSub) return; + upsertSubscription(tx, ctx.timestamp, { + stripeSubscriptionId: localSub.stripeSubscriptionId, + stripeCustomerId: localSub.stripeCustomerId, + status: localSub.status, + currentPeriodEndUnix: localSub.currentPeriodEndUnix, + cancelAtPeriodEnd: localSub.cancelAtPeriodEnd, + cancelAtUnix: localSub.cancelAtUnix, + quantity: args.quantity, + priceId: localSub.priceId, + metadataJson: localSub.metadataJson, + orgId: localSub.orgId, + userId: localSub.userId, + }); + }); + return {}; + } +); diff --git a/spacetime-stripe-ts/src/submodule/operations/queries.ts b/spacetime-stripe-ts/src/submodule/operations/queries.ts new file mode 100644 index 00000000000..83df7cebec7 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations/queries.ts @@ -0,0 +1,222 @@ +import { + Range, + t, + spacetimedb, + stripeCustomerTable, + stripeSubscriptionTable, + stripeCheckoutSessionTable, + stripePaymentTable, + stripeInvoiceTable, + subscriptionWithCreationTime, +} from '../schema'; +import { withAdminTx, takeRows } from '../operations'; + +export const get_customer = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.option(stripeCustomerTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx( + ctx, + tx => + tx.db.stripeCustomer.stripeCustomerId.find(stripeCustomerId) ?? + undefined + ) +); + +export const get_customer_by_email = spacetimedb.procedure( + { email: t.string() }, + t.option(stripeCustomerTable.rowType), + (ctx, { email }) => + withAdminTx(ctx, tx => { + for (const customer of tx.db.stripeCustomer.byEmail.filter(email)) + return customer; + return undefined; + }) +); + +export const get_customer_by_user_id = spacetimedb.procedure( + { userId: t.string() }, + t.option(stripeCustomerTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => { + for (const customer of tx.db.stripeCustomer.byUserId.filter(userId)) + return customer; + return undefined; + }) +); + +export const get_subscription = spacetimedb.procedure( + { stripeSubscriptionId: t.string() }, + t.option(stripeSubscriptionTable.rowType), + (ctx, { stripeSubscriptionId }) => + withAdminTx( + ctx, + tx => + tx.db.stripeSubscription.stripeSubscriptionId.find( + stripeSubscriptionId + ) ?? undefined + ) +); + +export const list_subscriptions = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripeSubscriptionTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeSubscription.byCustomer.filter(stripeCustomerId)) + ) +); + +export const list_subscriptions_with_creation_time = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(subscriptionWithCreationTime), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows( + tx.db.stripeSubscription.byCustomerInsertedAt.filter([ + stripeCustomerId, + new Range(), + ]) + ).map(sub => ({ + insertedAtMicros: sub.insertedAt.microsSinceUnixEpoch, + stripeSubscriptionId: sub.stripeSubscriptionId, + stripeCustomerId: sub.stripeCustomerId, + status: sub.status, + })) + ) +); + +export const get_subscription_by_org_id = spacetimedb.procedure( + { orgId: t.string() }, + t.option(stripeSubscriptionTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => { + const matches = takeRows( + tx.db.stripeSubscription.byOrgInsertedAt.filter([orgId, new Range()]), + 5000 + ); + let latest = matches[0]; + for (const sub of matches) { + if (!latest) { + latest = sub; + continue; + } + const currentMicros = sub.insertedAt.microsSinceUnixEpoch; + const latestMicros = latest.insertedAt.microsSinceUnixEpoch; + if ( + currentMicros > latestMicros || + (currentMicros === latestMicros && + sub.stripeSubscriptionId > latest.stripeSubscriptionId) + ) { + latest = sub; + } + } + return latest; + }) +); + +export const list_subscriptions_by_org_id = spacetimedb.procedure( + { orgId: t.string() }, + t.array(stripeSubscriptionTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => + takeRows( + tx.db.stripeSubscription.byOrgInsertedAt.filter([orgId, new Range()]) + ) + ) +); + +export const list_subscriptions_by_user_id = spacetimedb.procedure( + { userId: t.string() }, + t.array(stripeSubscriptionTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => + takeRows( + tx.db.stripeSubscription.byUserInsertedAt.filter([userId, new Range()]) + ) + ) +); + +export const get_payment = spacetimedb.procedure( + { stripePaymentIntentId: t.string() }, + t.option(stripePaymentTable.rowType), + (ctx, { stripePaymentIntentId }) => + withAdminTx( + ctx, + tx => + tx.db.stripePayment.stripePaymentIntentId.find(stripePaymentIntentId) ?? + undefined + ) +); + +export const list_payments = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripePaymentTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripePayment.byCustomer.filter(stripeCustomerId)) + ) +); + +export const list_payments_by_user_id = spacetimedb.procedure( + { userId: t.string() }, + t.array(stripePaymentTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripePayment.byUserId.filter(userId)) + ) +); + +export const list_payments_by_org_id = spacetimedb.procedure( + { orgId: t.string() }, + t.array(stripePaymentTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => takeRows(tx.db.stripePayment.byOrgId.filter(orgId))) +); + +export const list_invoices = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripeInvoiceTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeInvoice.byCustomer.filter(stripeCustomerId)) + ) +); + +export const list_invoices_by_org_id = spacetimedb.procedure( + { orgId: t.string() }, + t.array(stripeInvoiceTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => takeRows(tx.db.stripeInvoice.byOrgId.filter(orgId))) +); + +export const list_invoices_by_user_id = spacetimedb.procedure( + { userId: t.string() }, + t.array(stripeInvoiceTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeInvoice.byUserId.filter(userId)) + ) +); + +export const get_checkout_session = spacetimedb.procedure( + { stripeCheckoutSessionId: t.string() }, + t.option(stripeCheckoutSessionTable.rowType), + (ctx, { stripeCheckoutSessionId }) => + withAdminTx( + ctx, + tx => + tx.db.stripeCheckoutSession.stripeCheckoutSessionId.find( + stripeCheckoutSessionId + ) ?? undefined + ) +); + +export const list_checkout_sessions = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripeCheckoutSessionTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeCheckoutSession.byCustomer.filter(stripeCustomerId)) + ) +); diff --git a/spacetime-stripe-ts/src/submodule/operations/webhook.ts b/spacetime-stripe-ts/src/submodule/operations/webhook.ts new file mode 100644 index 00000000000..98e82d7ca94 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations/webhook.ts @@ -0,0 +1,111 @@ +import { WebhookEventStatus, spacetimedb } from '../schema'; +import { + SyncResponse, + type HandlerContext, + type Request as StdbRequest, +} from 'spacetimedb/server'; +import { verifyStripeSignature } from '@spacetimedb/crypto'; +import { parseStripeEventMetadata } from '../webhook-metadata'; +import { MAX_WEBHOOK_METADATA_LENGTH } from '../limits'; +import { applyStripeEvent, updateWebhookStatus } from '../operations'; +import { + validateWebhookRequestBody, + validateWebhookRequestHeaders, +} from '../webhook-request'; + +// POST $STDB_URI/v1/database//route/stripe/webhook +function jsonResponse(status: number, body: unknown): SyncResponse { + return new SyncResponse(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +export function handle_stripe_webhook( + ctx: HandlerContext, + req: StdbRequest +): SyncResponse { + const signatureHeader = req.headers.get('stripe-signature') ?? undefined; + const headerRejection = validateWebhookRequestHeaders( + req.method, + req.headers.get('content-length'), + signatureHeader + ); + if (headerRejection) { + return jsonResponse(headerRejection.status, { + error: headerRejection.error, + }); + } + + const payloadJson = req.text(); + const bodyRejection = validateWebhookRequestBody(payloadJson); + if (bodyRejection) { + return jsonResponse(bodyRejection.status, { error: bodyRejection.error }); + } + + // Webhook processing requires a configured signing secret. + const webhookSecret = ctx.withTx(tx => { + const cfg = tx.db.stripeConfig.singleton.find(true); + return cfg?.webhookSigningSecret ?? undefined; + }); + if (!webhookSecret) { + return jsonResponse(503, { + error: 'webhook signing secret not configured', + }); + } + const nowSeconds = Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n); + const sigOk = verifyStripeSignature({ + rawBody: payloadJson, + signatureHeader: signatureHeader ?? '', + secret: webhookSecret, + nowSeconds, + }); + if (!sigOk) { + return jsonResponse(401, { error: 'signature mismatch' }); + } + + const metadata = parseStripeEventMetadata(payloadJson); + if (!metadata) + return jsonResponse(400, { error: 'missing or invalid event metadata' }); + const { eventId, eventType, livemode } = metadata; + if ( + eventId.length === 0 || + eventId.length > MAX_WEBHOOK_METADATA_LENGTH || + eventType.length === 0 || + eventType.length > MAX_WEBHOOK_METADATA_LENGTH + ) { + return jsonResponse(400, { error: 'invalid event metadata' }); + } + + try { + const outcome = ctx.withTx(tx => { + const existing = tx.db.stripeWebhookEvent.eventId.find(eventId); + if (existing) return { kind: 'duplicate', status: existing.status }; + + tx.db.stripeWebhookEvent.insert({ + eventId, + eventType, + livemode, + signatureHeader, + payloadJson, + status: WebhookEventStatus.Received, + errorMessage: undefined, + receivedAt: tx.timestamp, + processedAt: undefined, + }); + + const result = applyStripeEvent(tx, payloadJson); + updateWebhookStatus(tx, eventId, result.status, result.error); + return { kind: 'applied', status: result.status, error: result.error }; + }); + + return jsonResponse(200, { ok: true, eventId, ...outcome }); + } catch (error) { + console.error(`stripe webhook processing failed for ${eventId}:`, error); + return jsonResponse(500, { error: 'webhook processing failed' }); + } +} + +export const stripe_webhook_handler = spacetimedb.httpHandler( + handle_stripe_webhook +); diff --git a/spacetime-stripe-ts/src/submodule/router.ts b/spacetime-stripe-ts/src/submodule/router.ts new file mode 100644 index 00000000000..8fd6b3bdfcd --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/router.ts @@ -0,0 +1,7 @@ +import { Router } from 'spacetimedb/server'; +import { spacetimedb } from './schema'; +import { stripe_webhook_handler } from './operations/webhook'; + +export const stripeWebhookRouter = spacetimedb.httpRouter( + new Router().post('/stripe/webhook', stripe_webhook_handler) +); diff --git a/spacetime-stripe-ts/src/submodule/schema.ts b/spacetime-stripe-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..d549aa2ef4a --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/schema.ts @@ -0,0 +1,532 @@ +import { + schema, + table, + t, + Range, + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as v from 'valibot'; +import type Stripe from 'stripe'; +import { installStripe } from './install'; + +// Internal ingest lifecycle for webhook rows. Received = stored. Processed = +// applied to the data model. Ignored = duplicate or unhandled event type. +// Failed = signature/format error. +// +// The other status columns on this schema (subscription, checkout, invoice, +// and payment) stay as t.string() because they reflect Stripe-owned vocabulary +// delivered by webhooks. An open string preserves new provider states. +// Stripe's TS types lift them to literal unions on the SDK side; consumers +// can do `subscription.status === 'active'` directly against the wire value. +export const webhookEventStatus = t.enum('WebhookEventStatus', [ + 'Received', + 'Processed', + 'Ignored', + 'Failed', +]); +export const WebhookEventStatus = { + Received: { tag: 'Received' as const }, + Processed: { tag: 'Processed' as const }, + Ignored: { tag: 'Ignored' as const }, + Failed: { tag: 'Failed' as const }, +}; +export type WebhookEventStatusValue = + (typeof WebhookEventStatus)[keyof typeof WebhookEventStatus]; + +export const stripeCustomerRow = { + stripeCustomerId: t.string().primaryKey(), + appUserId: t.option(t.string()), + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + userId: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeSubscriptionRow = { + stripeSubscriptionId: t.string().primaryKey(), + stripeCustomerId: t.string(), + status: t.string(), + currentPeriodEndUnix: t.i64(), + cancelAtPeriodEnd: t.bool(), + cancelAtUnix: t.option(t.i64()), + quantity: t.option(t.i64()), + priceId: t.option(t.string()), + metadataJson: t.option(t.string()), + orgId: t.option(t.string()), + userId: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeCheckoutSessionRow = { + stripeCheckoutSessionId: t.string().primaryKey(), + stripeCustomerId: t.option(t.string()), + status: t.string(), + mode: t.string(), + metadataJson: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripePaymentRow = { + stripePaymentIntentId: t.string().primaryKey(), + stripeCustomerId: t.option(t.string()), + amount: t.i64(), + currency: t.string(), + status: t.string(), + createdUnix: t.i64(), + metadataJson: t.option(t.string()), + orgId: t.option(t.string()), + userId: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeInvoiceRow = { + stripeInvoiceId: t.string().primaryKey(), + stripeCustomerId: t.string(), + stripeSubscriptionId: t.option(t.string()), + status: t.string(), + amountDue: t.i64(), + amountPaid: t.i64(), + createdUnix: t.i64(), + orgId: t.option(t.string()), + userId: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeWebhookEventRow = { + eventId: t.string().primaryKey(), + eventType: t.string(), + livemode: t.bool(), + signatureHeader: t.option(t.string()), + payloadJson: t.string(), + status: webhookEventStatus, + errorMessage: t.option(t.string()), + receivedAt: t.timestamp(), + processedAt: t.option(t.timestamp()), +}; + +export const stripeCustomerTable = table( + { + name: 'stripe_customer', + public: false, + indexes: [ + { accessor: 'byEmail', algorithm: 'btree', columns: ['email'] }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + ], + }, + stripeCustomerRow +); +export const stripeSubscriptionTable = table( + { + name: 'stripe_subscription', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + { + accessor: 'byCustomerInsertedAt', + algorithm: 'btree', + columns: ['stripeCustomerId', 'insertedAt'], + }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { + accessor: 'byOrgInsertedAt', + algorithm: 'btree', + columns: ['orgId', 'insertedAt'], + }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + { + accessor: 'byUserInsertedAt', + algorithm: 'btree', + columns: ['userId', 'insertedAt'], + }, + ], + }, + stripeSubscriptionRow +); +export const stripeCheckoutSessionTable = table( + { + name: 'stripe_checkout_session', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + ], + }, + stripeCheckoutSessionRow +); +export const stripePaymentTable = table( + { + name: 'stripe_payment', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + ], + }, + stripePaymentRow +); +export const stripeInvoiceTable = table( + { + name: 'stripe_invoice', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + { + accessor: 'bySubscription', + algorithm: 'btree', + columns: ['stripeSubscriptionId'], + }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + ], + }, + stripeInvoiceRow +); +export const stripeWebhookEventTable = table( + { + name: 'stripe_webhook_event', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + ], + }, + stripeWebhookEventRow +); + +// Singleton row holding deploy-time secrets. Private, never subscribable. +export const stripeConfigRow = { + singleton: t.bool().primaryKey(), + secretKey: t.string(), + stripeVersion: t.option(t.string()), + webhookSigningSecret: t.option(t.string()), + updatedAt: t.timestamp(), +}; + +export const stripeConfigTable = table( + { name: 'stripe_config', public: false, indexes: [] }, + stripeConfigRow +); + +// Allowlist of identities permitted to call privileged procedures. +export const stripeAdminIdentityRow = { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), +}; + +export const stripeAdminIdentityTable = table( + { name: 'stripe_admin_identity', public: false, indexes: [] }, + stripeAdminIdentityRow +); + +export const spacetimedb = schema({ + stripeCustomer: stripeCustomerTable, + stripeSubscription: stripeSubscriptionTable, + stripeCheckoutSession: stripeCheckoutSessionTable, + stripePayment: stripePaymentTable, + stripeInvoice: stripeInvoiceTable, + stripeWebhookEvent: stripeWebhookEventTable, + stripeConfig: stripeConfigTable, + stripeAdminIdentity: stripeAdminIdentityTable, +}); + +export const init = spacetimedb.init(ctx => { + installStripe(ctx); +}); + +export default spacetimedb; + +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx< + typeof spacetimedb.schemaType +>; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; +export type JsonRecord = Record; +export type ModuleTimestamp = ReducerModuleCtx['timestamp']; + +export const stripeHttpResponse = t.object('StripeHttpResponse', { + status: t.u16(), + body: t.string(), +}); + +export const checkoutSessionResult = t.object('CheckoutSessionResult', { + sessionId: t.string(), + url: t.option(t.string()), +}); + +export const createCustomerResult = t.object('CreateCustomerResult', { + customerId: t.string(), +}); + +export const getOrCreateCustomerResult = t.object('GetOrCreateCustomerResult', { + customerId: t.string(), + isNew: t.bool(), +}); + +export const portalSessionResult = t.object('PortalSessionResult', { + url: t.string(), +}); + +export const subscriptionWithCreationTime = t.object( + 'SubscriptionWithCreationTime', + { + insertedAtMicros: t.i64(), + stripeSubscriptionId: t.string(), + stripeCustomerId: t.string(), + status: t.string(), + } +); + +export { Range, SenderError, t }; + +const vMetadata = v.optional( + v.union([v.record(v.string(), v.string()), v.null()]) +); + +// Stripe "expandable" fields are either a string ID or an object with an id. +const vExpandableId = v.union([v.string(), v.object({ id: v.string() })]); +const vExpandableIdOrNull = v.union([ + v.string(), + v.object({ id: v.string() }), + v.null(), +]); + +export type ExpandableId = v.InferOutput; +export type ExpandableIdOrNull = v.InferOutput; + +export function extractExpandableId(value: ExpandableId): string { + return typeof value === 'string' ? value : value.id; +} + +export function extractExpandableIdOrNull( + value: ExpandableIdOrNull +): string | null { + if (value === null) return null; + return typeof value === 'string' ? value : value.id; +} + +const vCustomerObject = v.object({ + id: v.string(), + email: v.optional(v.union([v.string(), v.null()])), + name: v.optional(v.union([v.string(), v.null()])), + metadata: vMetadata, +}); + +const vSubscriptionItem = v.object({ + current_period_end: v.optional(v.number()), + quantity: v.optional(v.number()), + price: v.optional(v.union([v.object({ id: v.string() }), v.null()])), +}); + +const vSubscriptionObject = v.object({ + id: v.string(), + customer: vExpandableId, + status: v.string(), + current_period_end: v.optional(v.number()), + cancel_at: v.optional(v.union([v.number(), v.null()])), + cancel_at_period_end: v.optional(v.boolean()), + items: v.optional(v.object({ data: v.array(vSubscriptionItem) })), + metadata: vMetadata, +}); + +const vCheckoutSessionObject = v.object({ + id: v.string(), + mode: v.optional(v.string()), + customer: v.optional(vExpandableIdOrNull), + metadata: vMetadata, +}); + +const vInvoiceObject = v.object({ + id: v.string(), + // Stripe.Invoice.customer is nullable; the apply function rejects null. + customer: vExpandableIdOrNull, + subscription: v.optional(vExpandableIdOrNull), + status: v.optional(v.union([v.string(), v.null()])), + amount_due: v.optional(v.number()), + amount_paid: v.optional(v.number()), + created: v.optional(v.number()), +}); + +const vPaymentIntentObject = v.object({ + id: v.string(), + customer: v.optional(vExpandableIdOrNull), + invoice: v.optional(vExpandableIdOrNull), + amount: v.optional(v.number()), + currency: v.optional(v.string()), + status: v.optional(v.string()), + created: v.optional(v.number()), + metadata: vMetadata, +}); + +// Discriminated union over the 12 handled event types; unknown types route to status=failed. +export const vStripeEvent = v.variant('type', [ + v.object({ + type: v.literal('customer.created'), + data: v.object({ object: vCustomerObject }), + }), + v.object({ + type: v.literal('customer.updated'), + data: v.object({ object: vCustomerObject }), + }), + v.object({ + type: v.literal('customer.subscription.created'), + data: v.object({ object: vSubscriptionObject }), + }), + v.object({ + type: v.literal('customer.subscription.updated'), + data: v.object({ object: vSubscriptionObject }), + }), + v.object({ + type: v.literal('customer.subscription.deleted'), + data: v.object({ object: vSubscriptionObject }), + }), + v.object({ + type: v.literal('checkout.session.completed'), + data: v.object({ object: vCheckoutSessionObject }), + }), + v.object({ + type: v.literal('invoice.created'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.finalized'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.paid'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.payment_succeeded'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.payment_failed'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('payment_intent.succeeded'), + data: v.object({ object: vPaymentIntentObject }), + }), +]); + +export type ParsedStripeEvent = v.InferOutput; + +// Compile-time SDK alignment: SDK event types must be assignable to our valibot variants. +function _alignCustomer( + e: Stripe.CustomerCreatedEvent | Stripe.CustomerUpdatedEvent +) { + const _: Extract< + ParsedStripeEvent, + { type: 'customer.created' | 'customer.updated' } + > = e; + return _; +} +function _alignSubscription( + e: + | Stripe.CustomerSubscriptionCreatedEvent + | Stripe.CustomerSubscriptionUpdatedEvent + | Stripe.CustomerSubscriptionDeletedEvent +) { + const _: Extract< + ParsedStripeEvent, + { + type: + | 'customer.subscription.created' + | 'customer.subscription.updated' + | 'customer.subscription.deleted'; + } + > = e; + return _; +} +function _alignCheckout(e: Stripe.CheckoutSessionCompletedEvent) { + const _: Extract = + e; + return _; +} +function _alignInvoice( + e: + | Stripe.InvoiceCreatedEvent + | Stripe.InvoiceFinalizedEvent + | Stripe.InvoicePaidEvent + | Stripe.InvoicePaymentSucceededEvent + | Stripe.InvoicePaymentFailedEvent +) { + const _: Extract< + ParsedStripeEvent, + { + type: + | 'invoice.created' + | 'invoice.finalized' + | 'invoice.paid' + | 'invoice.payment_succeeded' + | 'invoice.payment_failed'; + } + > = e; + return _; +} +function _alignPaymentIntent(e: Stripe.PaymentIntentSucceededEvent) { + const _: Extract = e; + return _; +} +void _alignCustomer; +void _alignSubscription; +void _alignCheckout; +void _alignInvoice; +void _alignPaymentIntent; + +export const vStripeIdResponse = v.object({ id: v.string() }); + +export const vStripeCheckoutSessionResponse = v.object({ + id: v.string(), + url: v.optional(v.union([v.string(), v.null()])), +}); + +export const vStripeBillingPortalSessionResponse = v.object({ + url: v.string(), +}); + +// Stripe error response: `{ error: { type, code, message, request_log_url } }`. +export const vStripeErrorBody = v.object({ + error: v.object({ + type: v.optional(v.string()), + code: v.optional(v.string()), + message: v.optional(v.string()), + request_log_url: v.optional(v.union([v.string(), v.null()])), + }), +}); + +function _alignCheckoutSessionResponse(r: Stripe.Checkout.Session) { + const _: v.InferOutput = r; + return _; +} +function _alignBillingPortalSessionResponse(r: Stripe.BillingPortal.Session) { + const _: v.InferOutput = r; + return _; +} +void _alignCheckoutSessionResponse; +void _alignBillingPortalSessionResponse; diff --git a/spacetime-stripe-ts/src/submodule/utils.ts b/spacetime-stripe-ts/src/submodule/utils.ts new file mode 100644 index 00000000000..c792151a0cc --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/utils.ts @@ -0,0 +1,45 @@ +import * as v from 'valibot'; +import { SenderError } from 'spacetimedb/server'; + +export type ParseResult = + | { kind: 'success'; data: T } + | { kind: 'error'; issues: v.BaseIssue[] }; + +export function attemptToParse( + schema: TSchema, + input: unknown +): ParseResult> { + const result = v.safeParse(schema, input); + if (result.success) return { kind: 'success', data: result.output }; + return { kind: 'error', issues: result.issues }; +} + +export function assertExhaustive(value: never): never { + throw new Error(`Unhandled discriminant: ${value as string}`); +} + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function safeJsonParse(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return undefined; + } +} + +export function summarizeIssues(issues: v.BaseIssue[]): string { + if (issues.length === 0) return 'no issues'; + const head = issues[0]!; + const path = (head.path ?? []) + .map(p => + typeof p.key === 'string' || typeof p.key === 'number' + ? String(p.key) + : '?' + ) + .join('.'); + const where = path ? ` at ${path}` : ''; + return `${head.message}${where}`; +} diff --git a/spacetime-stripe-ts/src/submodule/webhook-metadata.ts b/spacetime-stripe-ts/src/submodule/webhook-metadata.ts new file mode 100644 index 00000000000..e4bb0db42e9 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/webhook-metadata.ts @@ -0,0 +1,25 @@ +export type StripeEventMetadata = { + eventId: string; + eventType: string; + livemode: boolean; +}; + +export function parseStripeEventMetadata( + payloadJson: string +): StripeEventMetadata | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(payloadJson); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const record = parsed as Record; + if (typeof record.id !== 'string' || typeof record.type !== 'string') + return undefined; + return { + eventId: record.id, + eventType: record.type, + livemode: typeof record.livemode === 'boolean' ? record.livemode : false, + }; +} diff --git a/spacetime-stripe-ts/src/submodule/webhook-request.ts b/spacetime-stripe-ts/src/submodule/webhook-request.ts new file mode 100644 index 00000000000..a55df3f1df8 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/webhook-request.ts @@ -0,0 +1,35 @@ +import { MAX_WEBHOOK_BODY_LENGTH, MAX_WEBHOOK_HEADER_LENGTH } from './limits'; + +export type WebhookRequestRejection = { + status: number; + error: string; +}; + +export function validateWebhookRequestHeaders( + method: string, + contentLengthHeader: string | null, + signatureHeader: string | undefined +): WebhookRequestRejection | undefined { + if (method !== 'POST') return { status: 405, error: 'method not allowed' }; + const contentLength = Number(contentLengthHeader ?? '0'); + if ( + Number.isFinite(contentLength) && + contentLength > MAX_WEBHOOK_BODY_LENGTH + ) { + return { status: 413, error: 'payload too large' }; + } + if ((signatureHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH) { + return { status: 431, error: 'signature header too large' }; + } + return undefined; +} + +export function validateWebhookRequestBody( + payloadJson: string +): WebhookRequestRejection | undefined { + if (payloadJson.length === 0) return { status: 400, error: 'empty body' }; + if (new TextEncoder().encode(payloadJson).length > MAX_WEBHOOK_BODY_LENGTH) { + return { status: 413, error: 'payload too large' }; + } + return undefined; +} diff --git a/spacetime-stripe-ts/tsconfig.json b/spacetime-stripe-ts/tsconfig.json new file mode 100644 index 00000000000..c659d97428a --- /dev/null +++ b/spacetime-stripe-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/tools/check-example-assets.mjs b/tools/check-example-assets.mjs new file mode 100644 index 00000000000..4a02d10854c --- /dev/null +++ b/tools/check-example-assets.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +let checked = 0; + +for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || !/^spacetime-.+-ts$/.test(entry.name)) continue; + + const publicDir = join(root, entry.name, 'example', 'public'); + const indexPath = join(publicDir, 'index.html'); + if (!existsSync(indexPath)) continue; + + checked++; + const html = readFileSync(indexPath, 'utf8'); + const stylesPath = join(publicDir, 'styles.css'); + const uiPath = join(publicDir, 'ui.js'); + + if (/)/i.test(html)) { + failures.push(`${entry.name}: index.html contains an inline style block`); + } + if (/]*\bsrc=)[^>]*>/i.test(html)) { + failures.push(`${entry.name}: index.html contains an inline script block`); + } + if (!existsSync(stylesPath)) { + failures.push(`${entry.name}: public/styles.css is missing`); + } + if (!/]+href=["'](?:\.\/|\/)styles\.css["'][^>]*>/i.test(html)) { + failures.push(`${entry.name}: index.html does not load styles.css`); + } + + const loadsUi = /]+src=["']\.\/ui\.js["'][^>]*>/i.test(html); + if (existsSync(uiPath) !== loadsUi) { + failures.push( + `${entry.name}: public/ui.js and its index.html script tag do not match` + ); + } +} + +if (checked !== 12) { + failures.push(`expected 12 browser examples, found ${checked}`); +} + +if (failures.length > 0) { + console.error('Example asset check failed:'); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log(`Example asset check passed for ${checked} browser examples.`); diff --git a/tools/check-spacetime-release.mjs b/tools/check-spacetime-release.mjs new file mode 100644 index 00000000000..615f791be6e --- /dev/null +++ b/tools/check-spacetime-release.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { spacetimedbVersion } from './release-packages.mjs'; + +const command = process.platform === 'win32' ? 'spacetime.exe' : 'spacetime'; +const result = spawnSync(command, ['--version'], { + encoding: 'utf8', + shell: false, +}); + +if (result.error) { + console.error(`Could not run ${command}: ${result.error.message}`); + console.error( + 'Install the released CLI from https://spacetimedb.com/install.' + ); + process.exit(1); +} + +if (result.status !== 0) { + console.error( + result.stderr || result.stdout || `${command} exited with ${result.status}` + ); + process.exit(result.status ?? 1); +} + +const output = `${result.stdout}\n${result.stderr}`.trim(); +const toolVersion = output.match(/spacetimedb tool version\s+([^\s;]+)/)?.[1]; +const libVersion = output.match(/spacetimedb-lib version\s+([^\s;]+)/)?.[1]; + +if (toolVersion !== spacetimedbVersion || libVersion !== spacetimedbVersion) { + console.error(`Expected SpacetimeDB tool and library ${spacetimedbVersion}.`); + console.error(output || 'The CLI did not report version information.'); + console.error(`Run: spacetime version install ${spacetimedbVersion}`); + console.error(`Then: spacetime version use ${spacetimedbVersion}`); + process.exit(1); +} + +console.log(`SpacetimeDB released toolchain ${spacetimedbVersion} is active.`); diff --git a/tools/consumer-install-check.mjs b/tools/consumer-install-check.mjs new file mode 100644 index 00000000000..15f858b8795 --- /dev/null +++ b/tools/consumer-install-check.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { releasePackages, spacetimedbVersion } from './release-packages.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'; +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const spacetimeCommand = + process.platform === 'win32' ? 'spacetime.exe' : 'spacetime'; +const temporaryRoot = mkdtempSync(join(tmpdir(), 'stdb-components-consumer-')); +const packDirectory = join(temporaryRoot, 'packs'); +mkdirSync(packDirectory); + +function run(command, args, cwd = temporaryRoot) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + shell: process.platform === 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + const detail = result.error?.message || result.stderr || result.stdout; + throw new Error(`${command} ${args.join(' ')} failed:\n${detail.trim()}`); + } + return result.stdout; +} + +function packageSpecifier(packageName, exportName) { + return exportName === '.' + ? packageName + : `${packageName}${exportName.slice(1)}`; +} + +try { + const dependencies = { + spacetimedb: spacetimedbVersion, + }; + const importLines = []; + let importIndex = 0; + + for (const packageDirectory of releasePackages) { + const directory = resolve(root, packageDirectory); + const manifest = JSON.parse( + readFileSync(resolve(directory, 'package.json'), 'utf8') + ); + const output = run( + pnpmCommand, + ['pack', '--json', '--pack-destination', packDirectory], + directory + ); + const result = JSON.parse(output); + const tarball = resolve(result.filename); + if (!existsSync(tarball)) + throw new Error(`npm pack did not create ${tarball}`); + dependencies[manifest.name] = `file:./packs/${basename(tarball)}`; + + for (const exportName of Object.keys( + manifest.exports ?? { '.': manifest.main } + )) { + const alias = `packageExport${importIndex}`; + importLines.push( + `import * as ${alias} from '${packageSpecifier(manifest.name, exportName)}';` + ); + importLines.push(`void ${alias};`); + importIndex += 1; + } + } + + writeFileSync( + join(temporaryRoot, 'package.json'), + `${JSON.stringify( + { + name: 'spacetimedb-components-consumer-check', + private: true, + type: 'module', + dependencies, + devDependencies: { typescript: '^5.9.3' }, + }, + null, + 2 + )}\n` + ); + writeFileSync( + join(temporaryRoot, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + allowImportingTsExtensions: true, + module: 'ESNext', + moduleResolution: 'Bundler', + noEmit: true, + skipLibCheck: true, + strict: true, + target: 'ES2022', + }, + include: ['consumer.ts'], + }, + null, + 2 + )}\n` + ); + writeFileSync( + join(temporaryRoot, 'consumer.ts'), + `${importLines.join('\n')}\n` + ); + mkdirSync(join(temporaryRoot, 'src')); + writeFileSync( + join(temporaryRoot, 'src', 'index.ts'), + ` +import { schema } from 'spacetimedb/server'; +import * as apiKeys from '@spacetimedb/api-keys/submodule'; +import * as auth from '@spacetimedb/auth/submodule'; +import * as files from '@spacetimedb/files/submodule'; +import * as grid from '@spacetimedb/grid/submodule'; +import * as lobby from '@spacetimedb/lobby/submodule'; +import * as posthog from '@spacetimedb/posthog/submodule'; +import * as presence from '@spacetimedb/presence/submodule'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import * as resend from '@spacetimedb/resend/submodule'; +import * as stripe from '@spacetimedb/stripe/submodule'; + +const spacetimedb = schema({ + apiKeys, + auth, + files, + grid, + lobby, + posthog, + presence, + rateLimit, + resend, + stripe, +}); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + apiKeys.installApiKeys(ctx.as.apiKeys); + auth.installAuth(ctx.as.auth); + files.installFiles(ctx.as.files); + grid.installGrid(ctx.as.grid); + lobby.installLobby(ctx.as.lobby); + posthog.installPostHog(ctx.as.posthog); + presence.installPresence(ctx.as.presence); + rateLimit.installRateLimit(ctx.as.rateLimit); + resend.installResend(ctx.as.resend); + stripe.installStripe(ctx.as.stripe); +}); +` + ); + + run(npmCommand, ['install', '--ignore-scripts', '--no-audit', '--no-fund']); + if (process.argv.includes('--audit')) { + const audit = spawnSync(npmCommand, ['audit', '--omit=dev', '--json'], { + cwd: temporaryRoot, + encoding: 'utf8', + shell: process.platform === 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + let report; + try { + report = JSON.parse(audit.stdout); + } catch { + throw new Error( + `npm audit did not return JSON${audit.stderr ? `:\n${audit.stderr.trim()}` : ''}` + ); + } + const counts = report.metadata?.vulnerabilities ?? {}; + const total = ['info', 'low', 'moderate', 'high', 'critical'].reduce( + (sum, severity) => sum + Number(counts[severity] ?? 0), + 0 + ); + if (audit.status !== 0 || total > 0) { + const summary = ['info', 'low', 'moderate', 'high', 'critical'] + .filter(severity => Number(counts[severity] ?? 0) > 0) + .map(severity => `${severity}=${counts[severity]}`) + .join(', '); + throw new Error(`packed production dependency audit failed: ${summary}`); + } + console.log('Packed production dependency audit passed.'); + } + run(npxCommand, ['tsc', '--project', 'tsconfig.json']); + run(spacetimeCommand, ['build']); + + for (const packageDirectory of releasePackages) { + const sourceManifest = JSON.parse( + readFileSync(resolve(root, packageDirectory, 'package.json'), 'utf8') + ); + const installedDirectory = resolve( + temporaryRoot, + 'node_modules', + ...sourceManifest.name.split('/') + ); + const installedManifest = JSON.parse( + readFileSync(resolve(installedDirectory, 'package.json'), 'utf8') + ); + for (const value of Object.values(installedManifest.exports ?? {})) { + const targets = + typeof value === 'string' ? [value] : Object.values(value); + for (const target of targets) { + if (typeof target !== 'string') continue; + if (!existsSync(resolve(installedDirectory, target))) { + throw new Error( + `${installedManifest.name} export is absent after install: ${target}` + ); + } + } + } + } + + console.log( + `Consumer install check passed for ${releasePackages.length} packed packages, ${importIndex} exports, and a clean host-module build.` + ); +} finally { + rmSync(temporaryRoot, { force: true, recursive: true }); +} diff --git a/tools/doc-check.mjs b/tools/doc-check.mjs new file mode 100644 index 00000000000..49f35c7ca78 --- /dev/null +++ b/tools/doc-check.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node + +import { createRequire } from 'node:module'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { releasePackages } from './release-packages.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const requireFromPackage = createRequire( + resolve(root, 'spacetime-agents-ts', 'package.json') +); +const ts = requireFromPackage('typescript'); +const failures = []; + +const documentationFiles = [ + 'COMPONENTS.md', + 'COMPONENTS_GETTING_STARTED.md', + 'COMPONENTS_AUTHORING.md', + 'NPM_RELEASE_CHECKLIST.md', + ...releasePackages.flatMap(packageDir => { + const files = [`${packageDir}/README.md`]; + const exampleReadme = `${packageDir}/example/README.md`; + if (existsSync(resolve(root, exampleReadme))) files.push(exampleReadme); + return files; + }), + 'spacetime-stripe-ts/example/spacetimedb/README.md', +]; + +function fail(file, line, message) { + failures.push(`${file}:${line}: ${message}`); +} + +function lineNumberAt(text, offset) { + return text.slice(0, offset).split('\n').length; +} + +function validateLinks(file, text) { + const linkPattern = /!?\[[^\]]*\]\(([^)]+)\)/g; + for (const match of text.matchAll(linkPattern)) { + let target = match[1].trim(); + if (target.startsWith('<') && target.endsWith('>')) + target = target.slice(1, -1); + target = target.split(/\s+["']/)[0]; + if (/^(?:https?:|mailto:|#)/i.test(target)) continue; + const path = decodeURIComponent(target.split('#')[0]); + if (!path) continue; + if (!existsSync(resolve(root, dirname(file), path))) { + fail( + file, + lineNumberAt(text, match.index), + `broken relative link: ${target}` + ); + } + } +} + +function validateFences(file, text) { + const lines = text.split(/\r?\n/); + let fence; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const opening = line.match(/^```\s*([A-Za-z0-9_-]*)\s*$/); + if (!fence && opening) { + fence = { + language: opening[1].toLowerCase(), + start: index + 1, + lines: [], + }; + continue; + } + if (fence && /^```\s*$/.test(line)) { + if (['ts', 'typescript', 'js', 'javascript'].includes(fence.language)) { + const source = fence.lines.join('\n'); + const result = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + }, + fileName: `${file}.${fence.language.startsWith('j') ? 'js' : 'ts'}`, + reportDiagnostics: true, + }); + for (const diagnostic of result.diagnostics ?? []) { + if (diagnostic.category !== ts.DiagnosticCategory.Error) continue; + const position = + diagnostic.file && diagnostic.start != null + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + : { line: 0 }; + fail( + file, + fence.start + 1 + position.line, + `invalid ${fence.language} example: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}` + ); + } + } + fence = undefined; + continue; + } + if (fence) fence.lines.push(line); + } + if (fence) fail(file, fence.start, 'unclosed code fence'); +} + +for (const file of documentationFiles) { + const path = resolve(root, file); + if (!existsSync(path)) { + fail(file, 1, 'documentation file is missing'); + continue; + } + const text = readFileSync(path, 'utf8'); + validateLinks(file, text); + validateFences(file, text); + if (/\b(?:TODO|FIXME|WIP)\b/i.test(text)) { + fail(file, 1, 'contains draft-work marker (TODO/FIXME/WIP)'); + } + if (/\bnamespace[- ]branch\b/i.test(text)) { + fail(file, 1, 'contains obsolete namespace-branch wording'); + } + if (/SpacetimeDBPrivate/.test(text)) { + fail(file, 1, 'leaks a contributor-local repository name'); + } +} + +for (const packageDir of releasePackages) { + const file = `${packageDir}/README.md`; + const text = readFileSync(resolve(root, file), 'utf8'); + const manifest = JSON.parse( + readFileSync(resolve(root, packageDir, 'package.json'), 'utf8') + ); + const firstLine = text.split(/\r?\n/, 1)[0]; + if (firstLine !== `# ${manifest.name}`) + fail(file, 1, `must start with # ${manifest.name}`); + const requiredHeadings = ['Install', 'Usage', 'API', 'Testing', 'License']; + let previousHeadingOffset = -1; + for (const heading of requiredHeadings) { + const match = new RegExp(`^## ${heading}(?:\\s|$)`, 'mi').exec(text); + if (!match) { + fail(file, 1, `missing release README section: ${heading}`); + continue; + } + if (match.index < previousHeadingOffset) { + fail( + file, + lineNumberAt(text, match.index), + `release README section is out of order: ${heading}` + ); + } + previousHeadingOffset = match.index; + } + if (!/^### Integrate into an application\s*$/m.test(text)) { + fail( + file, + 1, + 'missing consumer onboarding section: Integrate into an application' + ); + } + if ( + !text.includes('spacetimedb@^2.8.3') && + packageDir !== 'spacetime-crypto-ts' + ) { + fail( + file, + 1, + 'install command must pin the compatible SpacetimeDB 2.8 peer range' + ); + } + if (!text.includes('https://spacetimedb.com/docs/')) { + fail(file, 1, 'missing link to the official getting-started guide'); + } +} + +for (const packageDir of releasePackages) { + const file = `${packageDir}/example/README.md`; + const path = resolve(root, file); + if (!existsSync(path)) continue; + const text = readFileSync(path, 'utf8'); + for (const heading of [ + 'Prerequisites', + 'Quick start', + 'Use in your project', + ]) { + if (!new RegExp(`^## ${heading}\\s*$`, 'm').test(text)) { + fail(file, 1, `missing example onboarding section: ${heading}`); + } + } + if (!text.includes('spacetime version use 2.8.3')) { + fail(file, 1, 'quick start must select SpacetimeDB CLI 2.8.3'); + } + if (!text.includes('spacetime start')) { + fail(file, 1, 'quick start must explain how to start the local server'); + } +} + +if (failures.length > 0) { + console.error(`Documentation check failed with ${failures.length} issue(s):`); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log( + `Documentation check passed for ${documentationFiles.length} files.` +); diff --git a/tools/example-server-identity.ts b/tools/example-server-identity.ts new file mode 100644 index 00000000000..a1f6c9564f9 --- /dev/null +++ b/tools/example-server-identity.ts @@ -0,0 +1,68 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; + +export type StoredServerToken = { + token: string | undefined; + source: 'environment' | 'file' | 'none'; +}; + +export function loadServerToken( + tokenPath: string, + environmentToken: string | undefined +): StoredServerToken { + const explicit = environmentToken?.trim(); + if (explicit) return { token: explicit, source: 'environment' }; + if (!existsSync(tokenPath)) return { token: undefined, source: 'none' }; + + const stored = readFileSync(tokenPath, 'utf8').trim(); + return stored + ? { token: stored, source: 'file' } + : { token: undefined, source: 'none' }; +} + +export function saveServerToken(tokenPath: string, token: string): void { + writeFileSync(tokenPath, `${token.trim()}\n`, { + encoding: 'utf8', + mode: 0o600, + }); +} + +export function discardStoredServerToken(tokenPath: string): void { + if (existsSync(tokenPath)) unlinkSync(tokenPath); +} + +export function grantServerIdentity(options: { + spacetimeBin?: string; + server: string; + database: string; + procedure: string; + identity: string; +}): void { + const spacetimeBin = options.spacetimeBin ?? 'spacetime'; + const result = spawnSync( + spacetimeBin, + [ + 'call', + '--server', + options.server, + options.database, + options.procedure, + JSON.stringify(options.identity), + ], + { encoding: 'utf8', shell: false } + ); + + if (result.error) { + throw new Error(`failed to run ${spacetimeBin}: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + throw new Error( + [ + `could not authorize the example server identity with ${options.procedure}`, + detail || `${spacetimeBin} exited ${result.status}`, + 'Publish the database with the currently logged-in CLI identity, then restart the example.', + ].join(': ') + ); + } +} diff --git a/tools/release-check.mjs b/tools/release-check.mjs new file mode 100644 index 00000000000..514cfb1c776 --- /dev/null +++ b/tools/release-check.mjs @@ -0,0 +1,463 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { + releasePackages, + releasePackageName, + spacetimedbPeerRange, + spacetimedbVersion, +} from './release-packages.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const canonicalLicense = readFileSync( + resolve(root, releasePackages[0], 'LICENSE.txt'), + 'utf8' +); + +function fail(packageName, message) { + failures.push(`${packageName}: ${message}`); +} + +function readJson(path, packageName) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + fail( + packageName, + `invalid JSON in ${relative(root, path)}: ${error.message}` + ); + return undefined; + } +} + +function isScheduledCallbackAny(node) { + const callback = node.parent; + if (!ts.isArrowFunction(callback) || callback.type !== node) return false; + const property = callback.parent; + if (!ts.isPropertyAssignment(property)) return false; + return ( + (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && + property.name.text === 'scheduled' + ); +} + +function checkExplicitAny(repositoryPath, absolutePath) { + const source = readFileSync(absolutePath, 'utf8'); + const sourceFile = ts.createSourceFile( + repositoryPath, + source, + ts.ScriptTarget.Latest, + true + ); + const visit = node => { + if ( + node.kind === ts.SyntaxKind.AnyKeyword && + !isScheduledCallbackAny(node) + ) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile) + ); + fail( + 'repository', + `${repositoryPath}:${line + 1}:${character + 1} uses explicit any outside the SpacetimeDB scheduled callback boundary` + ); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); +} + +const tracked = spawnSync( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard'], + { cwd: root, encoding: 'utf8', shell: process.platform === 'win32' } +); +if (tracked.status !== 0) { + fail( + 'repository', + `could not enumerate files: ${(tracked.stderr || tracked.stdout).trim()}` + ); +} else { + for (const repositoryPath of tracked.stdout.split(/\r?\n/).filter(Boolean)) { + const absolutePath = resolve(root, repositoryPath); + if (!existsSync(absolutePath)) continue; + + const normalizedRepositoryPath = repositoryPath.replaceAll('\\', '/'); + const isComponentPath = releasePackages.some( + packageDir => + normalizedRepositoryPath === packageDir || + normalizedRepositoryPath.startsWith(`${packageDir}/`) + ); + if (!isComponentPath && normalizedRepositoryPath !== 'pnpm-lock.yaml') { + continue; + } + if ( + /^spacetime-[^/]+-ts\/(?:module|app-module|store-module)(?:\/|$)/.test( + normalizedRepositoryPath + ) || + /^spacetime-[^/]+-ts\/example\/(?:module|app-module|store-module)(?:\/|$)/.test( + normalizedRepositoryPath + ) + ) { + fail( + 'repository', + `module directory must be named spacetimedb: ${repositoryPath}` + ); + } + + if ( + /tools\/(?:run-namespace-cli|use-namespace-cli)/.test( + normalizedRepositoryPath + ) + ) { + fail( + 'repository', + `obsolete local CLI helper remains: ${repositoryPath}` + ); + } + + if ( + /\.tsx?$/.test(repositoryPath) && + !normalizedRepositoryPath.includes('/codegen/') && + !normalizedRepositoryPath.includes('/module_bindings/') + ) { + const source = readFileSync(absolutePath, 'utf8'); + if (/\.find\([^\n]*\)\s*(?:===|!==)\s*undefined/.test(source)) { + fail( + 'repository', + `${repositoryPath} compares a table lookup with undefined; SpacetimeDB returns null for a missing row` + ); + } + checkExplicitAny(repositoryPath, absolutePath); + } + + if (repositoryPath.endsWith('pnpm-lock.yaml')) { + const lockfile = readFileSync(absolutePath, 'utf8'); + if (/SpacetimeDBPrivate|spacetimedb@file:/.test(lockfile)) { + fail( + 'repository', + `${repositoryPath} resolves SpacetimeDB from a local filesystem path` + ); + } + continue; + } + + if (!repositoryPath.endsWith('package.json')) continue; + const manifest = readJson(absolutePath, 'repository'); + if (!manifest) continue; + const workspaceMatch = normalizedRepositoryPath.match( + /^spacetime-([a-z0-9-]+)-ts\/(example\/spacetimedb|example|spacetimedb)\/package\.json$/ + ); + if (workspaceMatch) { + const [, slug, workspaceKind] = workspaceMatch; + const expectedWorkspaceName = + workspaceKind === 'example' + ? `spacetime-${slug}-example` + : workspaceKind === 'example/spacetimedb' + ? `spacetime-${slug}-example-module` + : `spacetime-${slug}-module`; + if (manifest.name !== expectedWorkspaceName) { + fail( + 'repository', + `${repositoryPath} name must be ${expectedWorkspaceName}` + ); + } + } + for (const section of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + ]) { + const version = manifest[section]?.spacetimedb; + if (version && version !== 'workspace:*') { + fail( + 'repository', + `${repositoryPath} ${section}.spacetimedb must be workspace:*` + ); + } + } + const peerVersion = manifest.peerDependencies?.spacetimedb; + if (peerVersion && peerVersion !== spacetimedbPeerRange) { + fail( + 'repository', + `${repositoryPath} peerDependencies.spacetimedb must be ${spacetimedbPeerRange}` + ); + } + } +} + +function exportTargets(exportsField) { + const targets = []; + for (const value of Object.values(exportsField ?? {})) { + if (typeof value === 'string') { + targets.push(value); + continue; + } + if (value && typeof value === 'object') { + for (const target of Object.values(value)) { + if (typeof target === 'string') targets.push(target); + } + } + } + return [...new Set(targets)]; +} + +function filesUnder(path) { + if (!existsSync(path)) return []; + const out = []; + for (const entry of readdirSync(path)) { + const child = resolve(path, entry); + if (statSync(child).isDirectory()) out.push(...filesUnder(child)); + else out.push(child); + } + return out; +} + +for (const packageDir of releasePackages) { + const directory = resolve(root, packageDir); + const manifestPath = resolve(directory, 'package.json'); + const manifest = readJson(manifestPath, packageDir); + if (!manifest) continue; + + const packageSlug = packageDir.replace(/^spacetime-/, ''); + const expectedName = releasePackageName(packageDir); + if (manifest.name !== expectedName) + fail(packageDir, `name must be ${expectedName}`); + if ( + !/^(?:0|[1-9]\d*)\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test( + manifest.version ?? '' + ) + ) { + fail(packageDir, 'version must be valid semver'); + } + if ( + typeof manifest.description !== 'string' || + manifest.description.length < 20 || + manifest.description.length > 180 + ) { + fail(packageDir, 'description must be 20-180 characters'); + } + if (manifest.license !== 'BUSL-1.1') + fail(packageDir, 'license must be BUSL-1.1'); + const packageLicense = readFileSync( + resolve(directory, 'LICENSE.txt'), + 'utf8' + ); + if (packageLicense !== canonicalLicense) { + fail(packageDir, 'LICENSE.txt must match the other release packages'); + } + if ( + !packageLicense.includes( + `Licensed Work: SpacetimeDB ${spacetimedbVersion}` + ) + ) { + fail( + packageDir, + `LICENSE.txt must cover SpacetimeDB ${spacetimedbVersion}` + ); + } + if (manifest.type !== 'module') fail(packageDir, 'type must be module'); + if ( + manifest.main !== './src/index.ts' || + manifest.types !== './src/index.ts' + ) { + fail(packageDir, 'main and types must point to ./src/index.ts'); + } + if (manifest.publishConfig?.access !== 'public') + fail(packageDir, 'publishConfig.access must be public'); + if (manifest.publishConfig?.registry !== 'https://registry.npmjs.org/') { + fail( + packageDir, + 'publishConfig.registry must be https://registry.npmjs.org/' + ); + } + if ( + manifest.repository?.url !== + 'git+https://github.com/clockworklabs/SpacetimeDB.git' + ) { + fail(packageDir, 'repository URL is missing or incorrect'); + } + if (manifest.repository?.directory !== packageDir) + fail(packageDir, 'repository.directory must match the package directory'); + if (!manifest.homepage || !manifest.bugs?.url) + fail(packageDir, 'homepage and bugs metadata are required'); + if (!Array.isArray(manifest.keywords) || manifest.keywords.length < 3) + fail(packageDir, 'at least three keywords are required'); + if ( + manifest.scripts?.format !== + 'prettier . --write --ignore-path ../.prettierignore' + ) { + fail(packageDir, 'format script must match the SpacetimeDB workspace'); + } + if ( + manifest.scripts?.lint !== + 'eslint . && prettier . --check --ignore-path ../.prettierignore' + ) { + fail(packageDir, 'lint script must match the SpacetimeDB workspace'); + } + if (!manifest.scripts?.typecheck) + fail(packageDir, 'typecheck script is required'); + if (!manifest.scripts?.test) fail(packageDir, 'a test script is required'); + + for (const requiredFile of ['src', 'README.md', 'LICENSE.txt']) { + if (!manifest.files?.includes(requiredFile)) + fail(packageDir, `files must include ${requiredFile}`); + if (!existsSync(resolve(directory, requiredFile))) + fail(packageDir, `${requiredFile} does not exist`); + } + + if (!manifest.exports?.['.']) fail(packageDir, 'the root export is required'); + for (const target of exportTargets(manifest.exports)) { + const normalized = target.replace(/^\.\//, ''); + if (!existsSync(resolve(directory, normalized))) + fail(packageDir, `export target does not exist: ${target}`); + const included = manifest.files?.some( + entry => normalized === entry || normalized.startsWith(`${entry}/`) + ); + if (!included) + fail(packageDir, `export target is excluded from the tarball: ${target}`); + } + + const dependencySections = ['dependencies', 'optionalDependencies']; + for (const section of dependencySections) { + for (const [name, version] of Object.entries(manifest[section] ?? {})) { + if (/^(?:file|link):/.test(version)) + fail( + packageDir, + `${section}.${name} must not use a filesystem dependency` + ); + if (name.startsWith('@spacetimedb/') && version !== 'workspace:^') { + fail(packageDir, `${section}.${name} must be workspace:^`); + } + } + } + + if (packageSlug !== 'crypto-ts') { + if (manifest.peerDependencies?.spacetimedb !== spacetimedbPeerRange) { + fail( + packageDir, + `spacetimedb peer dependency must be ${spacetimedbPeerRange}` + ); + } + if (Object.hasOwn(manifest.scripts ?? {}, 'publish')) { + fail( + packageDir, + 'scripts.publish is an npm lifecycle hook; use an explicit name such as publish:module' + ); + } + if (manifest.devDependencies?.spacetimedb !== 'workspace:*') { + fail(packageDir, 'spacetimedb devDependency must be workspace:*'); + } + } + + const readme = readFileSync(resolve(directory, 'README.md'), 'utf8'); + const firstLine = readme.split(/\r?\n/, 1)[0]; + if (firstLine !== `# ${expectedName}`) + fail(packageDir, `README must start with # ${expectedName}`); + for (const heading of ['Install', 'Testing', 'License']) { + if (!new RegExp(`^## ${heading}\\s*$`, 'm').test(readme)) + fail(packageDir, `README is missing the ${heading} section`); + } + if (readme.split(/\r?\n/).length > 400) + fail( + packageDir, + 'README exceeds 400 lines; move internal design notes elsewhere' + ); + for (const smell of [ + '## Backlog', + '## Roadmap', + 'What this package actually contains', + 'What STDB needs to ship for production', + ]) { + if (readme.includes(smell)) + fail(packageDir, `README contains internal or draft wording: ${smell}`); + } + + const submoduleTarget = manifest.exports?.['./submodule']?.default; + if (submoduleTarget) { + const source = readFileSync( + resolve(directory, submoduleTarget.replace(/^\.\//, '')), + 'utf8' + ); + if (/export\s*\{[^}]*\binit\b[^}]*\}/s.test(source)) + fail(packageDir, './submodule must not export init'); + } + + for (const sourcePath of [ + ...filesUnder(resolve(directory, 'src')), + ...filesUnder(resolve(directory, 'spacetimedb', 'src')), + ]) { + if (!sourcePath.endsWith('.ts')) continue; + const source = readFileSync(sourcePath, 'utf8'); + const sourceName = relative(root, sourcePath).split(sep).join('/'); + if (/@ts-(?:ignore|nocheck)/.test(source)) + fail(packageDir, `${sourceName} disables TypeScript checking`); + if (/from\s+['"]node:|\brequire\s*\(|\bprocess\./.test(source)) + fail(packageDir, `${sourceName} imports a Node-only API`); + } + + const packDirectory = mkdtempSync(join(tmpdir(), 'stdb-component-pack-')); + const packed = spawnSync( + pnpmCommand, + ['pack', '--json', '--pack-destination', packDirectory], + { cwd: directory, encoding: 'utf8', shell: process.platform === 'win32' } + ); + if (packed.status !== 0) { + const detail = + packed.error?.message || + packed.stderr || + packed.stdout || + `exit ${packed.status}`; + fail(packageDir, `pnpm pack failed: ${detail.trim()}`); + rmSync(packDirectory, { force: true, recursive: true }); + continue; + } + let packResult; + try { + packResult = JSON.parse(packed.stdout); + } catch (error) { + fail(packageDir, `could not parse pnpm pack output: ${error.message}`); + rmSync(packDirectory, { force: true, recursive: true }); + continue; + } + const packedFiles = (packResult.files ?? []).map(entry => + entry.path.replaceAll('\\', '/') + ); + for (const required of ['package.json', 'README.md', 'LICENSE.txt']) { + if (!packedFiles.includes(required)) + fail(packageDir, `tarball is missing ${required}`); + } + for (const path of packedFiles) { + if ( + /^(?:example|scripts|node_modules|ts-codegen|dist|target)\//.test(path) || + path === 'pnpm-lock.yaml' + ) { + fail(packageDir, `tarball contains development-only file: ${path}`); + } + } + rmSync(packDirectory, { force: true, recursive: true }); +} + +if (failures.length > 0) { + console.error(`Release check failed with ${failures.length} issue(s):`); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log( + `Release-preparation check passed for ${releasePackages.length} packages.` +); diff --git a/tools/release-packages.mjs b/tools/release-packages.mjs new file mode 100644 index 00000000000..f3d4ee53ff6 --- /dev/null +++ b/tools/release-packages.mjs @@ -0,0 +1,25 @@ +export const releasePackages = [ + 'spacetime-agents-ts', + 'spacetime-api-keys-ts', + 'spacetime-auth-ts', + 'spacetime-cron-ts', + 'spacetime-crypto-ts', + 'spacetime-files-ts', + 'spacetime-grid-ts', + 'spacetime-lobby-ts', + 'spacetime-posthog-ts', + 'spacetime-presence-ts', + 'spacetime-rate-limit-ts', + 'spacetime-resend-ts', + 'spacetime-retry-ts', + 'spacetime-stripe-ts', +]; + +export const spacetimedbVersion = '2.8.3'; +export const spacetimedbPeerRange = 'workspace:^'; +export const packedSpacetimedbPeerRange = '^2.8.3'; + +export function releasePackageName(packageDir) { + const slug = packageDir.replace(/^spacetime-/, '').replace(/-ts$/, ''); + return `@spacetimedb/${slug}`; +} diff --git a/tools/run-example-builds.mjs b/tools/run-example-builds.mjs new file mode 100644 index 00000000000..0c52144c1b2 --- /dev/null +++ b/tools/run-example-builds.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const targets = [ + 'spacetime-agents-ts/example', + 'spacetime-api-keys-ts/example', + 'spacetime-auth-ts/example', + 'spacetime-cron-ts/example', + 'spacetime-files-ts/example', + 'spacetime-grid-ts/example', + 'spacetime-lobby-ts/example', + 'spacetime-posthog-ts/example', + 'spacetime-presence-ts/example', + 'spacetime-rate-limit-ts/example', + 'spacetime-resend-ts/example', + 'spacetime-stripe-ts/example', +]; + +const failures = []; +for (const target of targets) { + console.log(`\nBuilding ${target}`); + const result = spawnSync(pnpmCommand, ['--dir', target, 'run', 'build'], { + cwd: root, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.status !== 0) failures.push(target); +} + +if (failures.length > 0) { + console.error(`\nExample builds failed: ${failures.join(', ')}`); + process.exit(1); +} + +console.log(`\nExample builds passed for ${targets.length} browser samples.`); diff --git a/tools/run-example-smokes.mjs b/tools/run-example-smokes.mjs new file mode 100644 index 00000000000..f9bfa76ef0d --- /dev/null +++ b/tools/run-example-smokes.mjs @@ -0,0 +1,703 @@ +#!/usr/bin/env node + +/* global document */ + +import { spawn, spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const isWindows = process.platform === 'win32'; +const pnpmCommand = isWindows ? 'pnpm.cmd' : 'pnpm'; +const spacetimeCommand = isWindows ? 'spacetime.exe' : 'spacetime'; +const confirmFlag = '--confirm-delete-data'; +const browserFlag = '--browser'; +const ephemeralFlag = '--ephemeral'; + +const examples = [ + { + dir: 'spacetime-stripe-ts/example', + database: 'spacetime-stripe-example', + port: 8787, + }, + { + dir: 'spacetime-cron-ts/example', + database: 'spacetime-cron-example', + port: 8788, + }, + { + dir: 'spacetime-agents-ts/example', + database: 'spacetime-agents-example', + port: 8789, + }, + { + dir: 'spacetime-resend-ts/example', + database: 'spacetime-resend-example', + port: 8790, + }, + { + dir: 'spacetime-auth-ts/example', + database: 'spacetime-auth-example', + port: 8791, + }, + { + dir: 'spacetime-rate-limit-ts/example', + database: 'spacetime-rate-limit-example', + port: 8792, + }, + { + dir: 'spacetime-grid-ts/example', + database: 'spacetime-grid-example', + port: 8793, + }, + { + dir: 'spacetime-presence-ts/example', + database: 'spacetime-presence-example', + port: 8794, + }, + { + dir: 'spacetime-posthog-ts/example', + database: 'spacetime-posthog-example', + port: 8796, + }, + { + dir: 'spacetime-lobby-ts/example', + database: 'spacetime-lobby-example', + port: 8797, + }, + { + dir: 'spacetime-api-keys-ts/example', + database: 'spacetime-api-keys-example', + port: 8798, + }, + { + dir: 'spacetime-files-ts/example', + database: 'spacetime-files-example', + port: 8799, + }, +]; + +function selectedExamples() { + const onlyIndex = process.argv.indexOf('--only'); + if (onlyIndex < 0) return examples; + const requested = process.argv[onlyIndex + 1]; + if (!requested) + throw new Error('--only requires an example directory or database name'); + const selected = examples.filter( + item => item.dir === requested || item.database === requested + ); + if (selected.length === 0) throw new Error(`unknown example: ${requested}`); + return selected; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? root, + env: options.env ?? process.env, + encoding: 'utf8', + stdio: options.inherit ? 'inherit' : 'pipe', + shell: isWindows && command === pnpmCommand, + windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); + throw new Error( + `${command} ${args.join(' ')} exited with ${result.status}${output ? `\n${output}` : ''}` + ); + } + return `${result.stdout ?? ''}${result.stderr ?? ''}`; +} + +function smokeEnvironment(example) { + return { + ...process.env, + HOST: '127.0.0.1', + PORT: String(example.port), + STDB_URI: 'ws://127.0.0.1:3000', + STDB_HTTP: 'http://127.0.0.1:3000', + STDB_SERVER: 'http://127.0.0.1:3000', + STDB_DATABASE: example.database, + STDB_APP_DATABASE: example.database, + AUTH_ISSUER_URL: `http://127.0.0.1:${example.port}`, + AUTH_BASE_URL: `http://127.0.0.1:${example.port}`, + AUTH_COOKIE_NAME: 'stdb_auth', + AUTH_SESSION_TTL_SECONDS: '604800', + AUTH_ES256_PRIVATE_KEY_PEM: '', + // The generic smoke suite must never spend money or mutate provider accounts. + OPENROUTER_API_KEY: '', + OPENAI_API_KEY: '', + ANTHROPIC_API_KEY: '', + POSTHOG_PROJECT_API_KEY: '', + STRIPE_SECRET_KEY: '', + STRIPE_SYNC_PRICES: '0', + RESEND_API_KEY: '', + RESEND_WEBHOOK_SECRET: '', + GOOGLE_CLIENT_ID: '', + GOOGLE_CLIENT_SECRET: '', + GITHUB_CLIENT_ID: '', + GITHUB_CLIENT_SECRET: '', + }; +} + +function startServer(example) { + const chunks = []; + const tsxCli = resolve( + root, + example.dir, + 'node_modules', + 'tsx', + 'dist', + 'cli.mjs' + ); + const child = spawn(process.execPath, [tsxCli, 'server.ts'], { + cwd: resolve(root, example.dir), + env: smokeEnvironment(example), + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const capture = chunk => { + chunks.push(chunk.toString()); + if (chunks.join('').length > 40_000) chunks.shift(); + }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + return { child, output: () => chunks.join('') }; +} + +async function stopServer(child) { + if (child.exitCode !== null) return; + if (isWindows) { + const killed = spawnSync( + 'taskkill.exe', + ['/pid', String(child.pid), '/t', '/f'], + { + stdio: 'ignore', + windowsHide: true, + timeout: 10_000, + } + ); + if (killed.error || killed.status !== 0) child.kill(); + } else { + child.kill('SIGTERM'); + } + await Promise.race([ + new Promise(resolveExit => child.once('exit', resolveExit)), + new Promise(resolveTimeout => setTimeout(resolveTimeout, 5_000)), + ]); + if (child.exitCode === null) { + child.kill('SIGKILL'); + await Promise.race([ + new Promise(resolveExit => child.once('exit', resolveExit)), + new Promise(resolveTimeout => setTimeout(resolveTimeout, 2_000)), + ]); + if (child.exitCode === null) { + throw new Error(`failed to stop example server process ${child.pid}`); + } + } +} + +async function request(url, options) { + return fetch(url, { ...options, signal: AbortSignal.timeout(2_000) }); +} + +async function waitForHealth(example, server) { + const deadline = Date.now() + 45_000; + const url = `http://127.0.0.1:${example.port}/api/health`; + let lastError = 'server did not answer'; + while (Date.now() < deadline) { + if (server.child.exitCode !== null) { + throw new Error( + `server exited with ${server.child.exitCode}\n${server.output()}` + ); + } + try { + const response = await request(url); + const body = await response.json(); + const reportedDatabase = body.database ?? body.app; + if ( + !response.ok || + body.ok !== true || + reportedDatabase !== example.database + ) { + throw new Error( + `unexpected health response ${response.status}: ${JSON.stringify(body)}` + ); + } + return; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + await new Promise(resolveWait => setTimeout(resolveWait, 250)); + } + } + throw new Error(`${lastError}\n${server.output()}`); +} + +async function checkHttpSurface(example) { + const origin = `http://127.0.0.1:${example.port}`; + const rootResponse = await request(`${origin}/`); + if ( + !rootResponse.ok || + !(rootResponse.headers.get('content-type') ?? '').includes('text/html') + ) { + throw new Error(`GET / did not return HTML (${rootResponse.status})`); + } + + const configResponse = await request(`${origin}/api/config`); + const config = await configResponse.json(); + const configuredDatabase = config.database ?? config.appDatabase; + if (!configResponse.ok || configuredDatabase !== example.database) { + throw new Error( + `unexpected /api/config response: ${JSON.stringify(config)}` + ); + } + + if (example.baseDatabase === 'spacetime-posthog-example') { + const removedRoute = await request(`${origin}/api/admin/identity`); + if (removedRoute.status !== 404) + throw new Error('removed PostHog admin route is reachable'); + } + if (example.baseDatabase === 'spacetime-stripe-example') { + for (const route of [ + '/api/admin/configure', + '/api/admin/seed', + '/api/admin/sync', + ]) { + const removedRoute = await request(`${origin}${route}`, { + method: 'POST', + }); + if (removedRoute.status !== 404) + throw new Error(`removed Stripe admin route is reachable: ${route}`); + } + const unavailableCheckout = await request(`${origin}/api/checkout`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + if (unavailableCheckout.status !== 503) { + throw new Error( + `unconfigured Stripe checkout returned ${unavailableCheckout.status}, expected 503` + ); + } + } + if (example.baseDatabase === 'spacetime-resend-example') { + const unsigned = await request(`${origin}/webhook/resend`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + if (unsigned.status !== 400) { + throw new Error( + `unsigned Resend webhook returned ${unsigned.status}, expected 400` + ); + } + } +} + +async function waitForEnabled(page, selector) { + await page.locator(selector).waitFor({ state: 'visible' }); + await page.waitForFunction( + target => !document.querySelector(target)?.hasAttribute('disabled'), + selector + ); +} + +async function checkExampleInteraction(example, page) { + switch (example.baseDatabase) { + case 'spacetime-stripe-example': + await page.locator('#btnCart').click(); + await page.waitForFunction( + () => !document.querySelector('#cartPopout')?.hasAttribute('hidden') + ); + await page.locator('#btnCartClose').click(); + return; + + case 'spacetime-cron-example': + await page.waitForFunction( + () => + document.querySelector('#connection')?.dataset.state === 'connected' + ); + await page.waitForFunction( + () => Number(document.querySelector('#stat-jobs')?.textContent) === 2 + ); + await page.locator('#open-scheduler').click(); + await page.locator('#job-name').waitFor({ state: 'visible' }); + return; + + case 'spacetime-agents-example': + await page.locator('#toggle-link').click(); + await page.waitForFunction( + () => + document.querySelector('#auth-title')?.textContent === + 'Create an account' + ); + return; + + case 'spacetime-resend-example': + await page.locator('#subject-input').fill('Browser smoke'); + await page.locator('#message-input').fill('Rendered preview'); + await page.locator('.msg-tab[data-tab="preview"]').click(); + await page.waitForFunction( + () => + !document.querySelector('#message-preview')?.hasAttribute('hidden') + ); + return; + + case 'spacetime-auth-example': + await page.locator('#toggle-link').click(); + await page.waitForFunction( + () => + document.querySelector('#auth-title')?.textContent === + 'Create account' + ); + return; + + case 'spacetime-rate-limit-example': { + try { + await waitForEnabled(page, '#tapBtn'); + } catch (error) { + const diagnostics = await page.evaluate(() => ({ + connection: globalThis.__reactorConnectionState, + tapVisible: document.querySelector('#tapBtn') != null, + tapDisabled: + document.querySelector('#tapBtn')?.hasAttribute('disabled') ?? null, + hasActionApi: typeof globalThis.reactor?.tap === 'function', + })); + throw new Error( + `Rate Limit did not become ready: ${JSON.stringify(diagnostics)}`, + { cause: error } + ); + } + if ( + await page.evaluate( + () => globalThis.__reactorConnectedBeforeReady === true + ) + ) { + throw new Error('Rate Limit reported connected before its action API'); + } + const before = await page.locator('#energy').textContent(); + await page.locator('#tapBtn').click(); + try { + await page.waitForFunction( + previous => + document.querySelector('#energy')?.textContent !== previous, + before + ); + } catch (error) { + const diagnostics = await page.evaluate(() => ({ + connectedBeforeReady: + globalThis.__reactorConnectedBeforeReady === true, + energy: document.querySelector('#energy')?.textContent ?? null, + tapDisabled: + document.querySelector('#tapBtn')?.hasAttribute('disabled') ?? null, + hasActionApi: typeof globalThis.reactor?.tap === 'function', + })); + throw new Error( + `Rate Limit tap did not update energy: ${JSON.stringify(diagnostics)}`, + { cause: error } + ); + } + return; + } + + case 'spacetime-grid-example': + await page.locator('#toggle-link').click(); + await page.waitForFunction( + () => + document.querySelector('#auth-title')?.textContent === + 'Create an account' + ); + return; + + case 'spacetime-presence-example': + await page.locator('#toggleLink').click(); + await page.waitForFunction( + () => + document.querySelector('#landingAuthTitle')?.textContent === + 'Create an account' + ); + return; + + case 'spacetime-posthog-example': + await waitForEnabled(page, '#tickOnce'); + await page.locator('#tickOnce').click(); + return; + + case 'spacetime-lobby-example': + await waitForEnabled(page, '#findDuel'); + await page.locator('#displayName').fill('Browser Smoke'); + await page.locator('#findDuel').click(); + await page.waitForFunction( + () => + document + .querySelector('#waitingScreen') + ?.classList.contains('active') || + document.querySelector('#duelScreen')?.classList.contains('active') + ); + return; + + case 'spacetime-api-keys-example': + await page.waitForFunction( + () => document.querySelector('#connChip')?.dataset.state === 'connected' + ); + await page.locator('#rosterBtn').click(); + await page.waitForFunction( + () => !document.querySelector('#rosterPanel')?.hasAttribute('hidden') + ); + return; + + case 'spacetime-files-example': + await waitForEnabled(page, '#new-folder'); + await page.locator('#new-folder').click(); + await page.waitForFunction(() => + document.querySelector('#dialog')?.classList.contains('open') + ); + await page.locator('#dialog-cancel').click(); + await page.locator('#file-input').setInputFiles([ + { name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('a') }, + { name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') }, + { name: 'c.txt', mimeType: 'text/plain', buffer: Buffer.from('c') }, + ]); + await page.waitForFunction( + () => document.querySelectorAll('[data-file]').length === 3 + ); + await page.locator('[data-file="/a.txt"]').click(); + await page + .locator('[data-file="/c.txt"]') + .click({ modifiers: ['Shift'] }); + await page.waitForFunction( + () => + document.querySelectorAll('[data-file].selected').length === 3 && + document.querySelector('#bulk-count')?.textContent === '3 selected' + ); + return; + + default: + throw new Error( + `missing browser interaction for ${example.baseDatabase}` + ); + } +} + +async function checkBrowserSurface(example, browser) { + const origin = `http://127.0.0.1:${example.port}`; + const context = await browser.newContext(); + const page = await context.newPage(); + const errors = []; + + if (example.baseDatabase === 'spacetime-rate-limit-example') { + await page.addInitScript(() => { + globalThis.__reactorConnectedBeforeReady = false; + globalThis.__reactorConnectionState = null; + globalThis.addEventListener('reactor:connState', event => { + globalThis.__reactorConnectionState = event.detail ?? null; + if ( + event.detail?.state === 'connected' && + typeof globalThis.reactor?.tap !== 'function' + ) { + globalThis.__reactorConnectedBeforeReady = true; + } + }); + }); + } + + page.on('pageerror', error => errors.push(`page error: ${error.message}`)); + page.on('console', message => { + if (message.type() !== 'error') return; + if (message.text().startsWith('Failed to load resource:')) return; + errors.push(`console: ${message.text()}`); + }); + page.on('response', response => { + if (response.status() < 400) return; + const url = new URL(response.url()); + if ( + url.origin === origin && + response.status() === 401 && + url.pathname === '/auth/session/refresh' + ) { + return; + } + errors.push( + `HTTP ${response.status()}: ${response.request().method()} ${url.href}` + ); + }); + page.on('requestfailed', request => { + const url = new URL(request.url()); + if ( + !['document', 'script', 'stylesheet', 'xhr', 'fetch'].includes( + request.resourceType() + ) + ) { + return; + } + errors.push( + `request failed: ${request.method()} ${url.href} (${request.failure()?.errorText ?? 'unknown'})` + ); + }); + + try { + const response = await page.goto(origin, { waitUntil: 'load' }); + if (!response?.ok()) { + throw new Error( + `browser GET / returned ${response?.status() ?? 'no response'}` + ); + } + await page.waitForFunction(() => + [...document.styleSheets].some(sheet => + sheet.href?.endsWith('/styles.css') + ) + ); + await checkExampleInteraction(example, page); + await page.waitForTimeout(250); + if (errors.length > 0) throw new Error(errors.join('\n')); + } finally { + await context.close(); + } +} + +async function smoke(example, browser) { + const startedAt = Date.now(); + console.log(`\n[smoke] ${example.dir}: fresh publish as ${example.database}`); + if (process.argv.includes(ephemeralFlag)) { + run( + spacetimeCommand, + [ + 'publish', + '--server', + 'local', + '--yes', + '--module-path', + resolve(root, example.dir, 'spacetimedb'), + example.database, + ], + { inherit: true } + ); + run(pnpmCommand, ['--dir', example.dir, 'run', 'build:codegen'], { + inherit: true, + }); + run(pnpmCommand, ['--dir', example.dir, 'run', 'build:app'], { + inherit: true, + }); + } else { + try { + run(pnpmCommand, ['--dir', example.dir, 'run', 'build:module:fresh'], { + inherit: true, + }); + } catch (firstError) { + console.warn( + `[smoke] ${example.dir}: fresh publish failed; retrying once` + ); + try { + run(pnpmCommand, ['--dir', example.dir, 'run', 'build:module:fresh'], { + inherit: true, + }); + } catch (retryError) { + throw new Error( + `${retryError instanceof Error ? retryError.message : String(retryError)}\nFirst attempt: ${firstError instanceof Error ? firstError.message : String(firstError)}` + ); + } + } + } + + console.log( + `[smoke] ${example.dir}: start and probe http://127.0.0.1:${example.port}` + ); + const server = startServer(example); + try { + await waitForHealth(example, server); + await checkHttpSurface(example); + if (browser) await checkBrowserSurface(example, browser); + } catch (error) { + const output = server.output().trim(); + throw new Error( + `${error instanceof Error ? error.message : String(error)}${output ? `\nServer output:\n${output}` : ''}` + ); + } finally { + await stopServer(server.child); + } + console.log( + `[smoke] ${example.dir}: passed (${((Date.now() - startedAt) / 1000).toFixed(1)}s)` + ); +} + +async function main() { + const ephemeral = process.argv.includes(ephemeralFlag); + if (!process.argv.includes(confirmFlag) && !ephemeral) { + console.error( + `Refusing to replace local example databases without ${confirmFlag}.` + ); + console.error( + 'This suite runs each build:module:fresh script with --delete-data=always.' + ); + process.exit(2); + } + + run(process.execPath, [resolve(root, 'tools/check-spacetime-release.mjs')], { + inherit: true, + }); + run(spacetimeCommand, ['server', 'ping', 'local']); + run(spacetimeCommand, ['login', 'show']); + + const suffix = `smoke-${process.pid}-${Date.now()}`; + const selected = selectedExamples().map(example => ({ + ...example, + baseDatabase: example.database, + database: ephemeral ? `${example.database}-${suffix}` : example.database, + })); + const failures = []; + const browser = process.argv.includes(browserFlag) + ? await chromium.launch({ headless: true }) + : undefined; + try { + for (const example of selected) { + try { + await smoke(example, browser); + } catch (error) { + failures.push({ + example: example.dir, + error: error instanceof Error ? error.message : String(error), + }); + console.error( + `[smoke] ${example.dir}: FAILED\n${failures.at(-1).error}` + ); + } finally { + if (ephemeral) { + try { + run(spacetimeCommand, [ + 'delete', + '--server', + 'local', + '--yes', + example.database, + ]); + } catch (error) { + failures.push({ + example: example.dir, + error: `ephemeral cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + } + } + } finally { + await browser?.close(); + } + + if (failures.length > 0) { + console.error( + `\nExample smoke failures (${failures.length}/${selected.length}):` + ); + for (const failure of failures) + console.error(`- ${failure.example}: ${failure.error.split('\n')[0]}`); + process.exit(1); + } + console.log( + `\nFresh-database smoke passed for ${selected.length} example app(s).` + ); +} + +main().catch(error => { + console.error(error instanceof Error ? error.stack : String(error)); + process.exit(1); +}); diff --git a/tools/run-example-tests.mjs b/tools/run-example-tests.mjs new file mode 100644 index 00000000000..8d8e9f2d2f2 --- /dev/null +++ b/tools/run-example-tests.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const targets = [ + 'spacetime-agents-ts/example', + 'spacetime-agents-ts/example/spacetimedb', + 'spacetime-api-keys-ts/example', + 'spacetime-files-ts/example', + 'spacetime-grid-ts/example', + 'spacetime-lobby-ts/example', + 'spacetime-posthog-ts/example', + 'spacetime-presence-ts/example', + 'spacetime-rate-limit-ts/example', + 'spacetime-resend-ts/example', +]; + +const failures = []; +for (const target of targets) { + console.log(`\nTesting ${target}`); + const result = spawnSync(pnpmCommand, ['--dir', target, 'run', 'test:unit'], { + cwd: root, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.status !== 0) failures.push(target); +} + +if (failures.length > 0) { + console.error(`\nExample tests failed: ${failures.join(', ')}`); + process.exit(1); +} + +console.log(`\nExample tests passed for ${targets.length} targeted suites.`); diff --git a/tools/run-module-builds.mjs b/tools/run-module-builds.mjs new file mode 100644 index 00000000000..901c4b66428 --- /dev/null +++ b/tools/run-module-builds.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const targets = [ + 'spacetime-agents-ts/spacetimedb', + 'spacetime-agents-ts/example/spacetimedb', + 'spacetime-api-keys-ts/example/spacetimedb', + 'spacetime-auth-ts/spacetimedb', + 'spacetime-auth-ts/example/spacetimedb', + 'spacetime-cron-ts/spacetimedb', + 'spacetime-cron-ts/example/spacetimedb', + 'spacetime-files-ts/example/spacetimedb', + 'spacetime-grid-ts/example/spacetimedb', + 'spacetime-lobby-ts', + 'spacetime-lobby-ts/example/spacetimedb', + 'spacetime-posthog-ts', + 'spacetime-posthog-ts/example/spacetimedb', + 'spacetime-presence-ts/spacetimedb', + 'spacetime-presence-ts/example/spacetimedb', + 'spacetime-rate-limit-ts/spacetimedb', + 'spacetime-rate-limit-ts/example/spacetimedb', + 'spacetime-resend-ts', + 'spacetime-resend-ts/example/spacetimedb', + 'spacetime-retry-ts/spacetimedb', + 'spacetime-stripe-ts', + 'spacetime-stripe-ts/example/spacetimedb', +]; + +const failures = []; +for (const target of targets) { + let passed = false; + for (let attempt = 1; attempt <= 2; attempt += 1) { + const suffix = attempt === 1 ? '' : ' (retry after transient failure)'; + console.log(`\nBuilding ${target}${suffix}`); + const result = spawnSync(pnpmCommand, ['--dir', target, 'run', 'build'], { + cwd: root, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + const reportedRuntimeError = /^Error: Uncaught\b/m.test(output); + if (result.status === 0 && !result.error && !reportedRuntimeError) { + passed = true; + break; + } + if (reportedRuntimeError) { + console.error(`Build reported a runtime error for ${target}.`); + } + if (attempt === 1) { + console.warn(`Build failed for ${target}; retrying once.`); + } + } + if (!passed) failures.push(target); +} + +if (failures.length > 0) { + console.error(`\nModule builds failed: ${failures.join(', ')}`); + process.exit(1); +} + +console.log(`\nModule builds passed for ${targets.length} release fixtures.`); diff --git a/tools/run-package-checks.mjs b/tools/run-package-checks.mjs new file mode 100644 index 00000000000..cdb5114cb6a --- /dev/null +++ b/tools/run-package-checks.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { releasePackages } from './release-packages.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; +const failures = []; + +function run(packageDir, script) { + console.log(`\n${packageDir}: ${script}`); + const result = spawnSync(pnpmCommand, ['--dir', packageDir, 'run', script], { + cwd: root, + encoding: 'utf8', + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.status !== 0) failures.push(`${packageDir}:${script}`); +} + +for (const packageDir of releasePackages) { + const manifest = JSON.parse( + readFileSync(resolve(root, packageDir, 'package.json'), 'utf8') + ); + run(packageDir, 'lint'); + run(packageDir, 'typecheck'); + if (!manifest.scripts?.test) { + failures.push(`${packageDir}:missing-test-script`); + continue; + } + run(packageDir, 'test'); +} + +if (failures.length > 0) { + console.error(`\nPackage checks failed: ${failures.join(', ')}`); + process.exit(1); +} + +console.log(`\nPackage checks passed for ${releasePackages.length} packages.`); diff --git a/tools/run-production-audits.mjs b/tools/run-production-audits.mjs new file mode 100644 index 00000000000..1ef4885720a --- /dev/null +++ b/tools/run-production-audits.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const result = spawnSync( + process.execPath, + [resolve(root, 'tools/consumer-install-check.mjs'), '--audit'], + { cwd: root, stdio: 'inherit' } +); + +process.exit(result.status ?? 1); From 1c4f3f12ffda7a9f92d1d2621e495fefb4f31f18 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 16:23:58 -0400 Subject: [PATCH 02/33] Remove standalone release files --- COMPONENTS.md | 5 - COMPONENTS_AUTHORING.md | 2 - NPM_RELEASE_CHECKLIST.md | 148 ---- package.json | 2 +- .../example/spacetimedb/LICENSE.txt | 759 ------------------ .../example/spacetimedb/README.md | 2 +- tools/doc-check.mjs | 1 - tools/release-check.mjs | 27 + 8 files changed, 29 insertions(+), 917 deletions(-) delete mode 100644 NPM_RELEASE_CHECKLIST.md delete mode 100644 spacetime-stripe-ts/example/spacetimedb/LICENSE.txt diff --git a/COMPONENTS.md b/COMPONENTS.md index 2630d0c7753..d96f37116ef 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -149,8 +149,3 @@ With a local SpacetimeDB server running, the Stripe and Resend synthetic smoke tests need no provider credentials. Credentialed provider tests, such as Stripe's sandbox E2E suite, are documented in the corresponding package README. - -## Releasing - -Follow [NPM_RELEASE_CHECKLIST.md](./NPM_RELEASE_CHECKLIST.md) for authentication, -versioning, dependency order, dry runs, manual publication, and verification. diff --git a/COMPONENTS_AUTHORING.md b/COMPONENTS_AUTHORING.md index a234ec0dfce..a60590ef4f3 100644 --- a/COMPONENTS_AUTHORING.md +++ b/COMPONENTS_AUTHORING.md @@ -127,5 +127,3 @@ pnpm components:build The root lint command also validates Markdown links, TypeScript/JavaScript code fences, stale work-in-progress markers, and the required README structure. -Follow [`NPM_RELEASE_CHECKLIST.md`](./NPM_RELEASE_CHECKLIST.md) for versioning, -npm authentication, publication order, and post-publish verification. diff --git a/NPM_RELEASE_CHECKLIST.md b/NPM_RELEASE_CHECKLIST.md deleted file mode 100644 index a4a56c812ba..00000000000 --- a/NPM_RELEASE_CHECKLIST.md +++ /dev/null @@ -1,148 +0,0 @@ -# Publishing the Submodules to npm - -These packages publish under the public `@spacetimedb` scope. Publishing is -manual until a dedicated release workflow and npm trusted publisher are -configured. The packages require the released SpacetimeDB 2.8 submodule APIs. - -## Prerequisites - -1. Install Node.js 22 or later and a current npm CLI. -2. Confirm you have write access to the `@spacetimedb` npm organization. -3. Enable two-factor authentication on the npm account. -4. Authenticate and verify the registry account: - - ```bash - npm login - npm whoami - npm config get registry - ``` - - The registry must be `https://registry.npmjs.org/`. - -5. Install and select the exact CLI used by the release gates: - - ```bash - spacetime version install 2.8.3 - spacetime version use 2.8.3 - spacetime --version - npm view spacetimedb@2.8.3 version - ``` - - Both the CLI tool and embedded library must report `2.8.3`. Package - development dependencies resolve the SDK from this pnpm workspace. - -## Release gates - -Run from the repository root: - -```bash -pnpm install --frozen-lockfile -pnpm components:check -pnpm components:build -pnpm components:consumer:check -pnpm --dir spacetime-stripe-ts run test:smoke -pnpm --dir spacetime-resend-ts run test:smoke -``` - -The release check validates metadata, exports, README structure, publishable -dependency ranges, lifecycle boundaries, forbidden Node-only imports, and the -contents of every npm tarball. The package checks run TypeScript validation and -all non-credentialed unit suites. - -The build gate first verifies the released 2.8.3 CLI, then compiles 22 server -fixtures and regenerates and bundles all 12 browser examples. The Stripe and -Resend smoke suites publish dedicated local databases and use synthetic signed -webhooks, so they require a running local SpacetimeDB server but no provider -credentials. Stripe's `test:stripe:e2e` suite remains opt-in because it uses a -real Stripe sandbox and Stripe CLI session. - -Before publishing, also confirm: - -- The worktree contains only intended release changes. -- The commit to release is on the protected default branch. -- Every changed package has the intended version. -- `CHANGELOG` or release notes describe user-visible changes. -- No `.env`, credential, log, generated binding, example build, or - `node_modules` file appears in `pnpm pack --json`. - -## Versioning - -For the first publication, use the reviewed version already recorded in the -manifest. npm never permits overwriting an existing name and version. - -Check a package before choosing a version: - -```bash -npm view @spacetimedb/crypto version -``` - -An npm `E404` means the package name has not been published. For an existing -package, update the version and defer Git tagging: - -```bash -cd spacetime-crypto-ts -npm version patch --no-git-tag-version -``` - -Use `minor` or `major` when the change warrants it. If an internal dependency -receives a version outside a consumer's current range, update the consumer -manifest before publishing. - -## Publish order - -Publish dependency foundations before their consumers: - -1. `@spacetimedb/crypto` -2. `@spacetimedb/rate-limit` -3. Packages with no unpublished internal dependency: `agents`, `cron`, - `grid`, `lobby`, `posthog`, `presence`, and `retry` -4. `@spacetimedb/api-keys`, `@spacetimedb/files`, `@spacetimedb/auth`, - `@spacetimedb/resend`, and `@spacetimedb/stripe` - -Independent packages within steps 1-3 may be released in -any order. Wait for each foundation version to become visible through -`npm view` before publishing its consumers. - -## Dry run and publish - -Run these commands from the package directory. The explicit access flag is -important for the first publication of an organization-scoped public package. - -```bash -pnpm pack --json -pnpm publish --dry-run --access public -pnpm publish --access public -``` - -Interactive publication requires 2FA. Do not pass an access token on the -command line or store it in the repository. npm also supports staged -publication (`npm stage publish`) when a separate 2FA approval step is desired. - -Immediately verify the published package: - -```bash -npm view @spacetimedb/crypto@0.1.0 --json -npm install --ignore-scripts @spacetimedb/crypto@0.1.0 -``` - -For packages with `./submodule`, verify the installed package contains that -export and run a clean consumer typecheck before continuing to the next package. - -## After publication - -1. Tag the release commit using the repository's chosen tag convention. -2. Create release notes that list every published package and version. -3. Submit eligible packages to the SpacetimeDB submodule registry. -4. Configure npm trusted publishing for a future release workflow. Use Node - 22.14.0 or newer and npm 11.5.1 or newer in the publish job. Trusted - publishing uses short-lived OIDC credentials and automatically records npm - provenance for eligible public packages; the workflow needs - `id-token: write`. New trusted-publisher configurations must explicitly - allow `npm publish`, `npm stage publish`, or both. - -References: - -- [Publishing scoped public packages](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/) -- [npm two-factor authentication](https://docs.npmjs.com/about-two-factor-authentication/) -- [Trusted publishing](https://docs.npmjs.com/trusted-publishers/) -- [npm provenance](https://docs.npmjs.com/generating-provenance-statements/) diff --git a/package.json b/package.json index 6474b65dd1a..529bbe226a3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "pnpm run-all test && pnpm components:test", "generate": "pnpm run-all generate", "clean": "pnpm -r exec rimraf dist .tsbuildinfo coverage", - "components:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"COMPONENTS*.md\" \"NPM_RELEASE_CHECKLIST.md\" \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", + "components:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"COMPONENTS*.md\" \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", "components:lint": "node tools/doc-check.mjs && node tools/release-check.mjs && node tools/check-example-assets.mjs && eslint \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts && pnpm -r --filter \"./spacetime-*-ts\" run lint", "components:test": "node tools/run-package-checks.mjs && node tools/run-example-tests.mjs", "components:toolchain:check": "node tools/check-spacetime-release.mjs", diff --git a/spacetime-stripe-ts/example/spacetimedb/LICENSE.txt b/spacetime-stripe-ts/example/spacetimedb/LICENSE.txt deleted file mode 100644 index ea0cb1c5e9e..00000000000 --- a/spacetime-stripe-ts/example/spacetimedb/LICENSE.txt +++ /dev/null @@ -1,759 +0,0 @@ -SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT - -Business Source License 1.1 - -Parameters - -Licensor: Clockwork Laboratories, Inc. -Licensed Work: SpacetimeDB 2.8.3 - The Licensed Work is - (c) 2023 Clockwork Laboratories, Inc. - -Additional Use Grant: You may make use of the Licensed Work provided your - application or service uses the Licensed Work with no - more than one SpacetimeDB instance in production and - provided that you do not use the Licensed Work for a - Database Service. - - A “Database Service” is a commercial offering that - allows third parties (other than your employees and - contractors) to access the functionality of the - Licensed Work by creating tables whose schemas are - controlled by such third parties. - -Change Date: 2031-08-18 - -Change License: GNU Affero General Public License v3.0 with a linking - exception - -For information about alternative licensing arrangements for the Software, -please visit: https://spacetimedb.com - -Notice - -The Business Source License (this document, or the “License”) is not an Open -Source license. However, the Licensed Work will eventually be made available -under an Open Source License, as stated in this License. - -License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. -“Business Source License” is a trademark of MariaDB Corporation Ab. - ------------------------------------------------------------------------------ - -Base License and Subdirectory Specific Licenses - -1. Repository-Wide License -Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. - -2. Subdirectory-Specific Licenses -Certain subdirectories within this repository are licensed under different terms. - -If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. - -In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. - -3. Contributor Acknowledgement -By contributing to this repository, you agree that: - -Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. - -If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. - -4. Reading the Applicable License -Before using, modifying, or distributing code from this repository, you must read: - -This base LICENSE.txt file for the overall repository license. - -Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. - ------------------------------------------------------------------------------ - -Business Source License 1.1 - -Terms - -The Licensor hereby grants you the right to copy, modify, create derivative -works, redistribute, and make non-production use of the Licensed Work. The -Licensor may make an Additional Use Grant, above, permitting limited -production use. - -Effective on the Change Date, or the fourth anniversary of the first publicly -available distribution of a specific version of the Licensed Work under this -License, whichever comes first, the Licensor hereby grants you rights under -the terms of the Change License, and the rights granted in the paragraph -above terminate. - -If your use of the Licensed Work does not comply with the requirements -currently in effect as described in this License, you must purchase a -commercial license from the Licensor, its affiliated entities, or authorized -resellers, or you must refrain from using the Licensed Work. - -All copies of the original and modified Licensed Work, and derivative works -of the Licensed Work, are subject to this License. This License applies -separately for each version of the Licensed Work and the Change Date may vary -for each version of the Licensed Work released by Licensor. - -You must conspicuously display this License on each original or modified copy -of the Licensed Work. If you receive the Licensed Work in original or -modified form from a third party, the terms and conditions set forth in this -License apply to your use of that work. - -Any use of the Licensed Work in violation of this License will automatically -terminate your rights under this License for the current and all other -versions of the Licensed Work. - -This License does not grant you any right in any trademark or logo of -Licensor or its affiliates (provided that you may use a trademark or logo of -Licensor as expressly required by this License). - -TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON -AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, -EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND -TITLE. - -MariaDB hereby grants you permission to use this License’s text to license -your works, and to refer to it using the trademark “Business Source License”, -as long as you comply with the Covenants of Licensor below. - -Covenants of Licensor - -In consideration of the right to use this License’s text and the “Business -Source License” name and trademark, Licensor covenants to MariaDB, and to all -other recipients of the licensed work to be provided by Licensor: - -1. To specify as the Change License the GPL Version 2.0 or any later version, - or a license that is compatible with GPL Version 2.0 or a later version, - where “compatible” means that software provided under the Change License can - be included in a program with software provided under GPL Version 2.0 or a - later version. Licensor may specify additional Change Licenses without - limitation. - -2. To either: (a) specify an additional grant of rights to use that does not - impose any additional restriction on the right granted in this License, as - the Additional Use Grant; or (b) insert the text “None”. - -3. To specify a Change Date. - -4. Not to modify this License in any other way. - ------------------------------------------------------------------------------ - -Copyright (C) 2023 Clockwork Laboratories, Inc. - -This program is free software: you can redistribute it and/or modify it under -the terms of the GNU Affero General Public License, version 3, as published -by the Free Software Foundation. - -This program is distributed in the hope that it will be useful, but WITHOUT -ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -details. - -You should have received a copy of the GNU Affero General Public License -along with this program; if not, see . - -Additional permission under GNU GPL version 3 section 7 - -If you modify this Program, or any covered work, by linking or combining it -with SpacetimeDB (or a modified version of that library), containing parts -covered by the terms of the AGPL v3.0, the licensors of this Program grant -you additional permission to convey the resulting work. - -Additional permission under GNU AGPL version 3 section 13 - -If you modify this Program, or any covered work, by linking or combining it -with SpacetimeDB (or a modified version of that library), containing parts -covered by the terms of the AGPL v3.0, the licensors of this Program grant -you additional permission that, notwithstanding any other provision of this -License, you need not prominently offer all users interacting with your -modified version remotely through a computer network an opportunity to -receive the Corresponding Source of your version from a network server at no -charge, if your version supports such interaction. This permission does not -waive or modify any other obligations or terms of the AGPL v3.0, except for -the specific requirement set forth in section 13. - -A copy of the AGPL v3.0 license is reproduced below. - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - -Copyright © 2007 Free Software Foundation, Inc. -Everyone is permitted to copy and distribute verbatim copies of this license -document, but changing it is not allowed. - -Preamble -The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - -The licenses for most software and other practical works are designed to take -away your freedom to share and change the works. By contrast, our General -Public Licenses are intended to guarantee your freedom to share and change -all versions of a program--to make sure it remains free software for all its -users. - -When we speak of free software, we are referring to freedom, not price. Our -General Public Licenses are designed to make sure that you have the freedom -to distribute copies of free software (and charge for them if you wish), that -you receive source code or can get it if you want it, that you can change the -software or use pieces of it in new free programs, and that you know you can -do these things. - -Developers that use our General Public Licenses protect your rights with two -steps: (1) assert copyright on the software, and (2) offer you this License -which gives you legal permission to copy, distribute and/or modify the -software. - -A secondary benefit of defending all users' freedom is that improvements made -in alternate versions of the program, if they receive widespread use, become -available for other developers to incorporate. Many developers of free -software are heartened and encouraged by the resulting cooperation. However, -in the case of software used on network servers, this result may fail to come -about. The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its source -code to the public. - -The GNU Affero General Public License is designed specifically to ensure -that, in such cases, the modified source code becomes available to the -community. It requires the operator of a network server to provide the source -code of the modified version running there to the users of that server. -Therefore, public use of a modified version, on a publicly accessible server, -gives the public access to the source code of the modified version. - -An older license, called the Affero General Public License and published by -Affero, was designed to accomplish similar goals. This is a different -license, not a version of the Affero GPL, but Affero has released a new -version of the Affero GPL which permits relicensing under this license. - -The precise terms and conditions for copying, distribution and modification -follow. - -TERMS AND CONDITIONS -0. Definitions. -"This License" refers to version 3 of the GNU Affero General Public License. - -"Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - -"The Program" refers to any copyrightable work licensed under this License. -Each licensee is addressed as "you". "Licensees" and "recipients" may be -individuals or organizations. - -To "modify" a work means to copy from or adapt all or part of the work in a -fashion requiring copyright permission, other than the making of an exact -copy. The resulting work is called a "modified version" of the earlier work -or a work "based on" the earlier work. - -A "covered work" means either the unmodified Program or a work based on the -Program. - -To "propagate" a work means to do anything with it that, without permission, -would make you directly or secondarily liable for infringement under -applicable copyright law, except executing it on a computer or modifying a -private copy. Propagation includes copying, distribution (with or without -modification), making available to the public, and in some countries other -activities as well. - -To "convey" a work means any kind of propagation that enables other parties -to make or receive copies. Mere interaction with a user through a computer -network, with no transfer of a copy, is not conveying. - -An interactive user interface displays "Appropriate Legal Notices" to the -extent that it includes a convenient and prominently visible feature that (1) -displays an appropriate copyright notice, and (2) tells the user that there -is no warranty for the work (except to the extent that warranties are -provided), that licensees may convey the work under this License, and how to -view a copy of this License. If the interface presents a list of user -commands or options, such as a menu, a prominent item in the list meets this -criterion. - -1. Source Code. -The "source code" for a work means the preferred form of the work for making -modifications to it. "Object code" means any non-source form of a work. - -A "Standard Interface" means an interface that either is an official standard -defined by a recognized standards body, or, in the case of interfaces -specified for a particular programming language, one that is widely used -among developers working in that language. - -The "System Libraries" of an executable work include anything, other than the -work as a whole, that (a) is included in the normal form of packaging a Major -Component, but which is not part of that Major Component, and (b) serves only -to enable use of the work with that Major Component, or to implement a -Standard Interface for which an implementation is available to the public in -source code form. A "Major Component", in this context, means a major -essential component (kernel, window system, and so on) of the specific -operating system (if any) on which the executable work runs, or a compiler -used to produce the work, or an object code interpreter used to run it. - -The "Corresponding Source" for a work in object code form means all the -source code needed to generate, install, and (for an executable work) run the -object code and to modify the work, including scripts to control those -activities. However, it does not include the work's System Libraries, or -general-purpose tools or generally available free programs which are used -unmodified in performing those activities but which are not part of the work. -For example, Corresponding Source includes interface definition files -associated with source files for the work, and the source code for shared -libraries and dynamically linked subprograms that the work is specifically -designed to require, such as by intimate data communication or control flow -between those subprograms and other parts of the work. - -The Corresponding Source need not include anything that users can regenerate -automatically from other parts of the Corresponding Source. - -The Corresponding Source for a work in source code form is that same work. - -2. Basic Permissions. -All rights granted under this License are granted for the term of copyright -on the Program, and are irrevocable provided the stated conditions are met. -This License explicitly affirms your unlimited permission to run the -unmodified Program. The output from running a covered work is covered by this -License only if the output, given its content, constitutes a covered work. -This License acknowledges your rights of fair use or other equivalent, as -provided by copyright law. - -You may make, run and propagate covered works that you do not convey, without -conditions so long as your license otherwise remains in force. You may convey -covered works to others for the sole purpose of having them make -modifications exclusively for you, or provide you with facilities for running -those works, provided that you comply with the terms of this License in -conveying all material for which you do not control copyright. Those thus -making or running the covered works for you must do so exclusively on your -behalf, under your direction and control, on terms that prohibit them from -making any copies of your copyrighted material outside their relationship -with you. - -Conveying under any other circumstances is permitted solely under the -conditions stated below. Sublicensing is not allowed; section 10 makes it -unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. -No covered work shall be deemed part of an effective technological measure -under any applicable law fulfilling obligations under article 11 of the WIPO -copyright treaty adopted on 20 December 1996, or similar laws prohibiting or -restricting circumvention of such measures. - -When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention is -effected by exercising rights under this License with respect to the covered -work, and you disclaim any intention to limit operation or modification of -the work as a means of enforcing, against the work's users, your or third -parties' legal rights to forbid circumvention of technological measures. - -4. Conveying Verbatim Copies. -You may convey verbatim copies of the Program's source code as you receive -it, in any medium, provided that you conspicuously and appropriately publish -on each copy an appropriate copyright notice; keep intact all notices stating -that this License and any non-permissive terms added in accord with section 7 -apply to the code; keep intact all notices of the absence of any warranty; -and give all recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, and you -may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. -You may convey a work based on the Program, or the modifications to produce -it from the Program, in the form of source code under the terms of section 4, -provided that you also meet all of these conditions: - -a) The work must carry prominent notices stating that you modified it, and -giving a relevant date. -b) The work must carry prominent notices stating that it is released under -this License and any conditions added under section 7. This requirement -modifies the requirement in section 4 to "keep intact all notices". -c) You must license the entire work, as a whole, under this License to anyone -who comes into possession of a copy. This License will therefore apply, along -with any applicable section 7 additional terms, to the whole of the work, and -all its parts, regardless of how they are packaged. This License gives no -permission to license the work in any other way, but it does not invalidate -such permission if you have separately received it. -d) If the work has interactive user interfaces, each must display Appropriate -Legal Notices; however, if the Program has interactive interfaces that do not -display Appropriate Legal Notices, your work need not make them do so. -A compilation of a covered work with other separate and independent works, -which are not by their nature extensions of the covered work, and which are -not combined with it such as to form a larger program, in or on a volume of a -storage or distribution medium, is called an "aggregate" if the compilation -and its resulting copyright are not used to limit the access or legal rights -of the compilation's users beyond what the individual works permit. Inclusion -of a covered work in an aggregate does not cause this License to apply to the -other parts of the aggregate. - -6. Conveying Non-Source Forms. -You may convey a covered work in object code form under the terms of sections -4 and 5, provided that you also convey the machine-readable Corresponding -Source under the terms of this License, in one of these ways: - -a) Convey the object code in, or embodied in, a physical product (including a -physical distribution medium), accompanied by the Corresponding Source fixed -on a durable physical medium customarily used for software interchange. -b) Convey the object code in, or embodied in, a physical product (including a -physical distribution medium), accompanied by a written offer, valid for at -least three years and valid for as long as you offer spare parts or customer -support for that product model, to give anyone who possesses the object code -either (1) a copy of the Corresponding Source for all the software in the -product that is covered by this License, on a durable physical medium -customarily used for software interchange, for a price no more than your -reasonable cost of physically performing this conveying of source, or (2) -access to copy the Corresponding Source from a network server at no charge. -c) Convey individual copies of the object code with a copy of the written -offer to provide the Corresponding Source. This alternative is allowed only -occasionally and noncommercially, and only if you received the object code -with such an offer, in accord with subsection 6b. -d) Convey the object code by offering access from a designated place (gratis -or for a charge), and offer equivalent access to the Corresponding Source in -the same way through the same place at no further charge. You need not -require recipients to copy the Corresponding Source along with the object -code. If the place to copy the object code is a network server, the -Corresponding Source may be on a different server (operated by you or a third -party) that supports equivalent copying facilities, provided you maintain -clear directions next to the object code saying where to find the -Corresponding Source. Regardless of what server hosts the Corresponding -Source, you remain obligated to ensure that it is available for as long as -needed to satisfy these requirements. -e) Convey the object code using peer-to-peer transmission, provided you -inform other peers where the object code and Corresponding Source of the work -are being offered to the general public at no charge under subsection 6d. -A separable portion of the object code, whose source code is excluded from -the Corresponding Source as a System Library, need not be included in -conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any tangible -personal property which is normally used for personal, family, or household -purposes, or (2) anything designed or sold for incorporation into a dwelling. -In determining whether a product is a consumer product, doubtful cases shall -be resolved in favor of coverage. For a particular product received by a -particular user, "normally used" refers to a typical or common use of that -class of product, regardless of the status of the particular user or of the -way in which the particular user actually uses, or expects or is expected to -use, the product. A product is a consumer product regardless of whether the -product has substantial commercial, industrial or non-consumer uses, unless -such uses represent the only significant mode of use of the product. - -"Installation Information" for a User Product means any methods, procedures, -authorization keys, or other information required to install and execute -modified versions of a covered work in that User Product from a modified -version of its Corresponding Source. The information must suffice to ensure -that the continued functioning of the modified object code is in no case -prevented or interfered with solely because modification has been made. - -If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as part of -a transaction in which the right of possession and use of the User Product is -transferred to the recipient in perpetuity or for a fixed term (regardless of -how the transaction is characterized), the Corresponding Source conveyed -under this section must be accompanied by the Installation Information. But -this requirement does not apply if neither you nor any third party retains -the ability to install modified object code on the User Product (for example, -the work has been installed in ROM). - -The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates for -a work that has been modified or installed by the recipient, or for the User -Product in which it has been modified or installed. Access to a network may -be denied when the modification itself materially and adversely affects the -operation of the network or violates the rules and protocols for -communication across the network. - -Corresponding Source conveyed, and Installation Information provided, in -accord with this section must be in a format that is publicly documented (and -with an implementation available to the public in source code form), and must -require no special password or key for unpacking, reading or copying. - -7. Additional Terms. -"Additional permissions" are terms that supplement the terms of this License -by making exceptions from one or more of its conditions. Additional -permissions that are applicable to the entire Program shall be treated as -though they were included in this License, to the extent that they are valid -under applicable law. If additional permissions apply only to part of the -Program, that part may be used separately under those permissions, but the -entire Program remains governed by this License without regard to the -additional permissions. - -When you convey a copy of a covered work, you may at your option remove any -additional permissions from that copy, or from any part of it. (Additional -permissions may be written to require their own removal in certain cases when -you modify the work.) You may place additional permissions on material, added -by you to a covered work, for which you have or can give appropriate -copyright permission. - -Notwithstanding any other provision of this License, for material you add to -a covered work, you may (if authorized by the copyright holders of that -material) supplement the terms of this License with terms: - -a) Disclaiming warranty or limiting liability differently from the terms of -sections 15 and 16 of this License; or -b) Requiring preservation of specified reasonable legal notices or author -attributions in that material or in the Appropriate Legal Notices displayed -by works containing it; or -c) Prohibiting misrepresentation of the origin of that material, or requiring -that modified versions of such material be marked in reasonable ways as -different from the original version; or -d) Limiting the use for publicity purposes of names of licensors or authors -of the material; or -e) Declining to grant rights under trademark law for use of some trade names, -trademarks, or service marks; or -f) Requiring indemnification of licensors and authors of that material by -anyone who conveys the material (or modified versions of it) with contractual -assumptions of liability to the recipient, for any liability that these -contractual assumptions directly impose on those licensors and authors. -All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is governed -by this License along with a term that is a further restriction, you may -remove that term. If a license document contains a further restriction but -permits relicensing or conveying under this License, you may add to a covered -work material governed by the terms of that license document, provided that -the further restriction does not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you must -place, in the relevant source files, a statement of the additional terms that -apply to those files, or a notice indicating where to find the applicable -terms. - -Additional terms, permissive or non-permissive, may be stated in the form of -a separately written license, or stated as exceptions; the above requirements -apply either way. - -8. Termination. -You may not propagate or modify a covered work except as expressly provided -under this License. Any attempt otherwise to propagate or modify it is void, -and will automatically terminate your rights under this License (including -any patent licenses granted under the third paragraph of section 11). - -However, if you cease all violation of this License, then your license from a -particular copyright holder is reinstated (a) provisionally, unless and until -the copyright holder explicitly and finally terminates your license, and (b) -permanently, if the copyright holder fails to notify you of the violation by -some reasonable means prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is reinstated -permanently if the copyright holder notifies you of the violation by some -reasonable means, this is the first time you have received notice of -violation of this License (for any work) from that copyright holder, and you -cure the violation prior to 30 days after your receipt of the notice. - -Termination of your rights under this section does not terminate the licenses -of parties who have received copies or rights from you under this License. If -your rights have been terminated and not permanently reinstated, you do not -qualify to receive new licenses for the same material under section 10. - -9. Acceptance Not Required for Having Copies. -You are not required to accept this License in order to receive or run a copy -of the Program. Ancillary propagation of a covered work occurring solely as a -consequence of using peer-to-peer transmission to receive a copy likewise -does not require acceptance. However, nothing other than this License grants -you permission to propagate or modify any covered work. These actions -infringe copyright if you do not accept this License. Therefore, by modifying -or propagating a covered work, you indicate your acceptance of this License -to do so. - -10. Automatic Licensing of Downstream Recipients. -Each time you convey a covered work, the recipient automatically receives a -license from the original licensors, to run, modify and propagate that work, -subject to this License. You are not responsible for enforcing compliance by -third parties with this License. - -An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered work -results from an entity transaction, each party to that transaction who -receives a copy of the work also receives whatever licenses to the work the -party's predecessor in interest had or could give under the previous -paragraph, plus a right to possession of the Corresponding Source of the work -from the predecessor in interest, if the predecessor has it or can get it -with reasonable efforts. - -You may not impose any further restrictions on the exercise of the rights -granted or affirmed under this License. For example, you may not impose a -license fee, royalty, or other charge for exercise of rights granted under -this License, and you may not initiate litigation (including a cross-claim or -counterclaim in a lawsuit) alleging that any patent claim is infringed by -making, using, selling, offering for sale, or importing the Program or any -portion of it. - -11. Patents. -A "contributor" is a copyright holder who authorizes use under this License -of the Program or a work on which the Program is based. The work thus -licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims owned or -controlled by the contributor, whether already acquired or hereafter -acquired, that would be infringed by some manner, permitted by this License, -of making, using, or selling its contributor version, but do not include -claims that would be infringed only as a consequence of further modification -of the contributor version. For purposes of this definition, "control" -includes the right to grant patent sublicenses in a manner consistent with -the requirements of this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free patent -license under the contributor's essential patent claims, to make, use, sell, -offer for sale, import and otherwise run, modify and propagate the contents -of its contributor version. - -In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent (such -as an express permission to practice a patent or covenant not to sue for -patent infringement). To "grant" such a patent license to a party means to -make such an agreement or commitment not to enforce a patent against the -party. - -If you convey a covered work, knowingly relying on a patent license, and the -Corresponding Source of the work is not available for anyone to copy, free of -charge and under the terms of this License, through a publicly available -network server or other readily accessible means, then you must either (1) -cause the Corresponding Source to be so available, or (2) arrange to deprive -yourself of the benefit of the patent license for this particular work, or -(3) arrange, in a manner consistent with the requirements of this License, to -extend the patent license to downstream recipients. "Knowingly relying" means -you have actual knowledge that, but for the patent license, your conveying -the covered work in a country, or your recipient's use of the covered work in -a country, would infringe one or more identifiable patents in that country -that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or arrangement, -you convey, or propagate by procuring conveyance of, a covered work, and -grant a patent license to some of the parties receiving the covered work -authorizing them to use, propagate, modify or convey a specific copy of the -covered work, then the patent license you grant is automatically extended to -all recipients of the covered work and works based on it. - -A patent license is "discriminatory" if it does not include within the scope -of its coverage, prohibits the exercise of, or is conditioned on the -non-exercise of one or more of the rights that are specifically granted under -this License. You may not convey a covered work if you are a party to an -arrangement with a third party that is in the business of distributing -software, under which you make payment to the third party based on the extent -of your activity of conveying the work, and under which the third party -grants, to any of the parties who would receive the covered work from you, a -discriminatory patent license (a) in connection with copies of the covered -work conveyed by you (or copies made from those copies), or (b) primarily for -and in connection with specific products or compilations that contain the -covered work, unless you entered into that arrangement, or that patent -license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting any -implied license or other defenses to infringement that may otherwise be -available to you under applicable patent law. - -12. No Surrender of Others' Freedom. -If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not excuse -you from the conditions of this License. If you cannot convey a covered work -so as to satisfy simultaneously your obligations under this License and any -other pertinent obligations, then as a consequence you may not convey it at -all. For example, if you agree to terms that obligate you to collect a -royalty for further conveying from those to whom you convey the Program, the -only way you could satisfy both those terms and this License would be to -refrain entirely from conveying the Program. - -13. Remote Network Interaction; Use with the GNU General Public License. -Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users interacting -with it remotely through a computer network (if your version supports such -interaction) an opportunity to receive the Corresponding Source of your -version by providing access to the Corresponding Source from a network server -at no charge, through some standard or customary means of facilitating -copying of software. This Corresponding Source shall include the -Corresponding Source for any work covered by version 3 of the GNU General -Public License that is incorporated pursuant to the following paragraph. - -Notwithstanding any other provision of this License, you have permission to -link or combine any covered work with a work licensed under version 3 of the -GNU General Public License into a single combined work, and to convey the -resulting work. The terms of this License will continue to apply to the part -which is the covered work, but the work with which it is combined will remain -governed by version 3 of the GNU General Public License. - -14. Revised Versions of this License. -The Free Software Foundation may publish revised and/or new versions of the -GNU Affero General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies that a certain numbered version of the GNU Affero General Public -License "or any later version" applies to it, you have the option of -following the terms and conditions either of that numbered version or of any -later version published by the Free Software Foundation. If the Program does -not specify a version number of the GNU Affero General Public License, you -may choose any version ever published by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future versions of the -GNU Affero General Public License can be used, that proxy's public statement -of acceptance of a version permanently authorizes you to choose that version -for the Program. - -Later license versions may give you additional or different permissions. -However, no additional obligations are imposed on any author or copyright -holder as a result of your choosing to follow a later version. - -15. Disclaimer of Warranty. -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE -LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR -OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, -EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE -ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. -SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY -SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL -ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE -PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE -OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR -DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR -A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH -HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. -If the disclaimer of warranty and limitation of liability provided above -cannot be given local legal effect according to their terms, reviewing courts -shall apply local law that most closely approximates an absolute waiver of -all civil liability in connection with the Program, unless a warranty or -assumption of liability accompanies a copy of the Program in return for a -fee. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Programs -If you develop a new program, and you want it to be of the greatest possible -use to the public, the best way to achieve this is to make it free software -which everyone can redistribute and change under these terms. - -To do so, attach the following notices to the program. It is safest to attach -them to the start of each source file to most effectively state the exclusion -of warranty; and each file should have at least the "copyright" line and a -pointer to where the full notice is found. - -SpacetimeDB: A database which replaces your server. -Copyright (C) 2023 Clockwork Laboratories, Inc. - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . -Also add information on how to contact you by electronic and paper mail. - -If your software can interact with users remotely through a computer network, -you should also make sure that it provides a way for users to get its source. -For example, if your program is a web application, its interface could -display a "Source" link that leads users to an archive of the code. There are -many ways you could offer source, and different solutions will be better for -different programs; see section 13 for the specific requirements. - -You should also get your employer (if you work as a programmer) or school, if -any, to sign a "copyright disclaimer" for the program, if necessary. For more -information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/spacetime-stripe-ts/example/spacetimedb/README.md b/spacetime-stripe-ts/example/spacetimedb/README.md index 34d68db1554..011ca24dcef 100644 --- a/spacetime-stripe-ts/example/spacetimedb/README.md +++ b/spacetime-stripe-ts/example/spacetimedb/README.md @@ -27,4 +27,4 @@ The parent test app's `pnpm run dev` calls this for you. ## License -[BSL 1.1](./LICENSE.txt), same as SpacetimeDB. +[BUSL 1.1](../../LICENSE.txt), same as SpacetimeDB. diff --git a/tools/doc-check.mjs b/tools/doc-check.mjs index 49f35c7ca78..37817184dbe 100644 --- a/tools/doc-check.mjs +++ b/tools/doc-check.mjs @@ -17,7 +17,6 @@ const documentationFiles = [ 'COMPONENTS.md', 'COMPONENTS_GETTING_STARTED.md', 'COMPONENTS_AUTHORING.md', - 'NPM_RELEASE_CHECKLIST.md', ...releasePackages.flatMap(packageDir => { const files = [`${packageDir}/README.md`]; const exampleReadme = `${packageDir}/example/README.md`; diff --git a/tools/release-check.mjs b/tools/release-check.mjs index 514cfb1c776..6944e4df7a0 100644 --- a/tools/release-check.mjs +++ b/tools/release-check.mjs @@ -27,6 +27,23 @@ const canonicalLicense = readFileSync( resolve(root, releasePackages[0], 'LICENSE.txt'), 'utf8' ); +const forbiddenComponentArtifacts = [ + [ + /\/(?:node_modules|dist|build|coverage|target|\.stdb-[^/]+)\//, + 'generated or local runtime directory', + ], + [/\/public\/app\.js(?:\.map)?$/, 'generated browser bundle'], + [ + /\/(?:pnpm-lock\.yaml|pnpm-workspace\.yaml)$/, + 'nested package-manager file', + ], + [/\/(?:\.env|\.stdb-server-token)$/, 'local secret or identity file'], + [/\.(?:log|tgz|zip|tmp|bak|orig|rej)$/i, 'temporary or archive file'], + [ + /\/(?:ROADMAP|AUDIT|REVIEW)[^/]*\.(?:md|html)$/i, + 'local planning or review document', + ], +]; function fail(packageName, message) { failures.push(`${packageName}: ${message}`); @@ -105,6 +122,16 @@ if (tracked.status !== 0) { if (!isComponentPath && normalizedRepositoryPath !== 'pnpm-lock.yaml') { continue; } + if (isComponentPath) { + for (const [pattern, description] of forbiddenComponentArtifacts) { + if (pattern.test(`/${normalizedRepositoryPath}`)) { + fail( + 'repository', + `${description} must not be tracked: ${repositoryPath}` + ); + } + } + } if ( /^spacetime-[^/]+-ts\/(?:module|app-module|store-module)(?:\/|$)/.test( normalizedRepositoryPath From 29a12ba7b688333ff37862655dca41de41350619 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 16:36:18 -0400 Subject: [PATCH 03/33] Remove unnecessary artifact policy --- tools/release-check.mjs | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/tools/release-check.mjs b/tools/release-check.mjs index 6944e4df7a0..514cfb1c776 100644 --- a/tools/release-check.mjs +++ b/tools/release-check.mjs @@ -27,23 +27,6 @@ const canonicalLicense = readFileSync( resolve(root, releasePackages[0], 'LICENSE.txt'), 'utf8' ); -const forbiddenComponentArtifacts = [ - [ - /\/(?:node_modules|dist|build|coverage|target|\.stdb-[^/]+)\//, - 'generated or local runtime directory', - ], - [/\/public\/app\.js(?:\.map)?$/, 'generated browser bundle'], - [ - /\/(?:pnpm-lock\.yaml|pnpm-workspace\.yaml)$/, - 'nested package-manager file', - ], - [/\/(?:\.env|\.stdb-server-token)$/, 'local secret or identity file'], - [/\.(?:log|tgz|zip|tmp|bak|orig|rej)$/i, 'temporary or archive file'], - [ - /\/(?:ROADMAP|AUDIT|REVIEW)[^/]*\.(?:md|html)$/i, - 'local planning or review document', - ], -]; function fail(packageName, message) { failures.push(`${packageName}: ${message}`); @@ -122,16 +105,6 @@ if (tracked.status !== 0) { if (!isComponentPath && normalizedRepositoryPath !== 'pnpm-lock.yaml') { continue; } - if (isComponentPath) { - for (const [pattern, description] of forbiddenComponentArtifacts) { - if (pattern.test(`/${normalizedRepositoryPath}`)) { - fail( - 'repository', - `${description} must not be tracked: ${repositoryPath}` - ); - } - } - } if ( /^spacetime-[^/]+-ts\/(?:module|app-module|store-module)(?:\/|$)/.test( normalizedRepositoryPath From 0b11e195e7ed9151498007c2be906bf4e0bf8997 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 17:15:27 -0400 Subject: [PATCH 04/33] Use submodule terminology --- .github/workflows/ci.yml | 14 +- .gitignore | 3 +- .prettierignore | 1 - COMPONENTS.md | 151 --------------- COMPONENTS_AUTHORING.md | 129 ------------- COMPONENTS_GETTING_STARTED.md | 173 ------------------ README.md | 5 - package.json | 34 ++-- spacetime-agents-ts/example/README.md | 2 +- spacetime-api-keys-ts/README.md | 4 +- spacetime-api-keys-ts/example/README.md | 8 +- spacetime-auth-ts/example/README.md | 4 +- spacetime-cron-ts/example/README.md | 6 +- .../example/spacetimedb/src/index.ts | 2 +- spacetime-files-ts/example/README.md | 8 +- spacetime-grid-ts/README.md | 2 +- spacetime-grid-ts/example/README.md | 10 +- .../example/public/hex-geometry.js | 2 +- spacetime-lobby-ts/example/README.md | 12 +- spacetime-posthog-ts/example/README.md | 14 +- spacetime-presence-ts/example/README.md | 8 +- spacetime-rate-limit-ts/example/README.md | 8 +- spacetime-resend-ts/example/README.md | 10 +- spacetime-stripe-ts/example/README.md | 8 +- .../spacetimedb/src/submodule/operations.ts | 7 +- tools/consumer-install-check.mjs | 4 +- tools/doc-check.mjs | 3 - tools/release-check.mjs | 6 +- 28 files changed, 86 insertions(+), 552 deletions(-) delete mode 100644 COMPONENTS.md delete mode 100644 COMPONENTS_AUTHORING.md delete mode 100644 COMPONENTS_GETTING_STARTED.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0c6352d82c..934e04f96b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1687,10 +1687,10 @@ jobs: # if: always() # run: spacetime sql quickstart-chat "SELECT * FROM user" - typescript-components: + typescript-submodules: needs: [merge_queue_noop] if: ${{ needs.merge_queue_noop.outputs.skip != 'true' }} - name: TypeScript - Components + name: TypeScript - Submodules runs-on: spacetimedb-new-runner-2 steps: - name: Checkout repository @@ -1712,11 +1712,11 @@ jobs: "$RUNNER_TEMP/spacetime/bin/spacetime" version install 2.8.3 "$RUNNER_TEMP/spacetime/bin/spacetime" version use 2.8.3 - - name: Check components - run: pnpm components:check + - name: Check submodules + run: pnpm submodules:check - - name: Build component modules and examples - run: pnpm components:build + - name: Build submodule packages and examples + run: pnpm submodules:build - name: Audit packed production dependencies - run: pnpm components:audit:prod + run: pnpm submodules:audit:prod diff --git a/.gitignore b/.gitignore index 4571c07d24f..b795e61c6ae 100644 --- a/.gitignore +++ b/.gitignore @@ -205,8 +205,7 @@ __pycache__/ ## JetBrains .idea/ -# TypeScript component development outputs -.component-packs/ +# TypeScript submodule development outputs .stdb-*/ spacetime-*-ts/example/public/app.js spacetime-*-ts/example/public/app.js.map diff --git a/.prettierignore b/.prettierignore index 632ca9e1116..635f97ef4d8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,4 +9,3 @@ coverage **/src/codegen/** **/ts-codegen/** .stdb-* -.component-packs diff --git a/COMPONENTS.md b/COMPONENTS.md deleted file mode 100644 index d96f37116ef..00000000000 --- a/COMPONENTS.md +++ /dev/null @@ -1,151 +0,0 @@ -# SpacetimeDB TypeScript Components - -Reusable packages for SpacetimeDB TypeScript modules. Submodules run inside a -module, own or extend transactional state, and use `ctx.http.fetch` when they -need an external API. Browser clients connect directly to SpacetimeDB. - -Mountable submodules target the released SpacetimeDB 2.8 TypeScript SDK and -CLI. Package peer dependencies accept compatible 2.x releases from 2.8.3 -onward. Repository development and release verification use version 2.8.3. - -## Start here - -- **Adding a component to an application:** follow - [Getting started](./COMPONENTS_GETTING_STARTED.md), then use the - package-specific README. -- **Evaluating the components:** choose a runnable application from the package - table. Example READMEs include the exact local database, port, credentials, - and first successful action. -- **Contributing to this repository:** use the repository-development workflow - below. - -A typical mountable component starts with: - -```bash -npm install @spacetimedb/rate-limit spacetimedb@^2.8.3 -``` - -```ts -import { schema } from 'spacetimedb/server'; -import * as rateLimit from '@spacetimedb/rate-limit/submodule'; - -const spacetimedb = schema({ rateLimit }); -export default spacetimedb; - -export const init = spacetimedb.init(ctx => { - rateLimit.installRateLimit(ctx.as.rateLimit); -}); -``` - -The host application owns authorization and exposes operations and views for -its users. The package READMEs and full examples show that boundary. - -## Packages - -| Package | Purpose | Runnable example | -| ------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------- | -| [`@spacetimedb/agents`](./spacetime-agents-ts/) | Agent definitions, typed tools, model providers, and embeddings | [Multi-provider chat](./spacetime-agents-ts/example/) | -| [`@spacetimedb/api-keys`](./spacetime-api-keys-ts/) | API key issuance, verification, rotation, and audit history | [Colony sharing](./spacetime-api-keys-ts/example/) | -| [`@spacetimedb/auth`](./spacetime-auth-ts/) | Password and OAuth authentication, sessions, and profiles | [Authenticated notes](./spacetime-auth-ts/example/) | -| [`@spacetimedb/cron`](./spacetime-cron-ts/) | Durable calendar and interval scheduling | [Cron dashboard](./spacetime-cron-ts/example/) | -| [`@spacetimedb/crypto`](./spacetime-crypto-ts/) | Hashing, encoding, and webhook-signature helpers | Used by the provider examples | -| [`@spacetimedb/files`](./spacetime-files-ts/) | Transactional file storage, visibility, and serving | [Vault](./spacetime-files-ts/example/) | -| [`@spacetimedb/grid`](./spacetime-grid-ts/) | Square and hex grids, pathfinding, ranges, and movement | [Grid Tactics](./spacetime-grid-ts/example/) | -| [`@spacetimedb/lobby`](./spacetime-lobby-ts/) | Queues, rooms, ranked matching, and match results | [Starclash](./spacetime-lobby-ts/example/) | -| [`@spacetimedb/posthog`](./spacetime-posthog-ts/) | PostHog capture, outbox delivery, and feature flags | [Context Cafe](./spacetime-posthog-ts/example/) | -| [`@spacetimedb/presence`](./spacetime-presence-ts/) | Presence, heartbeat, activity, and expiration | [Presence Chat](./spacetime-presence-ts/example/) | -| [`@spacetimedb/rate-limit`](./spacetime-rate-limit-ts/) | Fixed-window rate limiting and bounded sweeps | [Powerhouse](./spacetime-rate-limit-ts/example/) | -| [`@spacetimedb/resend`](./spacetime-resend-ts/) | Resend email delivery and signed webhook ingestion | [Dispatch](./spacetime-resend-ts/example/) | -| [`@spacetimedb/retry`](./spacetime-retry-ts/) | Typed retry dispatch, backoff, and attempt history | [Cron dashboard](./spacetime-cron-ts/example/) | -| [`@spacetimedb/stripe`](./spacetime-stripe-ts/) | Stripe catalog, checkout, billing state, and webhooks | [Premium Store](./spacetime-stripe-ts/example/) | - -Each package ships TypeScript source, a BUSL-1.1 license, API documentation, -and a runnable integration example where the submodule needs host-module -wiring. - -## Submodule model - -Mountable packages expose `./submodule`. That entrypoint exports the schema, -registered operations, views, and an `install` helper. The host module -owns lifecycle hooks and route registration. - -Host-configured packages such as `agents`, `cron`, and `retry` keep -application-specific dispatch typed in the consuming module. Root and -documented subpath exports provide pure helpers. - -Shared rules: - -- Secrets live in private tables, never in public procedure arguments. -- Admin identities are seeded from the publishing owner during initialization. -- Per-user and per-membership data stays private and is exposed through scoped - views. -- Reducers use context time and randomness so execution remains deterministic. -- Scheduled work is bounded per invocation and leaves observable history. - -See [Component authoring](./COMPONENTS_AUTHORING.md) for package conventions. - -## Repository development - -Install the official SpacetimeDB launcher, select 2.8.3, and install workspace -dependencies before running the repository gates: - -```bash -spacetime version install 2.8.3 -spacetime version use 2.8.3 -pnpm install -``` - -The build gate rejects any CLI or embedded library version other than 2.8.3. -Component manifests use pnpm workspace references to the repository SDK. - -Package checks: - -```bash -pnpm components:check -pnpm components:build -pnpm components:consumer:check -``` - -`components:consumer:check` packs all 14 releases, installs them into a clean -temporary project with `spacetimedb@2.8.3`, resolves every public export, and -builds a host module containing all mountable components. - -Start the released standalone server with `spacetime start` when running an -integration example. Examples use the standard `local` server alias and port -`3000`. - -To publish and test every example with disposable local databases: - -```bash -pnpm components:browser:install -pnpm components:smoke:examples:ephemeral -``` - -This command requires an active local server and CLI login. It creates a unique -database for each example, generates client bindings, builds the browser app, -checks the HTTP surface, opens the app in Chromium, and exercises one safe UI -interaction. It fails on browser errors, unexpected HTTP errors, and missing -static assets. It removes each disposable database after the test. Provider -credentials are not used. - -Use the named-database command only when you intend to replace the normal local -example databases: - -```bash -pnpm components:smoke:examples:fresh -``` - -This command publishes with `--delete-data=always`. Its script name and required -confirmation flag make the data deletion explicit. Use each provider package's -opt-in test separately when validating real credentials. - -Run only the server and HTTP checks when Chromium is unavailable: - -```bash -pnpm components:smoke:examples:http:fresh -``` - -With a local SpacetimeDB server running, the Stripe and Resend synthetic smoke -tests need no provider credentials. Credentialed provider tests, such as -Stripe's sandbox E2E suite, are documented in the corresponding package -README. diff --git a/COMPONENTS_AUTHORING.md b/COMPONENTS_AUTHORING.md deleted file mode 100644 index a60590ef4f3..00000000000 --- a/COMPONENTS_AUTHORING.md +++ /dev/null @@ -1,129 +0,0 @@ -# Authoring a SpacetimeDB TypeScript submodule - -This guide defines the public conventions for packages in this repository. -A package may be a pure helper, a host-configured factory, or a mountable -submodule. Every entry point must match the package's implemented capabilities. - -## Package shapes - -- **Helper:** pure functions or typed dispatch. Examples include `crypto`, - `agents`, and `cron`. -- **Factory:** creates tables and operations from host-supplied types or - handlers. The host mounts the returned pieces into its schema. -- **Mountable submodule:** exports a reusable schema surface and an installation - helper. The host owns initialization and route wiring. - -Demo-specific tables, model names, task variants, and business rules belong in -`example/` or local build fixtures. Published `./submodule` exports contain the -reusable surface. - -## Package setup - -Use the scoped `@spacetimedb/` package name and publish TypeScript source directly. -Declare `spacetimedb` as a peer dependency when the public API uses its types. - -```json -{ - "name": "@spacetimedb/your-thing", - "version": "0.1.0", - "license": "BUSL-1.1", - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { "types": "./src/index.ts", "default": "./src/index.ts" } - }, - "files": ["src", "LICENSE.txt", "README.md"], - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" - }, - "peerDependencies": { "spacetimedb": "workspace:^" }, - "devDependencies": { "spacetimedb": "workspace:*" } -} -``` - -Add subpath exports only when they are intentional public APIs. Every exported -path must be included in `files` and must work in the packed tarball. - -Use pnpm workspace references for internal development. pnpm converts -`workspace:^` to a compatible release range when it packs a package. Pin the -CI CLI to the repository SDK version so release results are reproducible. - -## Layout - -```text -spacetime-your-thing-ts/ -|-- src/ # published implementation and public types -|-- example/ # optional runnable integration -| `-- spacetimedb/ # example host module -|-- spacetimedb/ # optional canonical module fixture -|-- scripts/ # package tests and release helpers -|-- package.json -|-- README.md -`-- LICENSE.txt -``` - -Use `spacetime--ts` for every top-level package directory. Use -`spacetimedb` for every host or fixture module directory. Publish a fixture only -when it is a documented, reusable entry point. - -## Runtime conventions - -1. External HTTP belongs in a procedure or HTTP handler. Reducers remain - deterministic. -2. Use `ctx.timestamp` and context-provided randomness in module operations. -3. Store API keys and signing secrets in private tables. Routine public - operations accept product data and return safe results. -4. Seed the publishing owner as the initial admin during `init`. -5. Keep per-user data private and expose it through caller-scoped views. -6. Bound scheduled and batch work. Preserve useful status or attempt history. -7. Treat outbound side effects as at-least-once unless the integration supplies - and enforces an idempotency key. -8. Use `snake_case` for database table and operation names. Keep TypeScript - identifiers readable and consistent with the surrounding package. - -For scheduled-table forward references, capture the registered reducer and -validate the wiring during module definition. - -## README requirements - -Every package README uses these top-level sections in this order: - -1. `Install` -2. `Usage` -3. `API` -4. `Limitations` -5. `Testing` -6. `License` - -Start with one plain-language paragraph that states what the package does and -who owns persistence, authorization, and lifecycle wiring. Examples must be -syntactically valid and use current exports. Include performance numbers, -provider claims, and platform statements only when the repository verifies and -maintains them. - -Under `Usage`, include an `Integrate into an application` subsection. It must -identify the package as a helper, factory, or mountable submodule and show the -smallest complete host integration. Avoid unexplained identifiers in the first -snippet. If application-specific functions are unavoidable, label the snippet -as a skeleton and name every placeholder. Link full examples with repository -URLs that continue to work when npm renders the packed README. - -Example READMEs must include `Prerequisites`, `Quick start`, and `Use in your -project`. State that checked-in workspace dependencies are for repository -development and show the published npm install command. Environment-file setup -must use one cross-platform command or show both Bash and PowerShell forms. - -## Release checks - -Before publishing: - -```bash -pnpm install -pnpm components:check -pnpm components:build -``` - -The root lint command also validates Markdown links, TypeScript/JavaScript code -fences, stale work-in-progress markers, and the required README structure. diff --git a/COMPONENTS_GETTING_STARTED.md b/COMPONENTS_GETTING_STARTED.md deleted file mode 100644 index 8b5d65a61e1..00000000000 --- a/COMPONENTS_GETTING_STARTED.md +++ /dev/null @@ -1,173 +0,0 @@ -# Getting started - -Use this guide when you want to run an example or add one of these packages to -an existing SpacetimeDB TypeScript module. Repository contributors should use -the development commands in [Components](./COMPONENTS.md#repository-development). - -## Prerequisites - -- Node.js 20 or later. -- npm, or pnpm 10 when running this repository's examples. -- The official SpacetimeDB launcher with CLI version 2.8.3 selected. - -Install the launcher using the -[official SpacetimeDB installation guide](https://spacetimedb.com/docs/), then -select the release used by these packages: - -```bash -spacetime version install 2.8.3 -spacetime version use 2.8.3 -spacetime --version -``` - -For local development, start the standalone server in a separate terminal. It -runs in the foreground on port `3000` by default: - -```bash -spacetime start -``` - -In another terminal, verify the server and authenticate the identity that will -publish the module: - -```bash -spacetime server ping local -spacetime login -spacetime login show -``` - -## Run an example - -Each application under `/example` has its own credentials, port, and -first-use instructions. The common workflow is: - -```bash -cd /example -pnpm install -pnpm --dir spacetimedb install -pnpm run build:module:fresh -pnpm run dev -``` - -Every example stores its host module in `spacetimedb/`. A few examples are pnpm -workspaces that install both projects together. Follow the exact commands in -each example README. - -`build:module:fresh` deletes and recreates only that example's local database. -After the first run, use `build:module` when you want to preserve its rows. - -The checked-in examples use pnpm workspace dependencies so each example tests -the component source and SDK in this repository. Consumer projects install -published packages from npm. - -## Add a package to an application - -### 1. Install compatible releases - -Install the component in the directory that contains your SpacetimeDB module's -`package.json`. Install the TypeScript SDK as an explicit compatible peer: - -```bash -npm install @spacetimedb/ spacetimedb@^2.8.3 -``` - -The package README lists any companion components that must be installed too. -Use one package manager consistently in your application. The commands below -use npm; pnpm and compatible clients can install the same package versions. - -### 2. Choose the integration shape - -Packages in this repository have one of three shapes: - -- **Mountable submodule:** import `@spacetimedb//submodule`, add it - to `schema({ ... })`, and call its installer from the host `init` hook. -- **Factory:** construct tables and operations with application-specific typed - handlers, then register the returned pieces in the host schema. -- **Helper:** call its pure or context-aware functions from tables, reducers, - procedures, or handlers owned by the application. - -The package README identifies the shape and provides the package-specific code. -For a mountable submodule, the basic host structure is: - -```ts -import { schema } from 'spacetimedb/server'; -import * as component from '@spacetimedb//submodule'; - -const spacetimedb = schema({ component }); -export default spacetimedb; - -export const init = spacetimedb.init(ctx => { - component.installComponent(ctx.as.component); -}); -``` - -`component` and `installComponent` are placeholders. Use the exact namespace -and installer from the package README. The host owns its lifecycle hook. - -### 3. Add the application boundary - -A reusable component cannot decide who your users are or which browser actions -are safe. Before exposing it to a client: - -1. Map `ctx.sender` or your authentication session to an application subject. -2. Wrap component helpers in narrow host reducers or procedures. -3. Expose private component state through caller- or tenant-scoped host views. -4. Register only the HTTP routes your application needs. -5. Store provider credentials in private module state, never browser code or a - public table. - -The full examples show these boundaries. Reuse the boundary pattern and select -the product-specific tables or development helpers that fit your application. - -### 4. Publish and generate bindings - -From the application root, replace the placeholder paths with your layout: - -```bash -spacetime publish --server local --yes --module-path ./spacetimedb my-app -spacetime generate --lang typescript --out-dir ./src/module_bindings --module-path ./spacetimedb --yes -``` - -Publishing already builds the module. Use `--delete-data=always` only when you -intend to destroy the target database's data. - -### 5. Connect the client - -Import the generated connection into the browser. Server component code stays -inside the module: - -```ts -import { DbConnection, tables } from './module_bindings'; - -const connection = DbConnection.builder() - .withUri('ws://127.0.0.1:3000') - .withDatabaseName('my-app') - .onConnect(ctx => { - ctx.subscriptionBuilder().subscribe([tables.myApplicationView]); - }) - .build(); -``` - -The exact generated reducer, procedure, view, and table names come from the host -module you published. Generate bindings again whenever that public schema -changes. - -## Production checklist - -Before deploying an integration: - -- Replace example development servers and console mailers with production - infrastructure. -- Provision service identities and secrets explicitly at deployment time. -- Use TLS, secure cookies, host validation, request limits, and trusted proxy - configuration at the deployment boundary. -- Keep base tables private and verify that every public view is scoped to its - caller or tenant. -- Treat provider side effects as at-least-once and use idempotency keys where - supported. -- Test data-preserving upgrades before publishing over production data. - -For the underlying platform workflow, see the official -[TypeScript quickstart](https://spacetimedb.com/docs/quickstarts/typescript/), -[publishing guide](https://spacetimedb.com/docs/databases/building-publishing/), -and [client-binding guide](https://spacetimedb.com/docs/clients/codegen/). diff --git a/README.md b/README.md index caf0796c8d7..2df4c9ad1e7 100644 --- a/README.md +++ b/README.md @@ -170,11 +170,6 @@ Connect from any of these platforms: | **C#** (standalone and Unity) | [Get started](https://spacetimedb.com/docs/quickstarts/c-sharp) | | **C++** (Unreal Engine) | [Get started](https://spacetimedb.com/docs/quickstarts/c-plus-plus) | -## TypeScript Components - -Reusable TypeScript components and their runnable examples are listed in -[SpacetimeDB TypeScript Components](./COMPONENTS.md). - ## Running with Docker ```bash diff --git a/package.json b/package.json index 529bbe226a3..f2f5c02ab6a 100644 --- a/package.json +++ b/package.json @@ -8,26 +8,26 @@ "type": "module", "scripts": { "run-all": "pnpm -r -F ./crates/bindings-typescript -F ./crates/bindings-typescript/examples/quickstart-chat -F ./crates/bindings-typescript/test-app -F ./docs -F \"./templates/**\" run", - "format": "pnpm run-all format && pnpm components:format && prettier eslint.config.js --write", - "lint": "pnpm run-all lint && pnpm components:lint && prettier eslint.config.js --check", + "format": "pnpm run-all format && pnpm submodules:format && prettier eslint.config.js --write", + "lint": "pnpm run-all lint && pnpm submodules:lint && prettier eslint.config.js --check", "build": "pnpm run-all build", - "test": "pnpm run-all test && pnpm components:test", + "test": "pnpm run-all test && pnpm submodules:test", "generate": "pnpm run-all generate", "clean": "pnpm -r exec rimraf dist .tsbuildinfo coverage", - "components:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"COMPONENTS*.md\" \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", - "components:lint": "node tools/doc-check.mjs && node tools/release-check.mjs && node tools/check-example-assets.mjs && eslint \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts && pnpm -r --filter \"./spacetime-*-ts\" run lint", - "components:test": "node tools/run-package-checks.mjs && node tools/run-example-tests.mjs", - "components:toolchain:check": "node tools/check-spacetime-release.mjs", - "components:build": "pnpm components:toolchain:check && pnpm --dir crates/bindings-typescript run build && node tools/run-module-builds.mjs && node tools/run-example-builds.mjs", - "components:consumer:check": "node tools/consumer-install-check.mjs", - "components:audit:prod": "node tools/run-production-audits.mjs", - "components:test:cron:local": "pnpm --dir spacetime-cron-ts run test:module:local && pnpm --dir spacetime-cron-ts run test:recovery", - "components:browser:install": "playwright install chromium", - "components:smoke:examples:http:fresh": "node tools/run-example-smokes.mjs --confirm-delete-data", - "components:smoke:examples:ephemeral": "node tools/run-example-smokes.mjs --ephemeral --browser", - "components:smoke:examples:fresh": "node tools/run-example-smokes.mjs --confirm-delete-data --browser", - "components:check": "pnpm components:lint && pnpm components:test && pnpm components:consumer:check", - "components:release:check:local": "pnpm components:check && pnpm components:build && pnpm components:audit:prod && pnpm components:test:cron:local && pnpm components:smoke:examples:ephemeral" + "submodules:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", + "submodules:lint": "node tools/doc-check.mjs && node tools/release-check.mjs && node tools/check-example-assets.mjs && eslint \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts && pnpm -r --filter \"./spacetime-*-ts\" run lint", + "submodules:test": "node tools/run-package-checks.mjs && node tools/run-example-tests.mjs", + "submodules:toolchain:check": "node tools/check-spacetime-release.mjs", + "submodules:build": "pnpm submodules:toolchain:check && pnpm --dir crates/bindings-typescript run build && node tools/run-module-builds.mjs && node tools/run-example-builds.mjs", + "submodules:consumer:check": "node tools/consumer-install-check.mjs", + "submodules:audit:prod": "node tools/run-production-audits.mjs", + "submodules:test:cron:local": "pnpm --dir spacetime-cron-ts run test:module:local && pnpm --dir spacetime-cron-ts run test:recovery", + "submodules:browser:install": "playwright install chromium", + "submodules:smoke:examples:http:fresh": "node tools/run-example-smokes.mjs --confirm-delete-data", + "submodules:smoke:examples:ephemeral": "node tools/run-example-smokes.mjs --ephemeral --browser", + "submodules:smoke:examples:fresh": "node tools/run-example-smokes.mjs --confirm-delete-data --browser", + "submodules:check": "pnpm submodules:lint && pnpm submodules:test && pnpm submodules:consumer:check", + "submodules:release:check:local": "pnpm submodules:check && pnpm submodules:build && pnpm submodules:audit:prod && pnpm submodules:test:cron:local && pnpm submodules:smoke:examples:ephemeral" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/spacetime-agents-ts/example/README.md b/spacetime-agents-ts/example/README.md index 5c96374091d..d78d26495d4 100644 --- a/spacetime-agents-ts/example/README.md +++ b/spacetime-agents-ts/example/README.md @@ -63,7 +63,7 @@ database. Use `pnpm run build:module` to republish while preserving existing row ## Use in your project -This workspace tests the component source in this repository. Consumer applications install published releases: +This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash npm install @spacetimedb/agents spacetimedb@^2.8.3 diff --git a/spacetime-api-keys-ts/README.md b/spacetime-api-keys-ts/README.md index b318a5bc95c..6f99d2277c4 100644 --- a/spacetime-api-keys-ts/README.md +++ b/spacetime-api-keys-ts/README.md @@ -21,7 +21,7 @@ For the install-to-publish workflow, see ### Integrate into an application -Mount the component in the host schema and install its private state from the +Mount the submodule in the host schema and install its private state from the host lifecycle hook: ```ts @@ -43,7 +43,7 @@ The root package exports a standalone `init`. The `./submodule` entrypoint leaves lifecycle ownership with the host module. Next, wrap `verifyApiKey` in the host operation that needs bearer-token access; -the component validates key material and scopes, while the host decides what a +the submodule validates key material and scopes, while the host decides what a scope authorizes. The complete [Colony host module](./example/spacetimedb/) shows scoped HTTP routes and one-time key delivery. diff --git a/spacetime-api-keys-ts/example/README.md b/spacetime-api-keys-ts/example/README.md index 756ecd34df2..429b91e8414 100644 --- a/spacetime-api-keys-ts/example/README.md +++ b/spacetime-api-keys-ts/example/README.md @@ -6,10 +6,10 @@ can issue scoped bearer links that allow another browser to view or modify it. ## What this demonstrates -- Mounting the API Keys component under the `apiKeys` namespace. +- Mounting the API Keys submodule under the `apiKeys` namespace. - Creating, rotating, validating, and revoking scoped bearer keys. - Validating keys in native SpacetimeDB HTTP handlers. -- Composing API Keys with the Grid and Presence components. +- Composing the API Keys, Grid, and Presence submodules. - Giving owners native reducer access while routing key holders through HTTP. - Recording allowed and rejected holder actions in an audit-style world event log. - Returning a raw key only at creation or rotation time. @@ -56,7 +56,7 @@ local rows. ## Use in your project -This workspace tests the component source in this repository. Consumer applications install published releases: +This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash npm install @spacetimedb/api-keys @spacetimedb/crypto spacetimedb@^2.8.3 @@ -190,7 +190,7 @@ For a release smoke test: ## Important files -- `spacetimedb/src/index.ts` - colony schema, component mounts, views, reducers, +- `spacetimedb/src/index.ts` - colony schema, submodule mounts, views, reducers, and HTTP handlers. - `server.ts` - static server and colony-route proxy. - `src/app.ts` - owner/holder modes, key handling, subscriptions, and UI logic. diff --git a/spacetime-auth-ts/example/README.md b/spacetime-auth-ts/example/README.md index f409bf797b0..2f3228a5e95 100644 --- a/spacetime-auth-ts/example/README.md +++ b/spacetime-auth-ts/example/README.md @@ -58,7 +58,7 @@ preserved. ## Use in your project -This workspace tests the component source in this repository. Consumer applications install published releases: +This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash npm install @spacetimedb/auth @spacetimedb/rate-limit spacetimedb@^2.8.3 @@ -83,7 +83,7 @@ console mailer and development server before production. | `AUTH_BASE_URL` | `http://localhost:8791` | Browser-visible auth base URL. | | `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | | `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | -| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by component | Optional persistent ES256 private key. | +| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by submodule | Optional persistent ES256 private key. | | Google/GitHub client variables | empty | Enables the matching OAuth provider when both values are present. | The server loads `.env` and calls `set_auth_config` on every startup using the diff --git a/spacetime-cron-ts/example/README.md b/spacetime-cron-ts/example/README.md index b284dafb8dc..5f578b94c26 100644 --- a/spacetime-cron-ts/example/README.md +++ b/spacetime-cron-ts/example/README.md @@ -7,7 +7,7 @@ This example is a small browser dashboard backed directly by SpacetimeDB. It sho The dashboard subscribes to the sanitized `cron_jobs` view, exact calendar targets, interval estimates, run outcomes, and application activity. The private job rows retain typed arguments without exposing them to browser subscriptions. Controls can reschedule or disable either job. Selecting `cleanup` also supplies its typed row-retention argument when scheduling. -This is a local development example. Its scheduling reducers accept any connected caller so the browser can exercise the component. Add application authorization before deploying equivalent controls. The example also enables `publicTables`, which exposes run history and trigger state for the dashboard. Review that visibility before using the same option in an application. +This is a local development example. Its scheduling reducers accept any connected caller so the browser can exercise the submodule. Add application authorization before deploying equivalent controls. The example also enables `publicTables`, which exposes run history and trigger state for the dashboard. Review that visibility before using the same option in an application. ## What this demonstrates @@ -68,7 +68,7 @@ pnpm run build:module ## Use in your project -This workspace tests the component source in this repository. Consumer +This workspace tests the submodule source in this repository. Consumer applications install the published release: ```bash @@ -134,7 +134,7 @@ only the status required by their users. ## Authorization and deployment boundaries - `scheduleCron`, `scheduleEvery`, and `unscheduleJob` are open so a local browser - can exercise the component. Production modules must authorize these calls. + can exercise the submodule. Production modules must authorize these calls. - Job names and argument types are part of the database schema. Changing an existing argument type requires a schema migration or a new job name. - Reducer handlers must remain deterministic. Use a Cron procedure for HTTP or diff --git a/spacetime-cron-ts/example/spacetimedb/src/index.ts b/spacetime-cron-ts/example/spacetimedb/src/index.ts index ac2c97bb5f3..6367de0fb49 100644 --- a/spacetime-cron-ts/example/spacetimedb/src/index.ts +++ b/spacetime-cron-ts/example/spacetimedb/src/index.ts @@ -101,7 +101,7 @@ export const init = spacetimedb.init(ctx => { // ── Client-facing management ───────────────────────────────────────────────── // These reducers are intentionally open so the local browser can exercise the -// component. Production applications must enforce their own admin policy. +// submodule. Production applications must enforce their own admin policy. const jobs: Record = { digest, cleanup }; diff --git a/spacetime-files-ts/example/README.md b/spacetime-files-ts/example/README.md index 74b765c61f6..dad4128c351 100644 --- a/spacetime-files-ts/example/README.md +++ b/spacetime-files-ts/example/README.md @@ -2,7 +2,7 @@ Vault is a small Drive-style file manager built with [`@spacetimedb/files`](../). File bytes and file records live in the mounted -Files component; the host module adds identity-owned folder metadata and scoped +Files submodule; the host module adds identity-owned folder metadata and scoped views. ## What this demonstrates @@ -11,7 +11,7 @@ views. - Identity-owned folders and caller-scoped file-summary subscriptions. - Keeping file bytes out of realtime subscriptions. - Reading private bytes through a sender-aware procedure. -- Serving explicitly public files through the component HTTP handler. +- Serving explicitly public files through the submodule HTTP handler. - Drag-and-drop uploads, folder traversal, search, previews, bulk actions, and ZIP downloads in a browser client. @@ -55,7 +55,7 @@ database. Use `pnpm run build:module` when existing local files must be preserve ## Use in your project -This workspace tests the component source in this repository. Consumer applications install published releases: +This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash npm install @spacetimedb/files @spacetimedb/crypto spacetimedb@^2.8.3 @@ -100,7 +100,7 @@ changes its confidentiality and creates a public download path. - File and folder paths are owner-scoped. Two identities can each use `/docs` and `/docs/readme.txt`. - Public links use the stable numeric file ID. -- The component stores bytes in SpacetimeDB rows and caps each file at 4 MB. +- The submodule stores bytes in SpacetimeDB rows and caps each file at 4 MB. - Vault demonstrates in-row storage for small assets. Use dedicated infrastructure for streaming uploads, media transformation, backups, and CDN delivery. diff --git a/spacetime-grid-ts/README.md b/spacetime-grid-ts/README.md index 69fbdcb23fe..4a813431d78 100644 --- a/spacetime-grid-ts/README.md +++ b/spacetime-grid-ts/README.md @@ -47,7 +47,7 @@ export const create_player_grid = spacetimedb.procedure( ); ``` -The component treats owner values as opaque strings. Host operations must map +The submodule treats owner values as opaque strings. Host operations must map the authenticated caller to that string before calling helpers such as `createGrid` or `moveEntity`. See the [Grid Tactics host module](./example/spacetimedb/) diff --git a/spacetime-grid-ts/example/README.md b/spacetime-grid-ts/example/README.md index efd84ff6fb2..d8ae94f239e 100644 --- a/spacetime-grid-ts/example/README.md +++ b/spacetime-grid-ts/example/README.md @@ -1,17 +1,17 @@ # Grid tactics example This example is a turn-based hex-grid tactics game built with -[`@spacetimedb/grid`](../). The mounted Grid component owns grids, cell +[`@spacetimedb/grid`](../). The mounted Grid submodule owns grids, cell state, and entity positions; the host module owns matches, participants, unit statistics, turns, and combat rules. ## What this demonstrates -- Mounting Grid and Auth components in one host module. +- Mounting the Grid and Auth submodules in one host module. - Authenticated match membership and caller-scoped subscriptions. - Hex-grid pathfinding with `computePathImpl`. - Movement and attack ranges with `cellsInRangeImpl`. -- Layering application rules over component-owned spatial state. +- Layering application rules over submodule-owned spatial state. - Human-versus-human matchmaking and a solo match against the built-in Xeno Garrison actor. @@ -58,7 +58,7 @@ database. Use `pnpm run build:module` when existing matches must be preserved. ## Use in your project -This workspace tests the component source in this repository. Consumer applications install published releases: +This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash npm install @spacetimedb/grid spacetimedb@^2.8.3 @@ -158,7 +158,7 @@ For a release smoke test: ## Important files -- `spacetimedb/src/index.ts` - component mounts, auth integration, match schema, +- `spacetimedb/src/index.ts` - submodule mounts, auth integration, match schema, scoped views, and game rules. - `src/app.ts` - auth/session linking, subscriptions, and interaction bridge. - `server.ts` - auth bootstrap, static serving, and same-origin proxy. diff --git a/spacetime-grid-ts/example/public/hex-geometry.js b/spacetime-grid-ts/example/public/hex-geometry.js index c971a31ded7..83265e85414 100644 --- a/spacetime-grid-ts/example/public/hex-geometry.js +++ b/spacetime-grid-ts/example/public/hex-geometry.js @@ -15,7 +15,7 @@ export function cellKey(x, y) { return `${x},${y}`; } -// Keep this formula aligned with the Grid component's server distance rule. +// Keep this formula aligned with the Grid submodule's server distance rule. export function axialHexDistance(ax, ay, bx, by) { return ( (Math.abs(ax - bx) + Math.abs(ax + ay - bx - by) + Math.abs(ay - by)) / 2 diff --git a/spacetime-lobby-ts/example/README.md b/spacetime-lobby-ts/example/README.md index ecb60ef0cf9..9be94708cc3 100644 --- a/spacetime-lobby-ts/example/README.md +++ b/spacetime-lobby-ts/example/README.md @@ -1,16 +1,16 @@ # Starclash lobby example Starclash is a ranked one-on-one spaceship duel built with -[`@spacetimedb/lobby`](../). The mounted Lobby component owns queue tickets, +[`@spacetimedb/lobby`](../). The mounted Lobby submodule owns queue tickets, rooms, seats, and ratings; the host module owns ship selection, duel state, maneuvers, combat resolution, and round logs. ## What this demonstrates -- Mounting the Lobby component in a host game module. +- Mounting the Lobby submodule in a host game module. - Ranked queue matching, room joining, rematches, and rating updates. - Falling back from a public queue to a server-controlled AI opponent. -- Keeping component matchmaking state separate from application game state. +- Keeping submodule matchmaking state separate from application game state. - Caller-scoped ticket, room, seat, rating, duel, and maneuver views. - Driving a realtime UI entirely from SpacetimeDB subscriptions. @@ -58,7 +58,7 @@ be preserved. ## Use in your project -This workspace tests the component source in this repository. Consumer +This workspace tests the submodule source in this repository. Consumer applications install the published release: ```bash @@ -85,13 +85,13 @@ The Node process serves static files, `GET /api/health`, and browser-safe ## Match and duel lifecycle 1. A player sets a display name and ship class. -2. `find_duel` joins the ranked public pool through the Lobby component. +2. `find_duel` joins the ranked public pool through the Lobby submodule. 3. Once two compatible tickets are matched, both subjects join the resulting room and the host module creates duel state. 4. Each pilot chooses a maneuver. The module resolves the round only when the required choices exist, then records combat changes and a round log. 5. A completed or abandoned duel reports its result to Lobby and closes the room. -6. The component updates ratings; players can queue again. +6. The submodule updates ratings; players can queue again. The fallback action cancels the player's public ticket and creates a match in an AI-specific pool with a server-controlled subject. diff --git a/spacetime-posthog-ts/example/README.md b/spacetime-posthog-ts/example/README.md index c480becd37c..7ff1f4a079e 100644 --- a/spacetime-posthog-ts/example/README.md +++ b/spacetime-posthog-ts/example/README.md @@ -3,15 +3,15 @@ Context Cafe is a small robot café simulator that demonstrates the mounted `@spacetimedb/posthog/submodule`. SpacetimeDB owns the catalog, simulation, per-browser café state, metrics, and analytics outbox. A dedicated local server -identity delivers queued events to PostHog; the browser never receives component +identity delivers queued events to PostHog; the browser never receives submodule administrator privileges or the PostHog project key. ## What this demonstrates -- Mounting the PostHog component under the `posthog` namespace. +- Mounting the PostHog submodule under the `posthog` namespace. - Enqueuing analytics in deterministic reducers for delivery outside transactions. -- Delivering the component outbox from an authorized server connection. +- Delivering the submodule outbox from an authorized server connection. - Caller-scoped café state and safe public aggregate delivery metrics. - Editing prices and availability while watching simulated conversion change. - Synchronizing a TypeScript-authored catalog from `catalog/catalog.ts`. @@ -22,7 +22,7 @@ administrator privileges or the PostHog project key. - The released SpacetimeDB 2.8 CLI. - A local SpacetimeDB server reachable as `local`. - A logged-in CLI identity. The identity that publishes the fresh database becomes - its initial component administrator. + its initial submodule administrator. - Optional: a PostHog project API key for real event delivery. Select the supported CLI release, then keep the local server running in a @@ -62,7 +62,7 @@ database. Use `pnpm run build:module` when existing data must be preserved. ## Use in your project -This workspace tests the component source in this repository. Consumer +This workspace tests the submodule source in this repository. Consumer applications install the published release: ```bash @@ -115,11 +115,11 @@ The Node server exposes only: | `GET /api/health` | Local health probe. | | `GET /api/config` | Browser-safe database and PostHog dashboard configuration. | -Component administrator grants are available only through module operations. +Submodule administrator grants are available only through module operations. ## Security and deployment boundaries -- `POSTHOG_PROJECT_API_KEY` is loaded by the server and written to the component's +- `POSTHOG_PROJECT_API_KEY` is loaded by the server and written to the submodule's private configuration table through the authenticated CLI owner. - `.stdb-server-token`, `.env`, and logs are ignored and must not be committed. - The development server binds to loopback by default. Setting `HOST` to another diff --git a/spacetime-presence-ts/example/README.md b/spacetime-presence-ts/example/README.md index d8e4d6b38d9..f2398e693f2 100644 --- a/spacetime-presence-ts/example/README.md +++ b/spacetime-presence-ts/example/README.md @@ -59,7 +59,7 @@ database. Use `pnpm run build:module` when existing local data must be preserved ## Use in your project -This workspace tests the component source in this repository. Consumer applications install published releases: +This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash npm install @spacetimedb/presence spacetimedb@^2.8.3 @@ -84,7 +84,7 @@ rooms, messages, and reactions belong to the host application. | `AUTH_BASE_URL` | `AUTH_ISSUER_URL` | Browser-visible auth base URL. | | `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | | `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | -| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by component | Optional persistent ES256 signing key. | +| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by submodule | Optional persistent ES256 signing key. | | Google/GitHub client variables | empty | Enables the matching OAuth provider when both values are present. | The development server loads `.env` and calls `set_auth_config` automatically on @@ -103,7 +103,7 @@ Browser SpacetimeDB module -> auth session -> linked application connection - -> presence, chat, files, and rate-limit components + -> presence, chat, files, and rate-limit submodules -> my_* views filtered for the linked user and room membership ``` @@ -176,7 +176,7 @@ For a release smoke test, use two accounts and verify: ## Important files - `spacetimedb/src/index.ts` - host schema, scoped views, chat operations, and - mounted component wiring. + mounted submodule wiring. - `server.ts` - environment loading, auth bootstrap, and HTTP proxy. - `src/app.ts` - browser connection, linked-session setup, and subscriptions. - `public/index.html` - the example interface. diff --git a/spacetime-rate-limit-ts/example/README.md b/spacetime-rate-limit-ts/example/README.md index 15b2470711b..522d02f1789 100644 --- a/spacetime-rate-limit-ts/example/README.md +++ b/spacetime-rate-limit-ts/example/README.md @@ -7,7 +7,7 @@ under the `rateLimit` namespace. ## What this demonstrates -- Mounting the Rate Limit component in an application module. +- Mounting the Rate Limit submodule in an application module. - Deriving server-owned actor keys and fixed gameplay scopes. - Enforcing limits from procedures with typed allow/deny results. - Using independent buckets for taps, overcharge, upgrades, and repair. @@ -56,7 +56,7 @@ players, upgrades, and limiter state. ## Use in your project -This workspace tests the component source in this repository. Consumer +This workspace tests the submodule source in this repository. Consumer applications install the published release: ```bash @@ -89,7 +89,7 @@ The returned result includes remaining capacity, reset time, and retry delay. The mounted `consume` procedure is reserved for administrators; normal gameplay uses the lower-level helper inside the host procedure's transaction. -The component implements fixed-window limiting. Application heat and cooldown +The submodule implements fixed-window limiting. Application heat and cooldown mechanics are separate game rules layered over the rate limit, so a request may be rejected by either system. @@ -169,7 +169,7 @@ For a release smoke test: ## Important files -- `spacetimedb/src/index.ts` - component mount, reactor rules, scoped views, and +- `spacetimedb/src/index.ts` - submodule mount, reactor rules, scoped views, and bounded maintenance operations. - `src/app.ts` - connection, procedures, subscriptions, and UI bridge. - `server.ts` - static development server and browser-safe configuration. diff --git a/spacetime-resend-ts/example/README.md b/spacetime-resend-ts/example/README.md index 4d666b253af..dec27a57a1a 100644 --- a/spacetime-resend-ts/example/README.md +++ b/spacetime-resend-ts/example/README.md @@ -3,11 +3,11 @@ Dispatch demonstrates a host module that mounts `@spacetimedb/resend/submodule`: compose an email, send it through Resend, and watch verified delivery events stream back through SpacetimeDB. Provider credentials -and component administration remain outside the browser. +and submodule administration remain outside the browser. ## What this demonstrates -- Mounting the Resend component under the `resend` namespace. +- Mounting the Resend submodule under the `resend` namespace. - Calling a host `send_dispatch` procedure backed by a private API key. - Showing caller-scoped email and delivery-event views in real time. - Receiving Resend webhooks through a native SpacetimeDB HTTP route. @@ -63,7 +63,7 @@ database. Use `pnpm run build:module` to preserve existing data. ## Use in your project -This workspace tests the component source in this repository. Consumer applications install published releases: +This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash npm install @spacetimedb/resend @spacetimedb/rate-limit @spacetimedb/crypto spacetimedb@^2.8.3 @@ -139,11 +139,11 @@ Authorized example server ## Security and deployment boundaries - The browser never receives the Resend API key, signing secret, server token, or - component administrator role. + submodule administrator role. - `send_dispatch` accepts only server-configured recipients. It allows five sends per caller every ten minutes and 25 sends globally per hour. - Provider failures return a stable application error while detailed delivery - state remains in private component tables. + state remains in private submodule tables. - `.env` and `.stdb-server-token` are ignored and must not be committed. - The development server binds to loopback by default. - A production deployment should provision its service identity and secret store diff --git a/spacetime-stripe-ts/example/README.md b/spacetime-stripe-ts/example/README.md index f160ab2951a..068cb6a7507 100644 --- a/spacetime-stripe-ts/example/README.md +++ b/spacetime-stripe-ts/example/README.md @@ -8,7 +8,7 @@ catalog state. ## What this demonstrates -- Mounting the Stripe component inside an application-owned store module. +- Mounting the Stripe submodule inside an application-owned store module. - Keeping Stripe credentials in private module state. - Using a narrow server API for customer lookup, price validation, and Checkout; the browser never receives the privileged service identity. @@ -66,7 +66,7 @@ database. Use `pnpm run build:module` to preserve existing data. ## Use in your project -This workspace tests the component source in this repository. Consumer applications install published releases: +This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash npm install @spacetimedb/stripe @spacetimedb/crypto spacetimedb@^2.8.3 @@ -83,7 +83,7 @@ webhook route. The product catalog and storefront are demonstration code. | --------------------------------------- | ------------------------------------ | ------------------------------------------------------------- | | `STRIPE_SECRET_KEY` | empty | Required for provider operations. Use a test-mode key. | | `STRIPE_WEBHOOK_SECRET` | empty | Verifies incoming Stripe webhook signatures. | -| `STRIPE_VERSION` | component default | Optional Stripe API-version override. | +| `STRIPE_VERSION` | submodule default | Optional Stripe API-version override. | | `STRIPE_SYNC_PRICES` | `0` | Set to `1` to create/link missing test prices during startup. | | `STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS` | automatic on non-production loopback | Explicit provider-action opt-in for other environments. | | `STRIPE_RETURN_BASE_URL` | `http://127.0.0.1:8787` | Server-owned Checkout return origin. | @@ -152,7 +152,7 @@ http://127.0.0.1:3000/v1/database/spacetime-stripe-example/route/stripe/webhook ``` Use the signing secret produced by the forwarding tool as -`STRIPE_WEBHOOK_SECRET`, then restart so the private component configuration is +`STRIPE_WEBHOOK_SECRET`, then restart so the private submodule configuration is updated. ## Security and deployment boundaries diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts b/spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts index f764d522e66..921e4d311c7 100644 --- a/spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts +++ b/spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts @@ -117,17 +117,14 @@ function getLookupKey(databaseIdentity: string, productId: string): string { return `stdb_${safeDb}_${safeProduct}`; } -function encodeFormComponent(value: string): string { +function encodeFormField(value: string): string { return encodeURIComponent(value).replace(/%20/g, '+'); } function formBody(pairs: Array<[string, string | undefined]>): string { return pairs .filter((pair): pair is [string, string] => pair[1] !== undefined) - .map( - ([key, value]) => - `${encodeFormComponent(key)}=${encodeFormComponent(value)}` - ) + .map(([key, value]) => `${encodeFormField(key)}=${encodeFormField(value)}`) .join('&'); } diff --git a/tools/consumer-install-check.mjs b/tools/consumer-install-check.mjs index 15f858b8795..2f23acdb34a 100644 --- a/tools/consumer-install-check.mjs +++ b/tools/consumer-install-check.mjs @@ -20,7 +20,7 @@ const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; const spacetimeCommand = process.platform === 'win32' ? 'spacetime.exe' : 'spacetime'; -const temporaryRoot = mkdtempSync(join(tmpdir(), 'stdb-components-consumer-')); +const temporaryRoot = mkdtempSync(join(tmpdir(), 'stdb-submodules-consumer-')); const packDirectory = join(temporaryRoot, 'packs'); mkdirSync(packDirectory); @@ -83,7 +83,7 @@ try { join(temporaryRoot, 'package.json'), `${JSON.stringify( { - name: 'spacetimedb-components-consumer-check', + name: 'spacetimedb-submodules-consumer-check', private: true, type: 'module', dependencies, diff --git a/tools/doc-check.mjs b/tools/doc-check.mjs index 37817184dbe..4e1c266fa55 100644 --- a/tools/doc-check.mjs +++ b/tools/doc-check.mjs @@ -14,9 +14,6 @@ const ts = requireFromPackage('typescript'); const failures = []; const documentationFiles = [ - 'COMPONENTS.md', - 'COMPONENTS_GETTING_STARTED.md', - 'COMPONENTS_AUTHORING.md', ...releasePackages.flatMap(packageDir => { const files = [`${packageDir}/README.md`]; const exampleReadme = `${packageDir}/example/README.md`; diff --git a/tools/release-check.mjs b/tools/release-check.mjs index 514cfb1c776..81411166860 100644 --- a/tools/release-check.mjs +++ b/tools/release-check.mjs @@ -97,12 +97,12 @@ if (tracked.status !== 0) { if (!existsSync(absolutePath)) continue; const normalizedRepositoryPath = repositoryPath.replaceAll('\\', '/'); - const isComponentPath = releasePackages.some( + const isSubmodulePath = releasePackages.some( packageDir => normalizedRepositoryPath === packageDir || normalizedRepositoryPath.startsWith(`${packageDir}/`) ); - if (!isComponentPath && normalizedRepositoryPath !== 'pnpm-lock.yaml') { + if (!isSubmodulePath && normalizedRepositoryPath !== 'pnpm-lock.yaml') { continue; } if ( @@ -410,7 +410,7 @@ for (const packageDir of releasePackages) { fail(packageDir, `${sourceName} imports a Node-only API`); } - const packDirectory = mkdtempSync(join(tmpdir(), 'stdb-component-pack-')); + const packDirectory = mkdtempSync(join(tmpdir(), 'stdb-submodule-pack-')); const packed = spawnSync( pnpmCommand, ['pack', '--json', '--pack-destination', packDirectory], From c7e57d25ec7da650f5e7fcf0e1d25ad7afc8a28c Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 17:47:09 -0400 Subject: [PATCH 05/33] Clean up submodules and examples --- .gitignore | 1 + package.json | 6 +- spacetime-agents-ts/example/server.ts | 2 +- spacetime-agents-ts/example/src/app.ts | 13 ++-- spacetime-api-keys-ts/README.md | 6 +- spacetime-api-keys-ts/example/src/app.ts | 26 ++++--- spacetime-auth-ts/example/.gitignore | 3 +- spacetime-auth-ts/example/README.md | 4 +- spacetime-auth-ts/example/server.ts | 2 +- spacetime-auth-ts/example/src/app.ts | 3 +- .../example/spacetimedb/src/index.ts | 4 +- spacetime-files-ts/example/README.md | 4 +- spacetime-files-ts/example/server.ts | 2 +- spacetime-files-ts/example/src/app.ts | 4 +- spacetime-grid-ts/example/README.md | 2 +- spacetime-grid-ts/example/server.ts | 2 +- spacetime-grid-ts/example/src/app.ts | 23 +++--- spacetime-lobby-ts/README.md | 2 +- spacetime-lobby-ts/example/README.md | 2 +- spacetime-lobby-ts/example/src/app.ts | 30 ++++---- spacetime-lobby-ts/src/index.ts | 2 +- spacetime-posthog-ts/.gitignore | 5 -- spacetime-posthog-ts/README.md | 2 +- spacetime-posthog-ts/example/README.md | 4 +- spacetime-posthog-ts/example/server.ts | 4 +- spacetime-posthog-ts/example/src/app.ts | 25 +++--- spacetime-posthog-ts/src/index.ts | 2 +- spacetime-presence-ts/example/README.md | 2 +- spacetime-presence-ts/example/server.ts | 2 +- spacetime-presence-ts/example/src/app.ts | 33 ++++---- spacetime-rate-limit-ts/README.md | 2 +- spacetime-rate-limit-ts/example/server.ts | 2 +- spacetime-rate-limit-ts/example/src/app.ts | 13 ++-- spacetime-resend-ts/.gitignore | 5 -- spacetime-resend-ts/README.md | 4 +- spacetime-resend-ts/example/src/app.ts | 6 +- spacetime-resend-ts/src/index.ts | 3 +- spacetime-stripe-ts/README.md | 4 +- spacetime-stripe-ts/example/README.md | 4 +- spacetime-stripe-ts/example/server.ts | 6 +- .../example/spacetimedb/README.md | 30 -------- .../example/spacetimedb/src/index.ts | 13 +--- .../src/{submodule => store}/auth.ts | 0 .../src/{submodule => store}/operations.ts | 0 .../src/{submodule => store}/schema.ts | 0 .../src/{submodule => store}/utils.ts | 0 .../src/{submodule => store}/webhooks.ts | 0 spacetime-stripe-ts/example/src/app.ts | 3 +- tools/check-example-assets.mjs | 29 +++---- tools/doc-check.mjs | 1 - tools/run-example-builds.mjs | 26 +++---- tools/run-example-tests.mjs | 25 +++--- tools/run-module-builds.mjs | 77 +++++++------------ tools/run-production-audits.mjs | 14 ---- 54 files changed, 209 insertions(+), 280 deletions(-) delete mode 100644 spacetime-posthog-ts/.gitignore delete mode 100644 spacetime-resend-ts/.gitignore delete mode 100644 spacetime-stripe-ts/example/spacetimedb/README.md rename spacetime-stripe-ts/example/spacetimedb/src/{submodule => store}/auth.ts (100%) rename spacetime-stripe-ts/example/spacetimedb/src/{submodule => store}/operations.ts (100%) rename spacetime-stripe-ts/example/spacetimedb/src/{submodule => store}/schema.ts (100%) rename spacetime-stripe-ts/example/spacetimedb/src/{submodule => store}/utils.ts (100%) rename spacetime-stripe-ts/example/spacetimedb/src/{submodule => store}/webhooks.ts (100%) delete mode 100644 tools/run-production-audits.mjs diff --git a/.gitignore b/.gitignore index b795e61c6ae..25d7ace9201 100644 --- a/.gitignore +++ b/.gitignore @@ -209,6 +209,7 @@ __pycache__/ .stdb-*/ spacetime-*-ts/example/public/app.js spacetime-*-ts/example/public/app.js.map +spacetime-*-ts/ts-codegen/ /protobuf cs-src/ diff --git a/package.json b/package.json index f2f5c02ab6a..2af79f308b5 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,13 @@ "test": "pnpm run-all test && pnpm submodules:test", "generate": "pnpm run-all generate", "clean": "pnpm -r exec rimraf dist .tsbuildinfo coverage", - "submodules:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", - "submodules:lint": "node tools/doc-check.mjs && node tools/release-check.mjs && node tools/check-example-assets.mjs && eslint \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks,run-production-audits}.mjs\" tools/example-server-identity.ts && pnpm -r --filter \"./spacetime-*-ts\" run lint", + "submodules:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", + "submodules:lint": "node tools/doc-check.mjs && node tools/release-check.mjs && node tools/check-example-assets.mjs && eslint \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks}.mjs\" tools/example-server-identity.ts && pnpm -r --filter \"./spacetime-*-ts\" run lint", "submodules:test": "node tools/run-package-checks.mjs && node tools/run-example-tests.mjs", "submodules:toolchain:check": "node tools/check-spacetime-release.mjs", "submodules:build": "pnpm submodules:toolchain:check && pnpm --dir crates/bindings-typescript run build && node tools/run-module-builds.mjs && node tools/run-example-builds.mjs", "submodules:consumer:check": "node tools/consumer-install-check.mjs", - "submodules:audit:prod": "node tools/run-production-audits.mjs", + "submodules:audit:prod": "node tools/consumer-install-check.mjs --audit", "submodules:test:cron:local": "pnpm --dir spacetime-cron-ts run test:module:local && pnpm --dir spacetime-cron-ts run test:recovery", "submodules:browser:install": "playwright install chromium", "submodules:smoke:examples:http:fresh": "node tools/run-example-smokes.mjs --confirm-delete-data", diff --git a/spacetime-agents-ts/example/server.ts b/spacetime-agents-ts/example/server.ts index ba3442434f4..2ad1241f54d 100644 --- a/spacetime-agents-ts/example/server.ts +++ b/spacetime-agents-ts/example/server.ts @@ -256,7 +256,7 @@ try { } app.listen(PORT, HOST, () => { - console.log(`Agents test app running at http://${HOST}:${PORT}`); + console.log(`Agents example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*, /files)`); console.log(` Database -> ${STDB_APP_DB}`); diff --git a/spacetime-agents-ts/example/src/app.ts b/spacetime-agents-ts/example/src/app.ts index c76bd4efb5c..764e0d07f88 100644 --- a/spacetime-agents-ts/example/src/app.ts +++ b/spacetime-agents-ts/example/src/app.ts @@ -1,6 +1,7 @@ // STDB connection + chat ops + auth. Exposes window.auth and window.stdb. import { DbConnection, + tables, type ErrorContext, type EventContext, type SubscriptionHandle, @@ -316,7 +317,7 @@ function setActiveThread(threadId: bigint | null): void { .onError((ctx: ErrorContext) => console.error('message sub error', ctx.event) ) - .subscribe([`SELECT * FROM my_messages WHERE thread_id = ${threadId}`]); + .subscribe([tables.myMessages.where(row => row.threadId.eq(threadId))]); } function wireRowHandlers(conn: DbConnection): void { @@ -416,11 +417,11 @@ async function bindSession( console.error('global sub error', ctx.event) ) .subscribe([ - 'SELECT * FROM my_threads', - 'SELECT * FROM my_thread_locks', - 'SELECT * FROM agent_override', - 'SELECT * FROM my_files', - 'SELECT * FROM my_auth_user', + tables.myThreads, + tables.myThreadLocks, + tables.agentOverride, + tables.myFiles, + tables.myAuthUser, ]); const previousActive = activeThreadId; diff --git a/spacetime-api-keys-ts/README.md b/spacetime-api-keys-ts/README.md index 6f99d2277c4..e510b1e71c6 100644 --- a/spacetime-api-keys-ts/README.md +++ b/spacetime-api-keys-ts/README.md @@ -88,7 +88,7 @@ Key lifecycle operations: Each owner may have up to 50 active, unexpired keys. Expiration may be set up to 10 years from creation. -## Verify In A Host App +## Verify in a host app Mounted apps can use the transactional helper directly: @@ -152,7 +152,7 @@ Package entrypoints: - `@spacetimedb/api-keys/submodule` supplies the mountable namespace, helpers, operations, and views for host applications. -## Tables And Views +## Tables and views Private tables: @@ -171,7 +171,7 @@ Public views: chosen age. Schedule it from the host according to the application's retention policy. -## Security Model +## Security model - Raw keys are returned once. Persistent state contains the hash and lookup prefix. diff --git a/spacetime-api-keys-ts/example/src/app.ts b/spacetime-api-keys-ts/example/src/app.ts index 5b6c550b688..36290e46cf1 100644 --- a/spacetime-api-keys-ts/example/src/app.ts +++ b/spacetime-api-keys-ts/example/src/app.ts @@ -1,4 +1,8 @@ -import { DbConnection, type ErrorContext } from './codegen/app/index.ts'; +import { + DbConnection, + tables, + type ErrorContext, +} from './codegen/app/index.ts'; import { parseShareKey, shareKeyFromHash } from './share-key'; import { @@ -254,22 +258,20 @@ function subscribeAll(): void { if (subscribed) return; subscribed = true; const c = requireConn(); - const queries = [ - `SELECT * FROM world WHERE owner_subject = '${colonyId}'`, - `SELECT * FROM world_event WHERE owner_subject = '${colonyId}'`, - `SELECT * FROM colony_grid WHERE id = ${gridId}`, - `SELECT * FROM colony_cells WHERE grid_id = ${gridId}`, - `SELECT * FROM colony_entities WHERE grid_id = ${gridId}`, - `SELECT * FROM presence_entry WHERE scope = '${colonyId}'`, - ]; - if (mode === 'owner') queries.push('SELECT * FROM my_access_keys'); - c.subscriptionBuilder() .onApplied(() => renderWorld()) .onError((ctx: ErrorContext) => console.error('subscription error', ctx.event) ) - .subscribe(queries); + .subscribe([ + tables.world.where(row => row.ownerSubject.eq(colonyId)), + tables.worldEvent.where(row => row.ownerSubject.eq(colonyId)), + tables.colonyGrid.where(row => row.id.eq(gridId)), + tables.colonyCells.where(row => row.gridId.eq(gridId)), + tables.colonyEntities.where(row => row.gridId.eq(gridId)), + tables.presenceEntry.where(row => row.scope.eq(colonyId)), + ...(mode === 'owner' ? [tables.myAccessKeys] : []), + ]); c.db.world.onInsert(() => renderWorld()); c.db.world.onUpdate(() => renderWorld()); diff --git a/spacetime-auth-ts/example/.gitignore b/spacetime-auth-ts/example/.gitignore index 3ebf7ec943f..42aba3bdb4b 100644 --- a/spacetime-auth-ts/example/.gitignore +++ b/spacetime-auth-ts/example/.gitignore @@ -5,8 +5,7 @@ public/app.js public/app.js.map .env -# STDB runtime data lives in %LOCALAPPDATA%/auth-ts-example/stdb-data (not here) -# Listed defensively in case anyone runs STDB with --data-dir=. +# Local SpacetimeDB state and secrets. .stdb-data/ .secrets/ *.pid diff --git a/spacetime-auth-ts/example/README.md b/spacetime-auth-ts/example/README.md index 2f3228a5e95..9130ec9b6aa 100644 --- a/spacetime-auth-ts/example/README.md +++ b/spacetime-auth-ts/example/README.md @@ -125,7 +125,7 @@ by the browser. ## Development mailer -The example intentionally uses a console mailer. Password-reset and +The example uses a console mailer. Password-reset and email-verification messages, including their one-time links, appear in the SpacetimeDB module logs. Production deployments require a delivery provider. @@ -183,7 +183,7 @@ spacetime sql --server http://127.0.0.1:3000 spacetime-auth-example "SELECT * FR - **OAuth reports a redirect mismatch:** compare the registered callback byte for byte with the URL derived from `AUTH_ISSUER_URL`. - **Sessions fail after a fresh publish:** clear site data and sign in again; - the database was deliberately replaced. + the database was replaced. ## Important files diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts index 16ff8d0a8b0..e7130d991f7 100644 --- a/spacetime-auth-ts/example/server.ts +++ b/spacetime-auth-ts/example/server.ts @@ -194,7 +194,7 @@ try { } app.listen(PORT, HOST, () => { - console.log(`Notes test app running at http://${HOST}:${PORT}`); + console.log(`Notes example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*)`); console.log(` Database -> ${STDB_APP_DB}`); diff --git a/spacetime-auth-ts/example/src/app.ts b/spacetime-auth-ts/example/src/app.ts index e2cb5a39014..3e22e1477cf 100644 --- a/spacetime-auth-ts/example/src/app.ts +++ b/spacetime-auth-ts/example/src/app.ts @@ -2,6 +2,7 @@ import { DbConnection, + tables, type EventContext, type ErrorContext, } from './codegen/app'; @@ -245,7 +246,7 @@ function wireSubscriptions(c: DbConnection) { c.subscriptionBuilder() .onApplied(() => broadcastNotes()) .onError((ctx: ErrorContext) => console.error('sub error', ctx.event)) - .subscribe(['SELECT * FROM my_notes', 'SELECT * FROM my_auth_user']); + .subscribe([tables.myNotes, tables.myAuthUser]); c.db.myNotes.onInsert(() => broadcastNotes()); c.db.myNotes.onUpdate(() => broadcastNotes()); diff --git a/spacetime-cron-ts/example/spacetimedb/src/index.ts b/spacetime-cron-ts/example/spacetimedb/src/index.ts index 6367de0fb49..aa161e11c7e 100644 --- a/spacetime-cron-ts/example/spacetimedb/src/index.ts +++ b/spacetime-cron-ts/example/spacetimedb/src/index.ts @@ -100,8 +100,8 @@ export const init = spacetimedb.init(ctx => { // ── Client-facing management ───────────────────────────────────────────────── -// These reducers are intentionally open so the local browser can exercise the -// submodule. Production applications must enforce their own admin policy. +// These reducers are open so the local browser can exercise the submodule. +// Production applications must enforce their own admin policy. const jobs: Record = { digest, cleanup }; diff --git a/spacetime-files-ts/example/README.md b/spacetime-files-ts/example/README.md index dad4128c351..4f85155a7e2 100644 --- a/spacetime-files-ts/example/README.md +++ b/spacetime-files-ts/example/README.md @@ -142,7 +142,7 @@ For a release smoke test, use two independent browser identities and verify: 4. A public file is reachable through `/files?id=`; an owner-only file returns 403. 5. Oversized uploads and invalid or conflicting paths fail atomically. 6. Refresh preserves the owning development identity unless the database was - deliberately reset. + reset. ## Troubleshooting @@ -152,7 +152,7 @@ For a release smoke test, use two independent browser identities and verify: target the database used by `STDB_URI`. - **An upload exceeds the limit:** keep example files below 4 MB; use an external object store for larger production assets. -- **Files disappear after a fresh publish:** `build:module:fresh` deliberately +- **Files disappear after a fresh publish:** `build:module:fresh` replaces the local database and all of its rows. ## Important files diff --git a/spacetime-files-ts/example/server.ts b/spacetime-files-ts/example/server.ts index 4fcdf7dc3a5..88ba5085e0f 100644 --- a/spacetime-files-ts/example/server.ts +++ b/spacetime-files-ts/example/server.ts @@ -97,7 +97,7 @@ app.get('/api/config', (_req: Request, res: Response) => { }); app.listen(PORT, HOST, () => { - console.log(`Vault test app running at http://${HOST}:${PORT}`); + console.log(`Vault example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxy /files/*)`); console.log(` Database -> ${STDB_APP_DB}`); diff --git a/spacetime-files-ts/example/src/app.ts b/spacetime-files-ts/example/src/app.ts index 9a1b0d74236..d26b7cad657 100644 --- a/spacetime-files-ts/example/src/app.ts +++ b/spacetime-files-ts/example/src/app.ts @@ -1,5 +1,5 @@ // SpacetimeDB connection and file-manager UI composition. -import { DbConnection, type ErrorContext } from './codegen/app'; +import { DbConnection, tables, type ErrorContext } from './codegen/app'; import type { FileSummary, Folder } from './codegen/app/types'; import { loadToken, @@ -1177,7 +1177,7 @@ async function main(): Promise { .onError((ctx: ErrorContext) => console.error('subscription error', ctx.event) ) - .subscribe(['SELECT * FROM my_folders', 'SELECT * FROM my_file_summaries']); + .subscribe([tables.myFolders, tables.myFileSummaries]); conn.db.myFolders.onInsert(scheduleRefresh); conn.db.myFolders.onUpdate(scheduleRefresh); diff --git a/spacetime-grid-ts/example/README.md b/spacetime-grid-ts/example/README.md index d8ae94f239e..5a5d3beecf6 100644 --- a/spacetime-grid-ts/example/README.md +++ b/spacetime-grid-ts/example/README.md @@ -113,7 +113,7 @@ Host match rules -> mounted Grid tables and helpers The browser first subscribes to caller-scoped match views. It creates a second, match-filtered subscription only for the selected match. Public catalogs and the -open-match lobby are intentionally shared; private match state is restricted by +open-match lobby are shared; private match state is restricted by the linked authenticated user and participation checks. ## Security and deployment boundaries diff --git a/spacetime-grid-ts/example/server.ts b/spacetime-grid-ts/example/server.ts index 21a3929b229..5c366e220a6 100644 --- a/spacetime-grid-ts/example/server.ts +++ b/spacetime-grid-ts/example/server.ts @@ -189,7 +189,7 @@ try { } app.listen(PORT, HOST, () => { - console.log(`Grid test app running at http://${HOST}:${PORT}`); + console.log(`Grid example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*)`); console.log(` Database -> ${STDB_APP_DB}`); diff --git a/spacetime-grid-ts/example/src/app.ts b/spacetime-grid-ts/example/src/app.ts index 34ee330f7dc..cdddc2f79b2 100644 --- a/spacetime-grid-ts/example/src/app.ts +++ b/spacetime-grid-ts/example/src/app.ts @@ -11,6 +11,7 @@ import { DbConnection, + tables, type ErrorContext, type SubscriptionHandle, } from './codegen/app'; @@ -280,15 +281,15 @@ function setActiveMatch(matchId: bigint | null): void { .onApplied(() => broadcastState()) .onError((ctx: ErrorContext) => console.error('match sub error', ctx.event)) .subscribe([ - `SELECT * FROM my_player_units WHERE match_id = ${matchId}`, - `SELECT * FROM my_grid_entities WHERE grid_id = ${m.gridId}`, - `SELECT * FROM my_cell_states WHERE grid_id = ${m.gridId}`, - `SELECT * FROM my_grids WHERE id = ${m.gridId}`, + tables.myPlayerUnits.where(row => row.matchId.eq(matchId)), + tables.myGridEntities.where(row => row.gridId.eq(m.gridId)), + tables.myCellStates.where(row => row.gridId.eq(m.gridId)), + tables.myGrids.where(row => row.id.eq(m.gridId)), ]); } function wireRowHandlers(conn: DbConnection): void { - const tables = [ + const tableAccessors = [ conn.db.myMatches, conn.db.myMatchParticipants, conn.db.myPlayerUnits, @@ -299,7 +300,7 @@ function wireRowHandlers(conn: DbConnection): void { conn.db.actorDirectory, conn.db.lobbyOpenMatches, ]; - for (const t of tables) { + for (const t of tableAccessors) { t.onInsert(() => broadcastState()); t.onUpdate(() => broadcastState()); t.onDelete(() => broadcastState()); @@ -338,11 +339,11 @@ async function bindSession( console.error('global sub error', ctx.event) ) .subscribe([ - 'SELECT * FROM my_matches', - 'SELECT * FROM my_match_participants', - 'SELECT * FROM unit_type', - 'SELECT * FROM actor_directory', - 'SELECT * FROM lobby_open_matches', + tables.myMatches, + tables.myMatchParticipants, + tables.unitType, + tables.actorDirectory, + tables.lobbyOpenMatches, ]); // Re-open per-match subscription if a match was active before reconnect. diff --git a/spacetime-lobby-ts/README.md b/spacetime-lobby-ts/README.md index 03b10e64b6f..58a4d1dfa88 100644 --- a/spacetime-lobby-ts/README.md +++ b/spacetime-lobby-ts/README.md @@ -39,7 +39,7 @@ export default spacetimedb; ``` Mounted host modules can call helpers with an explicit subject after they have -validated auth or mapped the STDB identity to an app user ID: +validated auth or mapped the SpacetimeDB identity to an application user ID: ```ts lobby.joinQueue(ctx.as.lobby, { diff --git a/spacetime-lobby-ts/example/README.md b/spacetime-lobby-ts/example/README.md index 9be94708cc3..7dc9b71f043 100644 --- a/spacetime-lobby-ts/example/README.md +++ b/spacetime-lobby-ts/example/README.md @@ -152,7 +152,7 @@ For a release smoke test: inspect `lobby_queue_summary` for queued tickets. - **The page connects to stale state:** verify `STDB_URI` targets the server registered as `local` by the publish scripts. -- **State disappears after republishing:** `build:module:fresh` deliberately +- **State disappears after republishing:** `build:module:fresh` deletes all local rows, including ratings. ## Important files diff --git a/spacetime-lobby-ts/example/src/app.ts b/spacetime-lobby-ts/example/src/app.ts index 69035da222b..7aa5072b495 100644 --- a/spacetime-lobby-ts/example/src/app.ts +++ b/spacetime-lobby-ts/example/src/app.ts @@ -1,4 +1,4 @@ -import { DbConnection, type ErrorContext } from './codegen'; +import { DbConnection, tables, type ErrorContext } from './codegen'; import { TOKEN_KEY_PREFIX, @@ -1042,20 +1042,20 @@ async function run(): Promise { console.error('subscription error', ctx.event) ) .subscribe([ - 'SELECT * FROM my_profile', - 'SELECT * FROM players', - 'SELECT * FROM my_lobby_tickets', - 'SELECT * FROM my_lobby_rooms', - 'SELECT * FROM my_lobby_room_seats', - 'SELECT * FROM lobby_queue_summary', - 'SELECT * FROM my_lobby_ratings', - 'SELECT * FROM lobby_ranked_leaderboard', - 'SELECT * FROM ship_catalog', - 'SELECT * FROM maneuver_catalog', - 'SELECT * FROM my_duels', - 'SELECT * FROM my_duel_combatants', - 'SELECT * FROM my_duel_round_logs', - 'SELECT * FROM my_duel_maneuvers', + tables.myProfile, + tables.players, + tables.myLobbyTickets, + tables.myLobbyRooms, + tables.myLobbyRoomSeats, + tables.lobbyQueueSummary, + tables.myLobbyRatings, + tables.lobbyRankedLeaderboard, + tables.shipCatalog, + tables.maneuverCatalog, + tables.myDuels, + tables.myDuelCombatants, + tables.myDuelRoundLogs, + tables.myDuelManeuvers, ]); wireTables(); wireActions(); diff --git a/spacetime-lobby-ts/src/index.ts b/spacetime-lobby-ts/src/index.ts index 919cb7b7c2f..922cb272a5d 100644 --- a/spacetime-lobby-ts/src/index.ts +++ b/spacetime-lobby-ts/src/index.ts @@ -1,4 +1,4 @@ -// Top-level entry. Only re-exports registered STDB exports plus standalone init. +// Registered SpacetimeDB exports for direct module publication. export { default, init } from './submodule/schema'; export { diff --git a/spacetime-posthog-ts/.gitignore b/spacetime-posthog-ts/.gitignore deleted file mode 100644 index 0eec7566545..00000000000 --- a/spacetime-posthog-ts/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -dist -ts-codegen -*.tsbuildinfo -.DS_Store diff --git a/spacetime-posthog-ts/README.md b/spacetime-posthog-ts/README.md index d627ca771dc..2654d301487 100644 --- a/spacetime-posthog-ts/README.md +++ b/spacetime-posthog-ts/README.md @@ -17,7 +17,7 @@ Requires SpacetimeDB 2.8.3 or later for submodule mounting. For the install-to-publish workflow, see [Getting started](https://spacetimedb.com/docs/). -This submodule can be published directly as its own STDB module from the root entrypoint. +This submodule can be published directly as its own SpacetimeDB module from the root entry point. ## Usage diff --git a/spacetime-posthog-ts/example/README.md b/spacetime-posthog-ts/example/README.md index 7ff1f4a079e..8617af7a12f 100644 --- a/spacetime-posthog-ts/example/README.md +++ b/spacetime-posthog-ts/example/README.md @@ -123,7 +123,7 @@ Submodule administrator grants are available only through module operations. private configuration table through the authenticated CLI owner. - `.stdb-server-token`, `.env`, and logs are ignored and must not be committed. - The development server binds to loopback by default. Setting `HOST` to another - address deliberately expands its network exposure. + address expands its network exposure. - The example server is scoped to local development. Production deployments should provision service identities and lifecycle supervision explicitly. @@ -145,7 +145,7 @@ advance through the authorized server identity. - **Connection targets disagree:** `STDB_URI`, `STDB_HTTP`, and the server selected by the publish script must refer to the same SpacetimeDB instance. - **Server identity cannot be authorized:** publish with the currently logged-in - CLI identity, then restart. Remove `.stdb-server-token` only when deliberately + CLI identity, then restart. Remove `.stdb-server-token` only when replacing the local server identity. - **Events stay queued:** verify `POSTHOG_PROJECT_API_KEY`, inspect server output, and confirm the PostHog host is reachable. diff --git a/spacetime-posthog-ts/example/server.ts b/spacetime-posthog-ts/example/server.ts index a820343fdd0..d3e21cf2082 100644 --- a/spacetime-posthog-ts/example/server.ts +++ b/spacetime-posthog-ts/example/server.ts @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { DbConnection, type ErrorContext } from './src/codegen'; +import { DbConnection, tables, type ErrorContext } from './src/codegen'; import { PRODUCTS, SCENARIOS } from './catalog/catalog'; import { discardStoredServerToken, @@ -183,7 +183,7 @@ function startAnalyticsDelivery(connection: DbConnection): void { .onError(ctx => console.error(`[posthog] outbox subscription failed: ${ctx.event}`) ) - .subscribe(['SELECT * FROM posthog_outbox_admin']); + .subscribe([tables.posthogOutboxAdmin]); } // Derive the PostHog app (dashboard) URL from the ingestion host, e.g. diff --git a/spacetime-posthog-ts/example/src/app.ts b/spacetime-posthog-ts/example/src/app.ts index d1bb834d7c0..0642234ca01 100644 --- a/spacetime-posthog-ts/example/src/app.ts +++ b/spacetime-posthog-ts/example/src/app.ts @@ -1,4 +1,9 @@ -import { DbConnection, type ErrorContext, type EventContext } from './codegen'; +import { + DbConnection, + tables, + type ErrorContext, + type EventContext, +} from './codegen'; import { MAX_MACHINE_LEVEL, RUSH_CYCLE_TICKS, @@ -986,15 +991,15 @@ async function run(): Promise { console.error('subscription error', ctx.event) ) .subscribe([ - 'SELECT * FROM cafe_products', - 'SELECT * FROM cafe_variants', - 'SELECT * FROM cafe_scenarios', - 'SELECT * FROM cafe_config', - 'SELECT * FROM cafe_metrics', - 'SELECT * FROM cafe_econ', - 'SELECT * FROM cafe_queue', - 'SELECT * FROM cafe_recent_sessions', - 'SELECT * FROM cafe_analytics_summary', + tables.cafeProducts, + tables.cafeVariants, + tables.cafeScenarios, + tables.cafeConfig, + tables.cafeMetrics, + tables.cafeEcon, + tables.cafeQueue, + tables.cafeRecentSessions, + tables.cafeAnalyticsSummary, ]); wireTableEvents(); diff --git a/spacetime-posthog-ts/src/index.ts b/spacetime-posthog-ts/src/index.ts index 75a6e6b4034..1643c75fef7 100644 --- a/spacetime-posthog-ts/src/index.ts +++ b/spacetime-posthog-ts/src/index.ts @@ -1,4 +1,4 @@ -// Top-level entry. Only re-exports registered STDB exports. +// Registered SpacetimeDB exports for direct module publication. export { default, init } from './submodule/schema'; export { diff --git a/spacetime-presence-ts/example/README.md b/spacetime-presence-ts/example/README.md index f2398e693f2..69dca03e57c 100644 --- a/spacetime-presence-ts/example/README.md +++ b/spacetime-presence-ts/example/README.md @@ -171,7 +171,7 @@ For a release smoke test, use two accounts and verify: - **Users appear offline too quickly:** confirm the browser remains connected and heartbeat calls reach the module within the configured limit. - **A browser token is rejected after a fresh publish:** clear site data and sign in - again because the database and signing state were deliberately reset. + again because the database and signing state were reset. ## Important files diff --git a/spacetime-presence-ts/example/server.ts b/spacetime-presence-ts/example/server.ts index 69263ca125f..eb7962e4575 100644 --- a/spacetime-presence-ts/example/server.ts +++ b/spacetime-presence-ts/example/server.ts @@ -195,7 +195,7 @@ try { } app.listen(PORT, HOST, () => { - console.log(`Chat test app running at http://${HOST}:${PORT}`); + console.log(`Chat example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxy /auth/*, /files)`); console.log(` Database -> ${STDB_APP_DB}`); diff --git a/spacetime-presence-ts/example/src/app.ts b/spacetime-presence-ts/example/src/app.ts index 3959a2e2ed0..7d83dab0084 100644 --- a/spacetime-presence-ts/example/src/app.ts +++ b/spacetime-presence-ts/example/src/app.ts @@ -1,5 +1,6 @@ import { DbConnection, + tables, type ErrorContext, type EventContext, } from './codegen/app/index.ts'; @@ -380,24 +381,24 @@ function wireSubscriptions(c: DbConnection): void { console.error('subscription error', ctx.event) ) .subscribe([ - 'SELECT * FROM my_chat_users', - 'SELECT * FROM my_servers', - 'SELECT * FROM my_server_members', - 'SELECT * FROM my_presence_entries', - 'SELECT * FROM my_rooms', - 'SELECT * FROM my_room_members', - 'SELECT * FROM my_room_messages', - 'SELECT * FROM my_room_message_reactions', - 'SELECT * FROM my_room_attachments', - 'SELECT * FROM my_message_threads', - 'SELECT * FROM my_thread_messages', - 'SELECT * FROM my_room_read_cursors', - 'SELECT * FROM my_auth_user', - 'SELECT * FROM my_rate_limit_status', + tables.myChatUsers, + tables.myServers, + tables.myServerMembers, + tables.myPresenceEntries, + tables.myRooms, + tables.myRoomMembers, + tables.myRoomMessages, + tables.myRoomMessageReactions, + tables.myRoomAttachments, + tables.myMessageThreads, + tables.myThreadMessages, + tables.myRoomReadCursors, + tables.myAuthUser, + tables.myRateLimitStatus, ]); const reRender = () => emitData(); - const tables = [ + const tableAccessors = [ c.db.myChatUsers, c.db.myRooms, c.db.myRoomMembers, @@ -411,7 +412,7 @@ function wireSubscriptions(c: DbConnection): void { c.db.myPresenceEntries, c.db.myRateLimitStatus, ]; - for (const t of tables) { + for (const t of tableAccessors) { t.onInsert(reRender); t.onUpdate(reRender); t.onDelete(reRender); diff --git a/spacetime-rate-limit-ts/README.md b/spacetime-rate-limit-ts/README.md index c29d077a3a6..bf9cc05e9bd 100644 --- a/spacetime-rate-limit-ts/README.md +++ b/spacetime-rate-limit-ts/README.md @@ -134,7 +134,7 @@ See the [Powerhouse host module](./example/spacetimedb/) for per-action policies, caller-visible status, and admin controls. -## Exported Defaults +## Exported defaults - `DEFAULT_SWEEP_BATCH = 500` - `DEFAULT_SWEEP_INTERVAL_SECONDS = 30n` diff --git a/spacetime-rate-limit-ts/example/server.ts b/spacetime-rate-limit-ts/example/server.ts index ca0344ce1bd..b66aa149583 100644 --- a/spacetime-rate-limit-ts/example/server.ts +++ b/spacetime-rate-limit-ts/example/server.ts @@ -35,6 +35,6 @@ app.get('/api/config', (_req: Request, res: Response) => { }); app.listen(PORT, HOST, () => { - console.log(`Rate-limit test app running at http://${HOST}:${PORT}`); + console.log(`Rate-limit example running at http://${HOST}:${PORT}`); console.log(` STDB -> ${STDB_URI} (${STDB_APP_DB})`); }); diff --git a/spacetime-rate-limit-ts/example/src/app.ts b/spacetime-rate-limit-ts/example/src/app.ts index d1fb33f52b7..c9dac2a2ded 100644 --- a/spacetime-rate-limit-ts/example/src/app.ts +++ b/spacetime-rate-limit-ts/example/src/app.ts @@ -1,5 +1,6 @@ import { DbConnection, + tables, type ErrorContext, type EventContext, } from './codegen/app/index.ts'; @@ -311,12 +312,12 @@ function wireDataHandlers(conn: DbConnection): void { console.error('subscription error', ctx.event) ) .subscribe([ - 'SELECT * FROM reactor_state', - 'SELECT * FROM reactor_events', - 'SELECT * FROM reactor_limit_status', - 'SELECT * FROM reactor_players', - 'SELECT * FROM reactor_shop', - 'SELECT * FROM rate_limit_demo_config', + tables.reactorState, + tables.reactorEvents, + tables.reactorLimitStatus, + tables.reactorPlayers, + tables.reactorShop, + tables.rateLimitDemoConfig, ]); db.reactorState.onInsert(() => broadcastState()); diff --git a/spacetime-resend-ts/.gitignore b/spacetime-resend-ts/.gitignore deleted file mode 100644 index 0eec7566545..00000000000 --- a/spacetime-resend-ts/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -dist -ts-codegen -*.tsbuildinfo -.DS_Store diff --git a/spacetime-resend-ts/README.md b/spacetime-resend-ts/README.md index ed4361c4eba..4bca6eff134 100644 --- a/spacetime-resend-ts/README.md +++ b/spacetime-resend-ts/README.md @@ -17,7 +17,7 @@ Requires SpacetimeDB 2.8.3 or later for submodule mounting. For the install-to-publish workflow, see [Getting started](https://spacetimedb.com/docs/). -This submodule can be published directly as its own STDB module from the root entrypoint. +This submodule can be published directly as its own SpacetimeDB module from the root entry point. ## Usage @@ -202,7 +202,7 @@ spacetime call --server http://127.0.0.1:3000 resend-ts set_resend_config '"re_. spacetime call --server http://127.0.0.1:3000 resend-ts send_email \ null \ '["delivered@resend.dev"]' \ - '"Test from STDB"' \ + '"Test from SpacetimeDB"' \ '"

          Hello.

          "' \ null null null null \ '"{\"userId\":\"u_123\"}"' \ diff --git a/spacetime-resend-ts/example/src/app.ts b/spacetime-resend-ts/example/src/app.ts index c93675fef49..bde7b97bf58 100644 --- a/spacetime-resend-ts/example/src/app.ts +++ b/spacetime-resend-ts/example/src/app.ts @@ -1,5 +1,6 @@ import { DbConnection, + tables, type ErrorContext, type EventContext, type SubscriptionEventContext, @@ -513,10 +514,7 @@ function wireDataHandlers() { console.error('subscription error:', ctx.event); showError('Subscription failed. Check the server console.'); }) - .subscribe([ - 'SELECT * FROM my_dispatch_emails', - 'SELECT * FROM my_dispatch_delivery_events', - ]); + .subscribe([tables.myDispatchEmails, tables.myDispatchDeliveryEvents]); } async function sendDispatch( diff --git a/spacetime-resend-ts/src/index.ts b/spacetime-resend-ts/src/index.ts index c2683a06994..b9ccdc72c28 100644 --- a/spacetime-resend-ts/src/index.ts +++ b/spacetime-resend-ts/src/index.ts @@ -1,4 +1,4 @@ -// Top-level entry. Only re-exports registered reducers/procedures (the runtime rejects other public exports). +// Registered SpacetimeDB exports for direct module publication. export { default, init } from './submodule/schema'; export { @@ -6,7 +6,6 @@ export { replay_webhook_event, } from './submodule/webhooks'; -// Setup procedures explicit (avoid re-exporting helpers from auth.ts / config.ts). export { set_resend_config, get_resend_config_status, diff --git a/spacetime-stripe-ts/README.md b/spacetime-stripe-ts/README.md index f0396841023..e623b7409d9 100644 --- a/spacetime-stripe-ts/README.md +++ b/spacetime-stripe-ts/README.md @@ -18,7 +18,7 @@ Requires SpacetimeDB 2.8.3 or later for submodule mounting. For the install-to-publish workflow, see [Getting started](https://spacetimedb.com/docs/). -This submodule can be published directly as its own STDB module from the root entrypoint. +This submodule can be published directly as its own SpacetimeDB module from the root entry point. ## Usage @@ -193,7 +193,7 @@ Both entry points verify the Stripe signature in-module against the configured `webhookSigningSecret` (HMAC-SHA256 over `${timestamp}.${rawBody}` via `@spacetimedb/crypto`). Missing secrets produce a service-unavailable response: -- `stripe_webhook_handler` (HTTP) - for direct Stripe -> STDB delivery. +- `stripe_webhook_handler` (HTTP) - for direct Stripe-to-SpacetimeDB delivery. - `ingest_stripe_webhook` (reducer) - for a relay forwarding the raw body + `stripe-signature` header over the SDK; it verifies before mutating state. diff --git a/spacetime-stripe-ts/example/README.md b/spacetime-stripe-ts/example/README.md index 068cb6a7507..00acfe5821e 100644 --- a/spacetime-stripe-ts/example/README.md +++ b/spacetime-stripe-ts/example/README.md @@ -166,7 +166,7 @@ updated. - Browser provider actions are automatic only for a non-production loopback host. Production and externally bound development servers default to disabled. Add application authentication and rate limiting, then set - `STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS=1` deliberately. + `STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS=1` only when required. - The server owns Checkout return URLs. Set `STRIPE_RETURN_BASE_URL` to the public HTTPS origin in production; browser-supplied redirect URLs are ignored. - Checkout success in the UI is a redirect result; authoritative fulfillment must @@ -197,7 +197,7 @@ create a test Checkout session. ## Important files -- `spacetimedb/src/submodule/operations.ts`: application catalog and Stripe +- `spacetimedb/src/store/operations.ts`: application catalog and Stripe delegation. - `server.ts`: safe startup configuration and server identity authorization. - `src/app.ts`: typed browser-side SpacetimeDB adapter. diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts index 3685bb2edcd..5659c26a959 100644 --- a/spacetime-stripe-ts/example/server.ts +++ b/spacetime-stripe-ts/example/server.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { DbConnection, type ErrorContext } from './src/codegen/app'; +import { DbConnection, tables, type ErrorContext } from './src/codegen/app'; import { discardStoredServerToken, grantServerIdentity, @@ -341,7 +341,7 @@ async function seedCatalogIfEmpty(conn: DbConnection): Promise { resolved = true; reject(new Error(`catalog probe failed: ${ctx.event}`)); }) - .subscribe(['SELECT * FROM store_product']); + .subscribe([tables.storeProduct]); }); const count = conn.db.storeProduct.count(); @@ -412,6 +412,6 @@ async function seedCatalogIfEmpty(conn: DbConnection): Promise { } app.listen(PORT, HOST, () => { - console.log(`Premium store test app running at http://${HOST}:${PORT}`); + console.log(`Premium Store example running at http://${HOST}:${PORT}`); }); })(); diff --git a/spacetime-stripe-ts/example/spacetimedb/README.md b/spacetime-stripe-ts/example/spacetimedb/README.md deleted file mode 100644 index 011ca24dcef..00000000000 --- a/spacetime-stripe-ts/example/spacetimedb/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Premium Store store module (stripe-ts example) - -Per-app STDB module for `spacetime-stripe-example`. Owns the storefront's product catalog (`store_product`) and admin gating, and mounts the Stripe primitives from [`stripe-ts`](../../) under the `stripe` submodule. - -This module exists to demonstrate how a real consumer integrates `stripe-ts`: the consumer brings their own STDB module for app-specific tables, mounts the submodule, and delegates to it through `ctx.as.stripe`. - -## Tables - -- `store_product`: public catalog rows -- `store_admin_identity`: private admin allowlist; fresh publishes seed the database owner from `init` - -## Procedures - -- `upsert_store_product`: admin-gated -- `seed_default_store_products({ force})`: admin-gated; idempotent unless `force` -- `set_store_product_price` / `clear_store_product_price`: admin-gated; links a Stripe price ID -- `list_store_products_json`: public read -- `add_admin_identity` / `remove_admin_identity` - -## Publishing - -```bash -spacetime publish --server http://127.0.0.1:3000 --yes spacetime-stripe-example -``` - -The parent test app's `pnpm run dev` calls this for you. - -## License - -[BUSL 1.1](../../LICENSE.txt), same as SpacetimeDB. diff --git a/spacetime-stripe-ts/example/spacetimedb/src/index.ts b/spacetime-stripe-ts/example/spacetimedb/src/index.ts index be30a418f7e..330b90a795f 100644 --- a/spacetime-stripe-ts/example/spacetimedb/src/index.ts +++ b/spacetime-stripe-ts/example/spacetimedb/src/index.ts @@ -1,9 +1,4 @@ -export { default, init } from './submodule/schema'; -export * from './submodule/operations'; -export { add_admin_identity, remove_admin_identity } from './submodule/auth'; -export { - health, - echo, - stripe_webhook_handler, - router, -} from './submodule/webhooks'; +export { default, init } from './store/schema'; +export * from './store/operations'; +export { add_admin_identity, remove_admin_identity } from './store/auth'; +export { health, echo, stripe_webhook_handler, router } from './store/webhooks'; diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/auth.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts similarity index 100% rename from spacetime-stripe-ts/example/spacetimedb/src/submodule/auth.ts rename to spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts similarity index 100% rename from spacetime-stripe-ts/example/spacetimedb/src/submodule/operations.ts rename to spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/schema.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/schema.ts similarity index 100% rename from spacetime-stripe-ts/example/spacetimedb/src/submodule/schema.ts rename to spacetime-stripe-ts/example/spacetimedb/src/store/schema.ts diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/utils.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/utils.ts similarity index 100% rename from spacetime-stripe-ts/example/spacetimedb/src/submodule/utils.ts rename to spacetime-stripe-ts/example/spacetimedb/src/store/utils.ts diff --git a/spacetime-stripe-ts/example/spacetimedb/src/submodule/webhooks.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts similarity index 100% rename from spacetime-stripe-ts/example/spacetimedb/src/submodule/webhooks.ts rename to spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts diff --git a/spacetime-stripe-ts/example/src/app.ts b/spacetime-stripe-ts/example/src/app.ts index 57732de8a21..d7532d1e096 100644 --- a/spacetime-stripe-ts/example/src/app.ts +++ b/spacetime-stripe-ts/example/src/app.ts @@ -1,5 +1,6 @@ import { DbConnection, + tables, type EventContext, type ErrorContext, } from './codegen/app'; @@ -214,7 +215,7 @@ async function main() { console.error('catalog sub error', ctx.event); updateConnState('error', String(ctx.event)); }) - .subscribe(['SELECT * FROM store_product']); + .subscribe([tables.storeProduct]); } main(); diff --git a/tools/check-example-assets.mjs b/tools/check-example-assets.mjs index 4a02d10854c..52d56c73111 100644 --- a/tools/check-example-assets.mjs +++ b/tools/check-example-assets.mjs @@ -1,19 +1,24 @@ #!/usr/bin/env node -import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { releasePackages } from './release-packages.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const failures = []; let checked = 0; -for (const entry of readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory() || !/^spacetime-.+-ts$/.test(entry.name)) continue; +for (const packageDir of releasePackages) { + const exampleDir = join(root, packageDir, 'example'); + if (!existsSync(join(exampleDir, 'package.json'))) continue; - const publicDir = join(root, entry.name, 'example', 'public'); + const publicDir = join(exampleDir, 'public'); const indexPath = join(publicDir, 'index.html'); - if (!existsSync(indexPath)) continue; + if (!existsSync(indexPath)) { + failures.push(`${packageDir}: example/public/index.html is missing`); + continue; + } checked++; const html = readFileSync(indexPath, 'utf8'); @@ -21,30 +26,26 @@ for (const entry of readdirSync(root, { withFileTypes: true })) { const uiPath = join(publicDir, 'ui.js'); if (/)/i.test(html)) { - failures.push(`${entry.name}: index.html contains an inline style block`); + failures.push(`${packageDir}: index.html contains an inline style block`); } if (/]*\bsrc=)[^>]*>/i.test(html)) { - failures.push(`${entry.name}: index.html contains an inline script block`); + failures.push(`${packageDir}: index.html contains an inline script block`); } if (!existsSync(stylesPath)) { - failures.push(`${entry.name}: public/styles.css is missing`); + failures.push(`${packageDir}: public/styles.css is missing`); } if (!/]+href=["'](?:\.\/|\/)styles\.css["'][^>]*>/i.test(html)) { - failures.push(`${entry.name}: index.html does not load styles.css`); + failures.push(`${packageDir}: index.html does not load styles.css`); } const loadsUi = /]+src=["']\.\/ui\.js["'][^>]*>/i.test(html); if (existsSync(uiPath) !== loadsUi) { failures.push( - `${entry.name}: public/ui.js and its index.html script tag do not match` + `${packageDir}: public/ui.js and its index.html script tag do not match` ); } } -if (checked !== 12) { - failures.push(`expected 12 browser examples, found ${checked}`); -} - if (failures.length > 0) { console.error('Example asset check failed:'); for (const failure of failures) console.error(`- ${failure}`); diff --git a/tools/doc-check.mjs b/tools/doc-check.mjs index 4e1c266fa55..b166be6cb26 100644 --- a/tools/doc-check.mjs +++ b/tools/doc-check.mjs @@ -20,7 +20,6 @@ const documentationFiles = [ if (existsSync(resolve(root, exampleReadme))) files.push(exampleReadme); return files; }), - 'spacetime-stripe-ts/example/spacetimedb/README.md', ]; function fail(file, line, message) { diff --git a/tools/run-example-builds.mjs b/tools/run-example-builds.mjs index 0c52144c1b2..4b7fcbb710e 100644 --- a/tools/run-example-builds.mjs +++ b/tools/run-example-builds.mjs @@ -1,25 +1,21 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { releasePackages } from './release-packages.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const targets = [ - 'spacetime-agents-ts/example', - 'spacetime-api-keys-ts/example', - 'spacetime-auth-ts/example', - 'spacetime-cron-ts/example', - 'spacetime-files-ts/example', - 'spacetime-grid-ts/example', - 'spacetime-lobby-ts/example', - 'spacetime-posthog-ts/example', - 'spacetime-presence-ts/example', - 'spacetime-rate-limit-ts/example', - 'spacetime-resend-ts/example', - 'spacetime-stripe-ts/example', -]; +const targets = releasePackages + .map(packageDir => `${packageDir}/example`) + .filter(target => { + const manifestPath = resolve(root, target, 'package.json'); + if (!existsSync(manifestPath)) return false; + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + return Boolean(manifest.scripts?.build); + }); const failures = []; for (const target of targets) { @@ -37,4 +33,4 @@ if (failures.length > 0) { process.exit(1); } -console.log(`\nExample builds passed for ${targets.length} browser samples.`); +console.log(`\nExample builds passed for ${targets.length} browser examples.`); diff --git a/tools/run-example-tests.mjs b/tools/run-example-tests.mjs index 8d8e9f2d2f2..ae19940d917 100644 --- a/tools/run-example-tests.mjs +++ b/tools/run-example-tests.mjs @@ -1,23 +1,24 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { releasePackages } from './release-packages.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const targets = [ - 'spacetime-agents-ts/example', - 'spacetime-agents-ts/example/spacetimedb', - 'spacetime-api-keys-ts/example', - 'spacetime-files-ts/example', - 'spacetime-grid-ts/example', - 'spacetime-lobby-ts/example', - 'spacetime-posthog-ts/example', - 'spacetime-presence-ts/example', - 'spacetime-rate-limit-ts/example', - 'spacetime-resend-ts/example', -]; +const targets = releasePackages + .flatMap(packageDir => [ + `${packageDir}/example`, + `${packageDir}/example/spacetimedb`, + ]) + .filter(target => { + const manifestPath = resolve(root, target, 'package.json'); + if (!existsSync(manifestPath)) return false; + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + return Boolean(manifest.scripts?.['test:unit']); + }); const failures = []; for (const target of targets) { diff --git a/tools/run-module-builds.mjs b/tools/run-module-builds.mjs index 901c4b66428..6e4a262bada 100644 --- a/tools/run-module-builds.mjs +++ b/tools/run-module-builds.mjs @@ -1,63 +1,44 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { releasePackages } from './release-packages.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const targets = [ - 'spacetime-agents-ts/spacetimedb', - 'spacetime-agents-ts/example/spacetimedb', - 'spacetime-api-keys-ts/example/spacetimedb', - 'spacetime-auth-ts/spacetimedb', - 'spacetime-auth-ts/example/spacetimedb', - 'spacetime-cron-ts/spacetimedb', - 'spacetime-cron-ts/example/spacetimedb', - 'spacetime-files-ts/example/spacetimedb', - 'spacetime-grid-ts/example/spacetimedb', - 'spacetime-lobby-ts', - 'spacetime-lobby-ts/example/spacetimedb', - 'spacetime-posthog-ts', - 'spacetime-posthog-ts/example/spacetimedb', - 'spacetime-presence-ts/spacetimedb', - 'spacetime-presence-ts/example/spacetimedb', - 'spacetime-rate-limit-ts/spacetimedb', - 'spacetime-rate-limit-ts/example/spacetimedb', - 'spacetime-resend-ts', - 'spacetime-resend-ts/example/spacetimedb', - 'spacetime-retry-ts/spacetimedb', - 'spacetime-stripe-ts', - 'spacetime-stripe-ts/example/spacetimedb', -]; +const targets = releasePackages + .flatMap(packageDir => [ + packageDir, + `${packageDir}/spacetimedb`, + `${packageDir}/example/spacetimedb`, + ]) + .filter(target => { + const manifestPath = resolve(root, target, 'package.json'); + if (!existsSync(manifestPath)) return false; + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + return manifest.scripts?.build === 'spacetime build'; + }); const failures = []; for (const target of targets) { - let passed = false; - for (let attempt = 1; attempt <= 2; attempt += 1) { - const suffix = attempt === 1 ? '' : ' (retry after transient failure)'; - console.log(`\nBuilding ${target}${suffix}`); - const result = spawnSync(pnpmCommand, ['--dir', target, 'run', 'build'], { - cwd: root, - encoding: 'utf8', - shell: process.platform === 'win32', - }); - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; - const reportedRuntimeError = /^Error: Uncaught\b/m.test(output); - if (result.status === 0 && !result.error && !reportedRuntimeError) { - passed = true; - break; - } - if (reportedRuntimeError) { - console.error(`Build reported a runtime error for ${target}.`); - } - if (attempt === 1) { - console.warn(`Build failed for ${target}; retrying once.`); - } + console.log(`\nBuilding ${target}`); + const result = spawnSync(pnpmCommand, ['--dir', target, 'run', 'build'], { + cwd: root, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + const reportedRuntimeError = /^Error: Uncaught\b/m.test(output); + if (reportedRuntimeError) { + console.error(`Build reported a runtime error for ${target}.`); + } + if (result.status !== 0 || result.error || reportedRuntimeError) { + failures.push(target); } - if (!passed) failures.push(target); } if (failures.length > 0) { diff --git a/tools/run-production-audits.mjs b/tools/run-production-audits.mjs deleted file mode 100644 index 1ef4885720a..00000000000 --- a/tools/run-production-audits.mjs +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const result = spawnSync( - process.execPath, - [resolve(root, 'tools/consumer-install-check.mjs'), '--audit'], - { cwd: root, stdio: 'inherit' } -); - -process.exit(result.status ?? 1); From 45e56c648f271bc0c7bf3b74afd1f9eeef9ff803 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 18:48:52 -0400 Subject: [PATCH 06/33] Harden submodule sweep operations --- spacetime-presence-ts/README.md | 11 +++- spacetime-presence-ts/scripts/test.ts | 60 +++++++++++++++++++ spacetime-presence-ts/src/index.ts | 4 ++ spacetime-presence-ts/src/mounted/index.ts | 18 ++++-- spacetime-presence-ts/src/presence.ts | 52 ++++++++++++++-- spacetime-rate-limit-ts/README.md | 1 + spacetime-rate-limit-ts/scripts/test.ts | 31 ++++++++++ spacetime-rate-limit-ts/src/index.ts | 2 + spacetime-rate-limit-ts/src/limit.ts | 20 ++++++- spacetime-rate-limit-ts/src/submodule.ts | 2 + .../src/submodule/operations.ts | 13 ++-- 11 files changed, 195 insertions(+), 19 deletions(-) diff --git a/spacetime-presence-ts/README.md b/spacetime-presence-ts/README.md index 8a849e21864..ef2fad12a0f 100644 --- a/spacetime-presence-ts/README.md +++ b/spacetime-presence-ts/README.md @@ -126,8 +126,9 @@ conn - `resolvePresenceSweepBatch` validates configured cleanup batch sizes. - `presenceEntryRow`, `presenceConfigRow`, `presenceSweepTickRow`, and `presenceTables` support lower-level table composition. -- `DEFAULT_PRESENCE_TTL_SECONDS`, `DEFAULT_PRESENCE_SWEEP_BATCH`, and - `DEFAULT_PRESENCE_STATUS` expose the package defaults. +- `DEFAULT_PRESENCE_TTL_SECONDS`, `DEFAULT_PRESENCE_SWEEP_BATCH`, + `MAX_PRESENCE_SWEEP_BATCH`, and `DEFAULT_PRESENCE_STATUS` expose the package + limits and defaults. Package entrypoints: @@ -137,6 +138,12 @@ Package entrypoints: - `@spacetimedb/presence/submodule` exports the ready-made mounted namespace. +The ready-made namespace publishes `presence_entry` rows. The `activity` and +`payloadJson` fields are visible to subscribed clients. Do not store secrets or +private application data in these fields. Its manual sweep and configuration +operations require a presence administrator. Sweep batches are limited to +10,000 rows per call. + ## Testing ```bash diff --git a/spacetime-presence-ts/scripts/test.ts b/spacetime-presence-ts/scripts/test.ts index cb9b0aceb95..d23b723d6f8 100644 --- a/spacetime-presence-ts/scripts/test.ts +++ b/spacetime-presence-ts/scripts/test.ts @@ -1,9 +1,13 @@ import { Timestamp } from 'spacetimedb'; import { buildPresenceKey, + installPresenceConfig, + MAX_PRESENCE_SWEEP_BATCH, removePresence, + resolvePresenceSweepBatch, sweepPresence, touchPresence, + updatePresenceConfig, upsertPresence, type PresenceEntryRow, } from '../src/presence.ts'; @@ -23,6 +27,15 @@ function assert(cond: boolean, name: string, detail = ''): void { } } +function assertThrows(fn: () => void, expected: string, name: string): void { + try { + fn(); + assert(false, name, `expected ${expected}`); + } catch (error) { + assert(error instanceof Error && error.message === expected, name); + } +} + function makeTx(nowMicros = 0n) { const rows = new Map(); const tx = { @@ -80,6 +93,53 @@ function makeTx(nowMicros = 0n) { process.stdout.write('\npresence submodule\n'); +{ + let config: + | { + singleton: boolean; + defaultTtlSeconds: number; + sweepBatch: number; + updatedAt: Timestamp; + } + | undefined; + const ctx = { + timestamp: new Timestamp(1n), + db: { + presenceConfig: { + singleton: { + find: () => config, + update: (row: typeof config) => { + config = row; + }, + }, + insert: (row: NonNullable) => { + config = row; + }, + }, + }, + }; + installPresenceConfig(ctx, { defaultTtlSeconds: 30, sweepBatch: 500 }); + ctx.timestamp = new Timestamp(2n); + updatePresenceConfig(ctx, { defaultTtlSeconds: 45, sweepBatch: 750 }); + assert( + config?.defaultTtlSeconds === 45 && config.sweepBatch === 750, + 'configuration updates an existing row' + ); + assert( + resolvePresenceSweepBatch(ctx) === 750, + 'sweep reads the updated batch size' + ); +} + +{ + const tx = makeTx(); + assertThrows( + () => sweepPresence(tx, [], MAX_PRESENCE_SWEEP_BATCH + 1), + 'presence.invalid_sweep_batch', + 'sweep rejects an excessive batch size' + ); +} + assert( buildPresenceKey('room::one', 'user') !== buildPresenceKey('room', 'one::user'), diff --git a/spacetime-presence-ts/src/index.ts b/spacetime-presence-ts/src/index.ts index 3cfc18e30c2..3f384698714 100644 --- a/spacetime-presence-ts/src/index.ts +++ b/spacetime-presence-ts/src/index.ts @@ -12,6 +12,7 @@ export { export { DEFAULT_PRESENCE_TTL_SECONDS, DEFAULT_PRESENCE_SWEEP_BATCH, + MAX_PRESENCE_SWEEP_BATCH, DEFAULT_PRESENCE_STATUS, buildPresenceKey, installPresenceConfig, @@ -19,9 +20,12 @@ export { touchPresence, removePresence, sweepPresence, + assertPresenceSweepBatch, + updatePresenceConfig, resolvePresenceSweepBatch, runPresenceSweep, type PresenceConfigCtxLike, + type PresenceConfigReadCtxLike, type PresenceEntryRow, type PresenceSweepCtxLike, type PresenceTxLike, diff --git a/spacetime-presence-ts/src/mounted/index.ts b/spacetime-presence-ts/src/mounted/index.ts index 3710963b6ca..090c9ef5e85 100644 --- a/spacetime-presence-ts/src/mounted/index.ts +++ b/spacetime-presence-ts/src/mounted/index.ts @@ -13,10 +13,11 @@ import { import { DEFAULT_PRESENCE_SWEEP_BATCH, DEFAULT_PRESENCE_STATUS, - installPresenceConfig, + MAX_PRESENCE_SWEEP_BATCH, removePresence, runPresenceSweep, sweepPresence, + updatePresenceConfig, upsertPresence, } from '../index'; @@ -116,8 +117,8 @@ function requireAdmin(ctx: Tx): void { } } -function toU32(name: string, value: number): number { - if (!Number.isInteger(value) || value <= 0 || value > 0xffff_ffff) { +function toU32(name: string, value: number, max = 0xffff_ffff): number { + if (!Number.isInteger(value) || value <= 0 || value > max) { throw new SenderError(`presence.invalid_${name}`); } return value; @@ -189,9 +190,10 @@ export const run_sweep = spacetimedb.procedure( const maxRows = args.maxRows === undefined ? undefined - : toU32('sweep_batch', Number(args.maxRows)); + : toU32('sweep_batch', Number(args.maxRows), MAX_PRESENCE_SWEEP_BATCH); let deleted = 0; ctx.withTx(tx => { + requireAdmin(tx); deleted = sweepPresence( tx, tx.db.presenceEntry.expiresAt.filter( @@ -221,12 +223,16 @@ export const update_config = spacetimedb.reducer( { defaultTtlSeconds: t.u32(), sweepBatch: t.u32() }, (ctx, args) => { requireAdmin(ctx); - installPresenceConfig(ctx, { + updatePresenceConfig(ctx, { defaultTtlSeconds: toU32( 'default_ttl_seconds', Number(args.defaultTtlSeconds) ), - sweepBatch: toU32('sweep_batch', Number(args.sweepBatch)), + sweepBatch: toU32( + 'sweep_batch', + Number(args.sweepBatch), + MAX_PRESENCE_SWEEP_BATCH + ), }); } ); diff --git a/spacetime-presence-ts/src/presence.ts b/spacetime-presence-ts/src/presence.ts index 19780612bd7..4f91014bf69 100644 --- a/spacetime-presence-ts/src/presence.ts +++ b/spacetime-presence-ts/src/presence.ts @@ -5,6 +5,7 @@ const U32_MAX = 0xffff_ffff; export const DEFAULT_PRESENCE_TTL_SECONDS = 30; export const DEFAULT_PRESENCE_SWEEP_BATCH = 500; +export const MAX_PRESENCE_SWEEP_BATCH = 10_000; export const DEFAULT_PRESENCE_STATUS = 'online'; const MAX_SCOPE_LENGTH = 128; const MAX_SUBJECT_LENGTH = 256; @@ -70,6 +71,16 @@ export interface PresenceSweepCtxLike extends PresenceTxLike { }; } +export interface PresenceConfigReadCtxLike { + db: { + presenceConfig: { + singleton: { + find(key: boolean): PresenceConfigRow | null | undefined; + }; + }; + }; +} + export interface PresenceUpsertOpts { scope: string; subject: string; @@ -118,7 +129,7 @@ export function installPresenceConfig( opts?.defaultTtlSeconds ?? DEFAULT_PRESENCE_TTL_SECONDS; const sweepBatch = opts?.sweepBatch ?? DEFAULT_PRESENCE_SWEEP_BATCH; assertPositiveU32('default_ttl_seconds', defaultTtlSeconds); - assertPositiveU32('sweep_batch', sweepBatch); + assertPresenceSweepBatch(sweepBatch); const existing = ctx.db.presenceConfig.singleton.find(true); if (!existing) { @@ -132,6 +143,29 @@ export function installPresenceConfig( } } +export function assertPresenceSweepBatch(value: number): void { + assertPositiveU32('sweep_batch', value); + if (value > MAX_PRESENCE_SWEEP_BATCH) { + throw new Error('presence.invalid_sweep_batch'); + } +} + +export function updatePresenceConfig( + ctx: PresenceConfigCtxLike, + opts: Required +): void { + assertPositiveU32('default_ttl_seconds', opts.defaultTtlSeconds); + assertPresenceSweepBatch(opts.sweepBatch); + const existing = ctx.db.presenceConfig.singleton.find(true); + if (!existing) throw new Error('presence.config_missing'); + ctx.db.presenceConfig.singleton.update({ + ...existing, + defaultTtlSeconds: opts.defaultTtlSeconds, + sweepBatch: opts.sweepBatch, + updatedAt: ctx.timestamp, + }); +} + export function upsertPresence( tx: PresenceTxLike, opts: PresenceUpsertOpts @@ -233,7 +267,7 @@ export function sweepPresence( expiredRows: Iterable, maxRows = DEFAULT_PRESENCE_SWEEP_BATCH ): number { - assertPositiveU32('sweep_batch', maxRows); + assertPresenceSweepBatch(maxRows); const nowMicros = tx.timestamp.microsSinceUnixEpoch as bigint; let deleted = 0; for (const row of expiredRows) { @@ -246,12 +280,22 @@ export function sweepPresence( } export function resolvePresenceSweepBatch( - ctx: PresenceSweepCtxLike, + ctx: PresenceConfigReadCtxLike, fallback = DEFAULT_PRESENCE_SWEEP_BATCH ): number { const cfg = ctx.db.presenceConfig.singleton.find(true); const value = Number(cfg?.sweepBatch ?? fallback); - return value > 0 ? value : fallback; + const safeFallback = + Number.isInteger(fallback) && + fallback > 0 && + fallback <= MAX_PRESENCE_SWEEP_BATCH + ? fallback + : DEFAULT_PRESENCE_SWEEP_BATCH; + return Number.isInteger(value) && + value > 0 && + value <= MAX_PRESENCE_SWEEP_BATCH + ? value + : safeFallback; } export function runPresenceSweep( diff --git a/spacetime-rate-limit-ts/README.md b/spacetime-rate-limit-ts/README.md index bf9cc05e9bd..5b70edb2715 100644 --- a/spacetime-rate-limit-ts/README.md +++ b/spacetime-rate-limit-ts/README.md @@ -137,6 +137,7 @@ for per-action policies, caller-visible status, and admin controls. ## Exported defaults - `DEFAULT_SWEEP_BATCH = 500` +- `MAX_SWEEP_BATCH = 10_000` - `DEFAULT_SWEEP_INTERVAL_SECONDS = 30n` ## Testing diff --git a/spacetime-rate-limit-ts/scripts/test.ts b/spacetime-rate-limit-ts/scripts/test.ts index 5070563624c..54b6c93928e 100644 --- a/spacetime-rate-limit-ts/scripts/test.ts +++ b/spacetime-rate-limit-ts/scripts/test.ts @@ -1,6 +1,8 @@ import { Timestamp } from 'spacetimedb'; import { consumeRateLimit, + MAX_SWEEP_BATCH, + resolveRateLimitSweepBatch, sweepRateLimits, type RateLimitBucketRow, } from '../src/limit.ts'; @@ -21,6 +23,15 @@ function assert(cond: boolean, name: string, detail = ''): void { } } +function assertThrows(fn: () => void, expected: string, name: string): void { + try { + fn(); + assert(false, name, `expected ${expected}`); + } catch (error) { + assert(error instanceof Error && error.message === expected, name); + } +} + function makeTx(nowMicros = 0n) { const rows = new Map(); const tx = { @@ -82,6 +93,26 @@ function makeTx(nowMicros = 0n) { process.stdout.write('\nrate limiter\n'); +{ + const tx = makeTx(); + assertThrows( + () => sweepRateLimits(tx, [], MAX_SWEEP_BATCH + 1), + 'rate_limit.invalid_sweep_batch', + 'sweep rejects an excessive batch size' + ); + const batch = resolveRateLimitSweepBatch( + { + db: { + rateLimitConfig: { + singleton: { find: () => ({ sweepBatch: MAX_SWEEP_BATCH + 1 }) }, + }, + }, + }, + 750 + ); + assert(batch === 750, 'invalid stored sweep batch uses the safe fallback'); +} + assert( buildRateLimitKey('a:actor:b', 'c') !== buildRateLimitKey('a', 'b:actor:c'), 'compound keys cannot collide through delimiters' diff --git a/spacetime-rate-limit-ts/src/index.ts b/spacetime-rate-limit-ts/src/index.ts index ce1d8bf91d1..8b6eef65d6c 100644 --- a/spacetime-rate-limit-ts/src/index.ts +++ b/spacetime-rate-limit-ts/src/index.ts @@ -1,5 +1,7 @@ export { DEFAULT_SWEEP_BATCH, + MAX_SWEEP_BATCH, + assertRateLimitSweepBatch, DEFAULT_SWEEP_INTERVAL_SECONDS, consumeRateLimit, installRateLimitState, diff --git a/spacetime-rate-limit-ts/src/limit.ts b/spacetime-rate-limit-ts/src/limit.ts index 3d5e1fdaf7b..5fcfec557a1 100644 --- a/spacetime-rate-limit-ts/src/limit.ts +++ b/spacetime-rate-limit-ts/src/limit.ts @@ -4,6 +4,7 @@ const ONE_SECOND_MICROS = 1_000_000n; const U32_MAX = 0xffff_ffff; export const DEFAULT_SWEEP_BATCH = 500; +export const MAX_SWEEP_BATCH = 10_000; export const DEFAULT_SWEEP_INTERVAL_SECONDS = 30n; export interface ConsumeRateLimitOpts { @@ -42,6 +43,13 @@ function assertPositiveInt(name: string, value: number): void { } } +export function assertRateLimitSweepBatch(value: number): void { + assertPositiveInt('sweep_batch', value); + if (value > MAX_SWEEP_BATCH) { + throw new Error('rate_limit.invalid_sweep_batch'); + } +} + function plusSeconds(timestamp: Timestamp, seconds: number): Timestamp { return new Timestamp( (timestamp.microsSinceUnixEpoch as bigint) + @@ -119,7 +127,7 @@ export function installRateLimitState( opts?: RateLimitInstallOpts ): void { const sweepBatch = opts?.sweepBatch ?? DEFAULT_SWEEP_BATCH; - assertPositiveInt('sweep_batch', sweepBatch); + assertRateLimitSweepBatch(sweepBatch); const sweepIntervalSeconds = opts?.sweepIntervalSeconds ?? DEFAULT_SWEEP_INTERVAL_SECONDS; if (sweepIntervalSeconds <= 0n) @@ -155,7 +163,13 @@ export function resolveRateLimitSweepBatch( const cfg = ctx.db.rateLimitConfig.singleton.find(true); if (!cfg) return fallback; const batch = Number(cfg.sweepBatch); - return batch > 0 ? batch : fallback; + const safeFallback = + Number.isInteger(fallback) && fallback > 0 && fallback <= MAX_SWEEP_BATCH + ? fallback + : DEFAULT_SWEEP_BATCH; + return Number.isInteger(batch) && batch > 0 && batch <= MAX_SWEEP_BATCH + ? batch + : safeFallback; } export interface RateLimitSweepCtxLike extends RateLimitTxLike { @@ -277,7 +291,7 @@ export function sweepRateLimits( expiredRows: Iterable, maxRows = DEFAULT_SWEEP_BATCH ): number { - assertPositiveInt('sweep_batch', maxRows); + assertRateLimitSweepBatch(maxRows); const nowMicros = tx.timestamp.microsSinceUnixEpoch as bigint; let deleted = 0; for (const row of expiredRows) { diff --git a/spacetime-rate-limit-ts/src/submodule.ts b/spacetime-rate-limit-ts/src/submodule.ts index 9ada70bb768..d7a2c306a72 100644 --- a/spacetime-rate-limit-ts/src/submodule.ts +++ b/spacetime-rate-limit-ts/src/submodule.ts @@ -2,6 +2,8 @@ export { default } from './submodule/schema'; export { installRateLimit } from './submodule/install'; export { DEFAULT_SWEEP_BATCH, + MAX_SWEEP_BATCH, + assertRateLimitSweepBatch, DEFAULT_SWEEP_INTERVAL_SECONDS, consumeRateLimit, installRateLimitState, diff --git a/spacetime-rate-limit-ts/src/submodule/operations.ts b/spacetime-rate-limit-ts/src/submodule/operations.ts index ba199eb5758..bff17d1a223 100644 --- a/spacetime-rate-limit-ts/src/submodule/operations.ts +++ b/spacetime-rate-limit-ts/src/submodule/operations.ts @@ -2,6 +2,7 @@ import { Range, SenderError } from 'spacetimedb/server'; import { consumeRateLimit, DEFAULT_SWEEP_BATCH, + MAX_SWEEP_BATCH, runRateLimitSweep, sweepRateLimits, } from '../index'; @@ -40,8 +41,8 @@ function requireAdmin(ctx: ReducerModuleCtx): void { } } -function toU32(name: string, value: number): number { - if (!Number.isInteger(value) || value <= 0 || value > 0xffff_ffff) { +function toU32(name: string, value: number, max = 0xffff_ffff): number { + if (!Number.isInteger(value) || value <= 0 || value > max) { throw new Error(`rate_limit.invalid_${name}`); } return value; @@ -106,7 +107,7 @@ export const runSweep = spacetimedb.procedure( const maxRows = args.maxRows === undefined ? undefined - : toU32('sweep_batch', Number(args.maxRows)); + : toU32('sweep_batch', Number(args.maxRows), MAX_SWEEP_BATCH); return ctx.withTx(tx => { requireAdmin(tx); return sweepRateLimits( @@ -141,7 +142,11 @@ export const updateConfig = spacetimedb.reducer( if (!cfg) throw new Error('rate_limit.config_missing'); ctx.db.rateLimitConfig.singleton.update({ ...cfg, - sweepBatch: toU32('sweep_batch', Number(args.sweepBatch)), + sweepBatch: toU32( + 'sweep_batch', + Number(args.sweepBatch), + MAX_SWEEP_BATCH + ), updatedAt: ctx.timestamp, }); } From 296c576aa759c9e9b46cfe6a108b4974630f14b8 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 18:53:34 -0400 Subject: [PATCH 07/33] Stop tracking generated example bindings --- .gitignore | 2 + package.json | 4 +- spacetime-agents-ts/example/.gitignore | 5 - .../app/apiKeys/add_admin_identity_reducer.ts | 15 - .../app/apiKeys/api_key_usage_admin_table.ts | 22 - .../app/apiKeys/api_keys_admin_table.ts | 31 -- .../create_api_key_for_subject_procedure.ts | 25 - .../app/apiKeys/create_api_key_procedure.ts | 24 - .../codegen/app/apiKeys/my_api_keys_table.ts | 31 -- .../apiKeys/remove_admin_identity_reducer.ts | 15 - .../revoke_api_key_for_subject_reducer.ts | 16 - .../app/apiKeys/revoke_api_key_reducer.ts | 15 - .../app/apiKeys/rotate_api_key_procedure.ts | 22 - .../apiKeys/sweep_api_key_usage_reducer.ts | 16 - .../example/src/codegen/app/apiKeys/types.ts | 111 ---- .../example/src/codegen/app/build_reducer.ts | 18 - .../example/src/codegen/app/clear_reducer.ts | 16 - .../codegen/app/clear_world_events_reducer.ts | 13 - .../src/codegen/app/colony_cells_table.ts | 20 - .../src/codegen/app/colony_entities_table.ts | 24 - .../src/codegen/app/colony_grid_table.ts | 26 - .../app/create_access_key_procedure.ts | 24 - .../src/codegen/app/ensure_world_procedure.ts | 19 - .../example/src/codegen/app/grid/types.ts | 69 --- .../example/src/codegen/app/index.ts | 324 ------------ .../src/codegen/app/my_access_keys_table.ts | 31 -- .../example/src/codegen/app/plant_reducer.ts | 17 - .../src/codegen/app/presence_entry_table.ts | 24 - .../codegen/app/presence_heartbeat_reducer.ts | 21 - .../src/codegen/app/presence_leave_reducer.ts | 15 - .../src/codegen/app/reset_world_reducer.ts | 13 - .../codegen/app/revoke_access_key_reducer.ts | 15 - .../app/rotate_access_key_procedure.ts | 22 - .../src/codegen/app/terraform_reducer.ts | 17 - .../example/src/codegen/app/types.ts | 159 ------ .../src/codegen/app/types/procedures.ts | 19 - .../example/src/codegen/app/types/reducers.ts | 30 -- .../src/codegen/app/unbuild_reducer.ts | 16 - .../src/codegen/app/world_event_table.ts | 22 - .../example/src/codegen/app/world_table.ts | 19 - spacetime-auth-ts/example/.gitignore | 11 - .../src/codegen/app/activity_log_table.ts | 18 - .../src/codegen/app/cleanup_fire_table.ts | 26 - .../src/codegen/app/cron_jobs_table.ts | 30 -- .../codegen/app/cron_reconcile_tick_table.ts | 17 - .../example/src/codegen/app/cron_run_table.ts | 28 - .../src/codegen/app/digest_fire_table.ts | 26 - .../example/src/codegen/app/index.ts | 203 -------- .../src/codegen/app/schedule_cron_reducer.ts | 18 - .../src/codegen/app/schedule_every_reducer.ts | 17 - .../example/src/codegen/app/types.ts | 153 ------ .../src/codegen/app/types/procedures.ts | 10 - .../example/src/codegen/app/types/reducers.ts | 16 - .../src/codegen/app/unschedule_job_reducer.ts | 15 - .../src/codegen/app/create_folder_reducer.ts | 15 - .../src/codegen/app/delete_file_reducer.ts | 15 - .../src/codegen/app/delete_folder_reducer.ts | 15 - .../example/src/codegen/app/files/types.ts | 32 -- .../example/src/codegen/app/index.ts | 142 ----- .../src/codegen/app/move_file_reducer.ts | 16 - .../codegen/app/my_file_summaries_table.ts | 21 - .../src/codegen/app/my_folders_table.ts | 21 - .../codegen/app/read_file_bytes_procedure.ts | 20 - .../src/codegen/app/rename_file_reducer.ts | 16 - .../src/codegen/app/rename_folder_reducer.ts | 16 - .../app/set_file_visibility_reducer.ts | 16 - .../example/src/codegen/app/types.ts | 46 -- .../src/codegen/app/types/procedures.ts | 13 - .../example/src/codegen/app/types/reducers.ts | 26 - .../src/codegen/app/upload_file_reducer.ts | 18 - .../src/codegen/app/actor_directory_table.ts | 24 - .../src/codegen/app/ai_take_turn_procedure.ts | 20 - .../src/codegen/app/attack_unit_procedure.ts | 17 - .../app/auth/get_auth_public_key_procedure.ts | 19 - .../app/auth/link_connection_reducer.ts | 15 - .../app/auth/list_my_sessions_procedure.ts | 19 - .../codegen/app/auth/my_auth_user_table.ts | 21 - .../rateLimit/add_rate_limit_admin_reducer.ts | 15 - .../admin_rate_limit_buckets_table.ts | 20 - .../app/auth/rateLimit/consume_procedure.ts | 24 - .../auth/rateLimit/rate_limit_config_table.ts | 17 - .../auth/rateLimit/reset_buckets_reducer.ts | 15 - .../app/auth/rateLimit/run_sweep_procedure.ts | 16 - .../src/codegen/app/auth/rateLimit/types.ts | 56 -- .../auth/rateLimit/update_config_reducer.ts | 15 - .../app/auth/revoke_my_session_reducer.ts | 15 - .../app/auth/revoke_session_reducer.ts | 15 - .../app/auth/set_auth_config_reducer.ts | 23 - .../example/src/codegen/app/auth/types.ts | 137 ----- .../app/auth/unlink_connection_reducer.ts | 13 - .../app/auth/update_profile_reducer.ts | 16 - .../src/codegen/app/auth/whoami_procedure.ts | 19 - .../src/codegen/app/create_match_procedure.ts | 20 - .../src/codegen/app/end_turn_procedure.ts | 16 - .../app/get_auth_public_key_procedure.ts | 19 - .../app/get_cells_in_range_procedure.ts | 23 - .../example/src/codegen/app/grid/types.ts | 69 --- .../example/src/codegen/app/index.ts | 360 ------------- .../src/codegen/app/join_match_procedure.ts | 16 - .../codegen/app/link_connection_reducer.ts | 15 - .../codegen/app/list_my_sessions_procedure.ts | 19 - .../codegen/app/lobby_open_matches_table.ts | 17 - .../src/codegen/app/move_unit_procedure.ts | 22 - .../src/codegen/app/my_auth_user_table.ts | 21 - .../src/codegen/app/my_cell_states_table.ts | 20 - .../src/codegen/app/my_grid_entities_table.ts | 24 - .../example/src/codegen/app/my_grids_table.ts | 26 - .../app/my_match_participants_table.ts | 20 - .../src/codegen/app/my_matches_table.ts | 28 - .../src/codegen/app/my_player_units_table.ts | 22 - .../src/codegen/app/npc_actor_table.ts | 18 - .../codegen/app/revoke_my_session_reducer.ts | 15 - .../src/codegen/app/revoke_session_reducer.ts | 15 - .../codegen/app/set_auth_config_reducer.ts | 23 - .../example/src/codegen/app/types.ts | 276 ---------- .../src/codegen/app/types/procedures.ts | 40 -- .../example/src/codegen/app/types/reducers.ts | 22 - .../src/codegen/app/unit_type_table.ts | 21 - .../codegen/app/unlink_connection_reducer.ts | 13 - .../src/codegen/app/update_profile_reducer.ts | 16 - .../src/codegen/app/whoami_procedure.ts | 19 - spacetime-lobby-ts/example/.gitignore | 6 - spacetime-posthog-ts/example/.gitignore | 7 - .../app/auth/get_auth_public_key_procedure.ts | 19 - .../app/auth/link_connection_reducer.ts | 15 - .../app/auth/list_my_sessions_procedure.ts | 19 - .../codegen/app/auth/my_auth_user_table.ts | 21 - .../rateLimit/add_rate_limit_admin_reducer.ts | 15 - .../admin_rate_limit_buckets_table.ts | 20 - .../app/auth/rateLimit/consume_procedure.ts | 24 - .../auth/rateLimit/rate_limit_config_table.ts | 17 - .../auth/rateLimit/reset_buckets_reducer.ts | 15 - .../app/auth/rateLimit/run_sweep_procedure.ts | 16 - .../src/codegen/app/auth/rateLimit/types.ts | 56 -- .../auth/rateLimit/update_config_reducer.ts | 15 - .../app/auth/revoke_my_session_reducer.ts | 15 - .../app/auth/revoke_session_reducer.ts | 15 - .../app/auth/set_auth_config_reducer.ts | 23 - .../example/src/codegen/app/auth/types.ts | 137 ----- .../app/auth/unlink_connection_reducer.ts | 13 - .../app/auth/update_profile_reducer.ts | 16 - .../src/codegen/app/auth/whoami_procedure.ts | 19 - .../src/codegen/app/create_room_reducer.ts | 18 - .../src/codegen/app/create_server_reducer.ts | 15 - .../src/codegen/app/delete_message_reducer.ts | 15 - .../src/codegen/app/delete_room_reducer.ts | 15 - .../src/codegen/app/delete_server_reducer.ts | 15 - .../app/delete_thread_message_reducer.ts | 15 - .../src/codegen/app/edit_message_reducer.ts | 16 - .../app/edit_thread_message_reducer.ts | 16 - .../example/src/codegen/app/files/types.ts | 32 -- .../app/get_attachment_file_procedure.ts | 20 - .../app/get_auth_public_key_procedure.ts | 19 - .../src/codegen/app/heartbeat_reducer.ts | 13 - .../example/src/codegen/app/index.ts | 488 ------------------ .../src/codegen/app/join_room_reducer.ts | 15 - .../src/codegen/app/join_server_reducer.ts | 15 - .../src/codegen/app/leave_room_reducer.ts | 15 - .../src/codegen/app/leave_server_reducer.ts | 15 - .../codegen/app/link_connection_reducer.ts | 15 - .../codegen/app/list_my_sessions_procedure.ts | 19 - .../src/codegen/app/mark_room_read_reducer.ts | 15 - .../src/codegen/app/my_auth_user_table.ts | 21 - .../src/codegen/app/my_chat_users_table.ts | 27 - .../codegen/app/my_message_threads_table.ts | 20 - .../codegen/app/my_presence_entries_table.ts | 24 - .../codegen/app/my_rate_limit_status_table.ts | 19 - .../codegen/app/my_room_attachments_table.ts | 27 - .../src/codegen/app/my_room_members_table.ts | 19 - .../app/my_room_message_reactions_table.ts | 19 - .../src/codegen/app/my_room_messages_table.ts | 23 - .../codegen/app/my_room_read_cursors_table.ts | 19 - .../example/src/codegen/app/my_rooms_table.ts | 24 - .../codegen/app/my_server_members_table.ts | 19 - .../src/codegen/app/my_servers_table.ts | 18 - .../codegen/app/my_thread_messages_table.ts | 20 - .../src/codegen/app/pin_message_reducer.ts | 15 - .../rateLimit/add_rate_limit_admin_reducer.ts | 15 - .../admin_rate_limit_buckets_table.ts | 20 - .../app/rateLimit/consume_procedure.ts | 24 - .../app/rateLimit/rate_limit_config_table.ts | 17 - .../app/rateLimit/reset_buckets_reducer.ts | 15 - .../app/rateLimit/run_sweep_procedure.ts | 16 - .../src/codegen/app/rateLimit/types.ts | 56 -- .../app/rateLimit/update_config_reducer.ts | 15 - .../src/codegen/app/rename_room_reducer.ts | 16 - .../src/codegen/app/rename_server_reducer.ts | 16 - .../codegen/app/revoke_my_session_reducer.ts | 15 - .../src/codegen/app/revoke_session_reducer.ts | 15 - .../codegen/app/search_messages_procedure.ts | 21 - .../src/codegen/app/send_message_reducer.ts | 24 - .../app/send_thread_message_reducer.ts | 16 - .../codegen/app/set_auth_config_reducer.ts | 23 - .../codegen/app/set_display_name_reducer.ts | 15 - .../codegen/app/set_room_category_reducer.ts | 16 - .../codegen/app/set_room_privacy_reducer.ts | 16 - .../src/codegen/app/set_status_reducer.ts | 21 - .../src/codegen/app/start_typing_reducer.ts | 15 - .../src/codegen/app/stop_typing_reducer.ts | 15 - .../codegen/app/toggle_reaction_reducer.ts | 16 - .../example/src/codegen/app/types.ts | 296 ----------- .../src/codegen/app/types/procedures.ts | 25 - .../example/src/codegen/app/types/reducers.ts | 76 --- .../codegen/app/unlink_connection_reducer.ts | 13 - .../src/codegen/app/unpin_message_reducer.ts | 15 - .../src/codegen/app/update_profile_reducer.ts | 16 - .../src/codegen/app/whoami_procedure.ts | 19 - .../src/codegen/app/buy_upgrade_procedure.ts | 20 - .../example/src/codegen/app/index.ts | 257 --------- .../src/codegen/app/overcharge_procedure.ts | 19 - .../rateLimit/add_rate_limit_admin_reducer.ts | 15 - .../admin_rate_limit_buckets_table.ts | 20 - .../app/rateLimit/consume_procedure.ts | 24 - .../app/rateLimit/rate_limit_config_table.ts | 17 - .../app/rateLimit/reset_buckets_reducer.ts | 15 - .../app/rateLimit/run_sweep_procedure.ts | 16 - .../src/codegen/app/rateLimit/types.ts | 56 -- .../app/rateLimit/update_config_reducer.ts | 15 - .../app/rate_limit_demo_config_table.ts | 18 - .../app/rate_limit_events_admin_table.ts | 26 - .../src/codegen/app/reactor_events_table.ts | 25 - .../codegen/app/reactor_limit_status_table.ts | 21 - .../src/codegen/app/reactor_players_table.ts | 24 - .../src/codegen/app/reactor_shop_table.ts | 21 - .../src/codegen/app/reactor_state_table.ts | 31 -- .../codegen/app/repair_reactor_procedure.ts | 19 - .../src/codegen/app/reset_demo_reducer.ts | 13 - .../src/codegen/app/run_sweep_procedure.ts | 16 - .../codegen/app/set_player_color_reducer.ts | 15 - .../codegen/app/start_reactor_procedure.ts | 19 - .../src/codegen/app/tap_reactor_procedure.ts | 19 - .../example/src/codegen/app/types.ts | 157 ------ .../src/codegen/app/types/procedures.ts | 28 - .../example/src/codegen/app/types/reducers.ts | 16 - .../src/codegen/app/update_config_reducer.ts | 17 - spacetime-resend-ts/example/.gitignore | 10 - spacetime-stripe-ts/example/.gitignore | 11 - tools/check-example-assets.mjs | 55 -- 238 files changed, 4 insertions(+), 7721 deletions(-) delete mode 100644 spacetime-agents-ts/example/.gitignore delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/add_admin_identity_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_key_usage_admin_table.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_keys_admin_table.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_for_subject_procedure.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_procedure.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/my_api_keys_table.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/remove_admin_identity_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_for_subject_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/rotate_api_key_procedure.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/sweep_api_key_usage_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/apiKeys/types.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/build_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/clear_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/clear_world_events_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/colony_cells_table.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/colony_entities_table.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/colony_grid_table.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/create_access_key_procedure.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/ensure_world_procedure.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/grid/types.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/index.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/my_access_keys_table.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/plant_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/presence_entry_table.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/presence_heartbeat_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/presence_leave_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/reset_world_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/revoke_access_key_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/rotate_access_key_procedure.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/terraform_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/types.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/types/procedures.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/types/reducers.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/unbuild_reducer.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/world_event_table.ts delete mode 100644 spacetime-api-keys-ts/example/src/codegen/app/world_table.ts delete mode 100644 spacetime-auth-ts/example/.gitignore delete mode 100644 spacetime-cron-ts/example/src/codegen/app/activity_log_table.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/cleanup_fire_table.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/cron_jobs_table.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/cron_reconcile_tick_table.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/cron_run_table.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/digest_fire_table.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/index.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/schedule_cron_reducer.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/schedule_every_reducer.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/types.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/types/procedures.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/types/reducers.ts delete mode 100644 spacetime-cron-ts/example/src/codegen/app/unschedule_job_reducer.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/create_folder_reducer.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/delete_file_reducer.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/delete_folder_reducer.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/files/types.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/index.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/move_file_reducer.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/my_file_summaries_table.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/my_folders_table.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/read_file_bytes_procedure.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/rename_file_reducer.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/rename_folder_reducer.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/set_file_visibility_reducer.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/types.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/types/procedures.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/types/reducers.ts delete mode 100644 spacetime-files-ts/example/src/codegen/app/upload_file_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/actor_directory_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/ai_take_turn_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/attack_unit_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/link_connection_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/my_auth_user_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/types.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/revoke_session_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/types.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/update_profile_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/auth/whoami_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/create_match_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/end_turn_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/get_auth_public_key_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/get_cells_in_range_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/grid/types.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/index.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/join_match_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/link_connection_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/list_my_sessions_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/lobby_open_matches_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/move_unit_procedure.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/my_auth_user_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/my_cell_states_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/my_grid_entities_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/my_grids_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/my_match_participants_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/my_matches_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/my_player_units_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/npc_actor_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/revoke_my_session_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/revoke_session_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/set_auth_config_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/types.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/types/procedures.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/types/reducers.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/unit_type_table.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/unlink_connection_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/update_profile_reducer.ts delete mode 100644 spacetime-grid-ts/example/src/codegen/app/whoami_procedure.ts delete mode 100644 spacetime-lobby-ts/example/.gitignore delete mode 100644 spacetime-posthog-ts/example/.gitignore delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/link_connection_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/my_auth_user_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/types.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/revoke_session_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/types.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/update_profile_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/auth/whoami_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/create_room_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/create_server_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/delete_message_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/delete_room_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/delete_server_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/delete_thread_message_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/edit_message_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/edit_thread_message_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/files/types.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/get_attachment_file_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/get_auth_public_key_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/heartbeat_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/index.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/join_room_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/join_server_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/leave_room_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/leave_server_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/link_connection_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/list_my_sessions_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/mark_room_read_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_auth_user_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_chat_users_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_message_threads_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_presence_entries_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_rate_limit_status_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_attachments_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_members_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_message_reactions_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_messages_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_room_read_cursors_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_rooms_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_server_members_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_servers_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/my_thread_messages_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/pin_message_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/consume_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/types.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rename_room_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/rename_server_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/revoke_my_session_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/revoke_session_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/search_messages_procedure.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/send_message_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/send_thread_message_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/set_auth_config_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/set_display_name_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/set_room_category_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/set_room_privacy_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/set_status_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/start_typing_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/stop_typing_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/toggle_reaction_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/types.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/types/procedures.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/types/reducers.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/unlink_connection_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/unpin_message_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/update_profile_reducer.ts delete mode 100644 spacetime-presence-ts/example/src/codegen/app/whoami_procedure.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/buy_upgrade_procedure.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/index.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/overcharge_procedure.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/consume_procedure.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/types.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_demo_config_table.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_events_admin_table.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_events_table.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_limit_status_table.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_players_table.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_shop_table.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reactor_state_table.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/repair_reactor_procedure.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/reset_demo_reducer.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/run_sweep_procedure.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/set_player_color_reducer.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/start_reactor_procedure.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/tap_reactor_procedure.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/types.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/types/procedures.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/types/reducers.ts delete mode 100644 spacetime-rate-limit-ts/example/src/codegen/app/update_config_reducer.ts delete mode 100644 spacetime-resend-ts/example/.gitignore delete mode 100644 spacetime-stripe-ts/example/.gitignore delete mode 100644 tools/check-example-assets.mjs diff --git a/.gitignore b/.gitignore index 25d7ace9201..cbb51dbb814 100644 --- a/.gitignore +++ b/.gitignore @@ -207,8 +207,10 @@ __pycache__/ # TypeScript submodule development outputs .stdb-*/ +spacetime-*-ts/example/.stdb-* spacetime-*-ts/example/public/app.js spacetime-*-ts/example/public/app.js.map +spacetime-*-ts/example/src/codegen/ spacetime-*-ts/ts-codegen/ /protobuf diff --git a/package.json b/package.json index 2af79f308b5..14c445c9036 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "test": "pnpm run-all test && pnpm submodules:test", "generate": "pnpm run-all generate", "clean": "pnpm -r exec rimraf dist .tsbuildinfo coverage", - "submodules:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", - "submodules:lint": "node tools/doc-check.mjs && node tools/release-check.mjs && node tools/check-example-assets.mjs && eslint \"tools/{check-example-assets,check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks}.mjs\" tools/example-server-identity.ts && pnpm -r --filter \"./spacetime-*-ts\" run lint", + "submodules:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"tools/{check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", + "submodules:lint": "node tools/doc-check.mjs && node tools/release-check.mjs && eslint \"tools/{check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks}.mjs\" tools/example-server-identity.ts && pnpm -r --filter \"./spacetime-*-ts\" run lint", "submodules:test": "node tools/run-package-checks.mjs && node tools/run-example-tests.mjs", "submodules:toolchain:check": "node tools/check-spacetime-release.mjs", "submodules:build": "pnpm submodules:toolchain:check && pnpm --dir crates/bindings-typescript run build && node tools/run-module-builds.mjs && node tools/run-example-builds.mjs", diff --git a/spacetime-agents-ts/example/.gitignore b/spacetime-agents-ts/example/.gitignore deleted file mode 100644 index ca141d51ce1..00000000000 --- a/spacetime-agents-ts/example/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -node_modules/ -public/app.js -public/app.js.map -src/codegen/ diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/add_admin_identity_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/add_admin_identity_reducer.ts deleted file mode 100644 index e39846ca8d9..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/add_admin_identity_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - identity: __t.identity(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_key_usage_admin_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_key_usage_admin_table.ts deleted file mode 100644 index 13ca42e538d..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_key_usage_admin_table.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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({ - usageId: __t.u64().name("usage_id"), - keyId: __t.string().name("key_id"), - prefix: __t.string(), - ownerSubject: __t.string().name("owner_subject"), - action: __t.string(), - allowed: __t.bool(), - reason: __t.string(), - usedAt: __t.timestamp().name("used_at"), -}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_keys_admin_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_keys_admin_table.ts deleted file mode 100644 index 478edd2b013..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/api_keys_admin_table.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 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 { - ApiKeyStatus, -} from "./types"; - - -export default __t.row({ - keyId: __t.string().name("key_id"), - prefix: __t.string(), - ownerSubject: __t.string().name("owner_subject"), - name: __t.string(), - scopesJson: __t.string().name("scopes_json"), - metadataJson: __t.option(__t.string()).name("metadata_json"), - get status() { - return ApiKeyStatus; - }, - createdAt: __t.timestamp().name("created_at"), - expiresAt: __t.option(__t.timestamp()).name("expires_at"), - lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), - revokedAt: __t.option(__t.timestamp()).name("revoked_at"), -}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_for_subject_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_for_subject_procedure.ts deleted file mode 100644 index b3ca01ada61..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_for_subject_procedure.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 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 { - ApiKeyCreateResult, -} from "./types"; - -export const params = { - ownerSubject: __t.string(), - name: __t.string(), - scopesJson: __t.string(), - metadataJson: __t.option(__t.string()), - expiresInSeconds: __t.option(__t.u32()), - keyPrefix: __t.option(__t.string()), -}; -export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_procedure.ts deleted file mode 100644 index 2324c883938..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/create_api_key_procedure.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 { - ApiKeyCreateResult, -} from "./types"; - -export const params = { - name: __t.string(), - scopesJson: __t.string(), - metadataJson: __t.option(__t.string()), - expiresInSeconds: __t.option(__t.u32()), - keyPrefix: __t.option(__t.string()), -}; -export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/my_api_keys_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/my_api_keys_table.ts deleted file mode 100644 index 478edd2b013..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/my_api_keys_table.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 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 { - ApiKeyStatus, -} from "./types"; - - -export default __t.row({ - keyId: __t.string().name("key_id"), - prefix: __t.string(), - ownerSubject: __t.string().name("owner_subject"), - name: __t.string(), - scopesJson: __t.string().name("scopes_json"), - metadataJson: __t.option(__t.string()).name("metadata_json"), - get status() { - return ApiKeyStatus; - }, - createdAt: __t.timestamp().name("created_at"), - expiresAt: __t.option(__t.timestamp()).name("expires_at"), - lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), - revokedAt: __t.option(__t.timestamp()).name("revoked_at"), -}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/remove_admin_identity_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/remove_admin_identity_reducer.ts deleted file mode 100644 index e39846ca8d9..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/remove_admin_identity_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - identity: __t.identity(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_for_subject_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_for_subject_reducer.ts deleted file mode 100644 index 8088a2a4a47..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_for_subject_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - keyId: __t.string(), - ownerSubject: __t.string(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_reducer.ts deleted file mode 100644 index 74c389667b3..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/revoke_api_key_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - keyId: __t.string(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/rotate_api_key_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/rotate_api_key_procedure.ts deleted file mode 100644 index 8261e1ed9bc..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/rotate_api_key_procedure.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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 { - ApiKeyCreateResult, -} from "./types"; - -export const params = { - keyId: __t.string(), - expiresInSeconds: __t.option(__t.u32()), - keyPrefix: __t.option(__t.string()), -}; -export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/sweep_api_key_usage_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/sweep_api_key_usage_reducer.ts deleted file mode 100644 index 77e9dcb6473..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/sweep_api_key_usage_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - maxAgeSeconds: __t.u32(), - maxRows: __t.u32(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/types.ts b/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/types.ts deleted file mode 100644 index 0fbfafcd592..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/apiKeys/types.ts +++ /dev/null @@ -1,111 +0,0 @@ -// 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 const ApiKey = __t.object("ApiKey", { - keyId: __t.string(), - prefix: __t.string(), - hash: __t.string(), - ownerSubject: __t.string(), - name: __t.string(), - scopesJson: __t.string(), - metadataJson: __t.option(__t.string()), - get status() { - return ApiKeyStatus; - }, - createdAt: __t.timestamp(), - createdAtOrder: __t.i64(), - expiresAt: __t.option(__t.timestamp()), - lastUsedAt: __t.option(__t.timestamp()), - revokedAt: __t.option(__t.timestamp()), -}); -export type ApiKey = __Infer; - -export const ApiKeyAdminIdentity = __t.object("ApiKeyAdminIdentity", { - identity: __t.identity(), - addedAtMicros: __t.i64(), -}); -export type ApiKeyAdminIdentity = __Infer; - -export const ApiKeyCreateResult = __t.object("ApiKeyCreateResult", { - keyId: __t.string(), - key: __t.string(), - prefix: __t.string(), - ownerSubject: __t.string(), - name: __t.string(), - scopesJson: __t.string(), - metadataJson: __t.option(__t.string()), - get status() { - return ApiKeyStatus; - }, - createdAt: __t.timestamp(), - expiresAt: __t.option(__t.timestamp()), -}); -export type ApiKeyCreateResult = __Infer; - -// The tagged union or sum type for the algebraic type `ApiKeyStatus`. -export const ApiKeyStatus = __t.enum("ApiKeyStatus", { - Active: __t.unit(), - Revoked: __t.unit(), -}); -export type ApiKeyStatus = __Infer; - -export const ApiKeySummary = __t.object("ApiKeySummary", { - keyId: __t.string(), - prefix: __t.string(), - ownerSubject: __t.string(), - name: __t.string(), - scopesJson: __t.string(), - metadataJson: __t.option(__t.string()), - get status() { - return ApiKeyStatus; - }, - createdAt: __t.timestamp(), - expiresAt: __t.option(__t.timestamp()), - lastUsedAt: __t.option(__t.timestamp()), - revokedAt: __t.option(__t.timestamp()), -}); -export type ApiKeySummary = __Infer; - -export const ApiKeyUsage = __t.object("ApiKeyUsage", { - usageId: __t.u64(), - keyId: __t.string(), - prefix: __t.string(), - ownerSubject: __t.string(), - action: __t.string(), - allowed: __t.bool(), - reason: __t.string(), - usedAt: __t.timestamp(), - usedAtOrder: __t.i64(), -}); -export type ApiKeyUsage = __Infer; - -export const ApiKeyUsageAdmin = __t.object("ApiKeyUsageAdmin", {}); -export type ApiKeyUsageAdmin = __Infer; - -export const ApiKeyUsageSummary = __t.object("ApiKeyUsageSummary", { - usageId: __t.u64(), - keyId: __t.string(), - prefix: __t.string(), - ownerSubject: __t.string(), - action: __t.string(), - allowed: __t.bool(), - reason: __t.string(), - usedAt: __t.timestamp(), -}); -export type ApiKeyUsageSummary = __Infer; - -export const ApiKeysAdmin = __t.object("ApiKeysAdmin", {}); -export type ApiKeysAdmin = __Infer; - -export const MyApiKeys = __t.object("MyApiKeys", {}); -export type MyApiKeys = __Infer; - diff --git a/spacetime-api-keys-ts/example/src/codegen/app/build_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/build_reducer.ts deleted file mode 100644 index 2b94f0ff71d..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/build_reducer.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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 { - x: __t.i32(), - y: __t.i32(), - kind: __t.string(), - label: __t.option(__t.string()), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/clear_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/clear_reducer.ts deleted file mode 100644 index c061193b961..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/clear_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - x: __t.i32(), - y: __t.i32(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/clear_world_events_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/clear_world_events_reducer.ts deleted file mode 100644 index e18fbc0a086..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/clear_world_events_reducer.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 {}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/colony_cells_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/colony_cells_table.ts deleted file mode 100644 index 9f1a3bfc694..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/colony_cells_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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().primaryKey(), - gridId: __t.u64().name("grid_id"), - x: __t.i32(), - y: __t.i32(), - cost: __t.i32(), - terrain: __t.option(__t.string()), -}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/colony_entities_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/colony_entities_table.ts deleted file mode 100644 index 1b75cfb7ea0..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/colony_entities_table.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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().primaryKey(), - gridId: __t.u64().name("grid_id"), - ownerUserId: __t.string().name("owner_user_id"), - x: __t.i32(), - y: __t.i32(), - kind: __t.string(), - blocksMovement: __t.bool().name("blocks_movement"), - label: __t.option(__t.string()), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/colony_grid_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/colony_grid_table.ts deleted file mode 100644 index ba03c161ea6..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/colony_grid_table.ts +++ /dev/null @@ -1,26 +0,0 @@ -// 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().primaryKey(), - ownerUserId: __t.string().name("owner_user_id"), - name: __t.string(), - kind: __t.string(), - orientation: __t.string(), - width: __t.i32(), - height: __t.i32(), - defaultCost: __t.i32().name("default_cost"), - connectivity: __t.i32(), - mode: __t.string(), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/create_access_key_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/create_access_key_procedure.ts deleted file mode 100644 index 2324c883938..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/create_access_key_procedure.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 { - ApiKeyCreateResult, -} from "./types"; - -export const params = { - name: __t.string(), - scopesJson: __t.string(), - metadataJson: __t.option(__t.string()), - expiresInSeconds: __t.option(__t.u32()), - keyPrefix: __t.option(__t.string()), -}; -export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/ensure_world_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/ensure_world_procedure.ts deleted file mode 100644 index dd16b86efa5..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/ensure_world_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - EnsureWorldResult, -} from "./types"; - -export const params = { -}; -export const returnType = EnsureWorldResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/grid/types.ts b/spacetime-api-keys-ts/example/src/codegen/app/grid/types.ts deleted file mode 100644 index 48f7c6524bd..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/grid/types.ts +++ /dev/null @@ -1,69 +0,0 @@ -// 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 const CellState = __t.object("CellState", { - id: __t.u64(), - gridId: __t.u64(), - x: __t.i32(), - y: __t.i32(), - cost: __t.i32(), - terrain: __t.option(__t.string()), -}); -export type CellState = __Infer; - -export const EntityPath = __t.object("EntityPath", { - entityId: __t.u64(), - gridId: __t.u64(), - get cells() { - return __t.array(PathCell); - }, - cost: __t.i32(), - computedAt: __t.timestamp(), -}); -export type EntityPath = __Infer; - -export const Grid = __t.object("Grid", { - id: __t.u64(), - ownerUserId: __t.string(), - name: __t.string(), - kind: __t.string(), - orientation: __t.string(), - width: __t.i32(), - height: __t.i32(), - defaultCost: __t.i32(), - connectivity: __t.i32(), - mode: __t.string(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type Grid = __Infer; - -export const GridEntity = __t.object("GridEntity", { - id: __t.u64(), - gridId: __t.u64(), - ownerUserId: __t.string(), - x: __t.i32(), - y: __t.i32(), - kind: __t.string(), - blocksMovement: __t.bool(), - label: __t.option(__t.string()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type GridEntity = __Infer; - -export const PathCell = __t.object("PathCell", { - x: __t.i32(), - y: __t.i32(), -}); -export type PathCell = __Infer; - diff --git a/spacetime-api-keys-ts/example/src/codegen/app/index.ts b/spacetime-api-keys-ts/example/src/codegen/app/index.ts deleted file mode 100644 index d4342f01cbe..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/index.ts +++ /dev/null @@ -1,324 +0,0 @@ -// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). - -/* 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 BuildReducer from "./build_reducer"; -import ClearReducer from "./clear_reducer"; -import ClearWorldEventsReducer from "./clear_world_events_reducer"; -import PlantReducer from "./plant_reducer"; -import PresenceHeartbeatReducer from "./presence_heartbeat_reducer"; -import PresenceLeaveReducer from "./presence_leave_reducer"; -import ResetWorldReducer from "./reset_world_reducer"; -import RevokeAccessKeyReducer from "./revoke_access_key_reducer"; -import TerraformReducer from "./terraform_reducer"; -import UnbuildReducer from "./unbuild_reducer"; - -// Import all procedure arg schemas -import * as CreateAccessKeyProcedure from "./create_access_key_procedure"; -import * as EnsureWorldProcedure from "./ensure_world_procedure"; -import * as RotateAccessKeyProcedure from "./rotate_access_key_procedure"; - -// Import all table schema definitions -import ColonyCellsRow from "./colony_cells_table"; -import ColonyEntitiesRow from "./colony_entities_table"; -import ColonyGridRow from "./colony_grid_table"; -import MyAccessKeysRow from "./my_access_keys_table"; -import PresenceEntryRow from "./presence_entry_table"; -import WorldRow from "./world_table"; -import WorldEventRow from "./world_event_table"; - -// Import namespace table schema definitions -import ApiKeys_ApiKeyUsageAdminRow from "./apiKeys/api_key_usage_admin_table"; -import ApiKeys_ApiKeysAdminRow from "./apiKeys/api_keys_admin_table"; -import ApiKeys_MyApiKeysRow from "./apiKeys/my_api_keys_table"; - -// Import namespace reducer arg schemas -import ApiKeys_AddAdminIdentityReducer from "./apiKeys/add_admin_identity_reducer"; -import ApiKeys_RemoveAdminIdentityReducer from "./apiKeys/remove_admin_identity_reducer"; -import ApiKeys_RevokeApiKeyReducer from "./apiKeys/revoke_api_key_reducer"; -import ApiKeys_RevokeApiKeyForSubjectReducer from "./apiKeys/revoke_api_key_for_subject_reducer"; -import ApiKeys_SweepApiKeyUsageReducer from "./apiKeys/sweep_api_key_usage_reducer"; - -// Import namespace procedure arg schemas -import * as ApiKeys_CreateApiKeyProcedure from "./apiKeys/create_api_key_procedure"; -import * as ApiKeys_CreateApiKeyForSubjectProcedure from "./apiKeys/create_api_key_for_subject_procedure"; -import * as ApiKeys_RotateApiKeyProcedure from "./apiKeys/rotate_api_key_procedure"; - -/** 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({ - presenceEntry: __table({ - name: 'presence_entry', - indexes: [ - { accessor: 'expiresAt', name: 'presence_entry_expires_at_idx_btree', algorithm: 'btree', columns: [ - 'expiresAt', - ] }, - { accessor: 'joinedAt', name: 'presence_entry_joined_at_idx_btree', algorithm: 'btree', columns: [ - 'joinedAt', - ] }, - { accessor: 'key', name: 'presence_entry_key_idx_btree', algorithm: 'btree', columns: [ - 'key', - ] }, - { accessor: 'lastSeenAt', name: 'presence_entry_last_seen_at_idx_btree', algorithm: 'btree', columns: [ - 'lastSeenAt', - ] }, - { accessor: 'scope', name: 'presence_entry_scope_idx_btree', algorithm: 'btree', columns: [ - 'scope', - ] }, - { accessor: 'status', name: 'presence_entry_status_idx_btree', algorithm: 'btree', columns: [ - 'status', - ] }, - { accessor: 'subject', name: 'presence_entry_subject_idx_btree', algorithm: 'btree', columns: [ - 'subject', - ] }, - ], - constraints: [ - { name: 'presence_entry_key_key', constraint: 'unique', columns: ['key'] }, - ], - }, PresenceEntryRow), - world: __table({ - name: 'world', - indexes: [ - { accessor: 'gridId', name: 'world_grid_id_idx_btree', algorithm: 'btree', columns: [ - 'gridId', - ] }, - { accessor: 'ownerSubject', name: 'world_owner_subject_idx_btree', algorithm: 'btree', columns: [ - 'ownerSubject', - ] }, - ], - constraints: [ - { name: 'world_owner_subject_key', constraint: 'unique', columns: ['ownerSubject'] }, - ], - }, WorldRow), - worldEvent: __table({ - name: 'world_event', - indexes: [ - { accessor: 'action', name: 'world_event_action_idx_btree', algorithm: 'btree', columns: [ - 'action', - ] }, - { accessor: 'allowed', name: 'world_event_allowed_idx_btree', algorithm: 'btree', columns: [ - 'allowed', - ] }, - { accessor: 'createdAt', name: 'world_event_created_at_idx_btree', algorithm: 'btree', columns: [ - 'createdAt', - ] }, - { accessor: 'eventId', name: 'world_event_event_id_idx_btree', algorithm: 'btree', columns: [ - 'eventId', - ] }, - { accessor: 'ownerSubject', name: 'world_event_owner_subject_idx_btree', algorithm: 'btree', columns: [ - 'ownerSubject', - ] }, - ], - constraints: [ - { name: 'world_event_event_id_key', constraint: 'unique', columns: ['eventId'] }, - ], - }, WorldEventRow), - colonyCells: __table({ - name: 'colony_cells', - indexes: [ - ], - constraints: [ - ], - }, ColonyCellsRow), - colonyEntities: __table({ - name: 'colony_entities', - indexes: [ - ], - constraints: [ - ], - }, ColonyEntitiesRow), - colonyGrid: __table({ - name: 'colony_grid', - indexes: [ - ], - constraints: [ - ], - }, ColonyGridRow), - myAccessKeys: __table({ - name: 'my_access_keys', - indexes: [ - ], - constraints: [ - ], - }, MyAccessKeysRow), - "apiKeys.api_key_usage_admin": __table({ - name: 'apiKeys.api_key_usage_admin', - indexes: [ - ], - constraints: [ - ], - }, ApiKeys_ApiKeyUsageAdminRow), - "apiKeys.api_keys_admin": __table({ - name: 'apiKeys.api_keys_admin', - indexes: [ - ], - constraints: [ - ], - }, ApiKeys_ApiKeysAdminRow), - "apiKeys.my_api_keys": __table({ - name: 'apiKeys.my_api_keys', - indexes: [ - ], - constraints: [ - ], - }, ApiKeys_MyApiKeysRow), -}); - -/** 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( - __reducerSchema("build", BuildReducer), - __reducerSchema("clear", ClearReducer), - __reducerSchema("clear_world_events", ClearWorldEventsReducer), - __reducerSchema("plant", PlantReducer), - __reducerSchema("presence_heartbeat", PresenceHeartbeatReducer), - __reducerSchema("presence_leave", PresenceLeaveReducer), - __reducerSchema("reset_world", ResetWorldReducer), - __reducerSchema("revoke_access_key", RevokeAccessKeyReducer), - __reducerSchema("terraform", TerraformReducer), - __reducerSchema("unbuild", UnbuildReducer), - __reducerSchema("apiKeys.add_admin_identity", ApiKeys_AddAdminIdentityReducer), - __reducerSchema("apiKeys.remove_admin_identity", ApiKeys_RemoveAdminIdentityReducer), - __reducerSchema("apiKeys.revoke_api_key", ApiKeys_RevokeApiKeyReducer), - __reducerSchema("apiKeys.revoke_api_key_for_subject", ApiKeys_RevokeApiKeyForSubjectReducer), - __reducerSchema("apiKeys.sweep_api_key_usage", ApiKeys_SweepApiKeyUsageReducer), -); - -/** 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("create_access_key", CreateAccessKeyProcedure.params, CreateAccessKeyProcedure.returnType), - __procedureSchema("ensure_world", EnsureWorldProcedure.params, EnsureWorldProcedure.returnType), - __procedureSchema("rotate_access_key", RotateAccessKeyProcedure.params, RotateAccessKeyProcedure.returnType), - __procedureSchema("apiKeys.create_api_key", ApiKeys_CreateApiKeyProcedure.params, ApiKeys_CreateApiKeyProcedure.returnType), - __procedureSchema("apiKeys.create_api_key_for_subject", ApiKeys_CreateApiKeyForSubjectProcedure.params, ApiKeys_CreateApiKeyForSubjectProcedure.returnType), - __procedureSchema("apiKeys.rotate_api_key", ApiKeys_RotateApiKeyProcedure.params, ApiKeys_RotateApiKeyProcedure.returnType), -); - -/** The remote SpacetimeDB module schema, both runtime and type information. */ -const REMOTE_MODULE = { - versionInfo: { - cliVersion: "2.8.3" 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. */ -const __qb = __makeQueryBuilder(tablesSchema.schemaType); -export const tables = { - presenceEntry: __qb.presenceEntry, - world: __qb.world, - worldEvent: __qb.worldEvent, - colonyCells: __qb.colonyCells, - colonyEntities: __qb.colonyEntities, - colonyGrid: __qb.colonyGrid, - myAccessKeys: __qb.myAccessKeys, - apiKeys: { - apiKeyUsageAdmin: __qb["apiKeys.api_key_usage_admin"], - apiKeysAdmin: __qb["apiKeys.api_keys_admin"], - myApiKeys: __qb["apiKeys.my_api_keys"], - }, -} as const; - -/** The reducers available in this remote SpacetimeDB module. */ -const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); -export const reducers = { - build: __reducerAccessors.build, - clear: __reducerAccessors.clear, - clearWorldEvents: __reducerAccessors.clearWorldEvents, - plant: __reducerAccessors.plant, - presenceHeartbeat: __reducerAccessors.presenceHeartbeat, - presenceLeave: __reducerAccessors.presenceLeave, - resetWorld: __reducerAccessors.resetWorld, - revokeAccessKey: __reducerAccessors.revokeAccessKey, - terraform: __reducerAccessors.terraform, - unbuild: __reducerAccessors.unbuild, - apiKeys: { - addAdminIdentity: __reducerAccessors["apiKeys.addAdminIdentity"], - removeAdminIdentity: __reducerAccessors["apiKeys.removeAdminIdentity"], - revokeApiKey: __reducerAccessors["apiKeys.revokeApiKey"], - revokeApiKeyForSubject: __reducerAccessors["apiKeys.revokeApiKeyForSubject"], - sweepApiKeyUsage: __reducerAccessors["apiKeys.sweepApiKeyUsage"], - }, -} as const; - -/** The procedures available in this remote SpacetimeDB module. */ -const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); -export const procedures = { - createAccessKey: __procedureAccessors.createAccessKey, - ensureWorld: __procedureAccessors.ensureWorld, - rotateAccessKey: __procedureAccessors.rotateAccessKey, - apiKeys: { - createApiKey: __procedureAccessors["apiKeys.createApiKey"], - createApiKeyForSubject: __procedureAccessors["apiKeys.createApiKeyForSubject"], - rotateApiKey: __procedureAccessors["apiKeys.rotateApiKey"], - }, -} as const; - -/** The context type returned in callbacks for all possible events. */ -export type EventContext = __EventContextInterface; -/** The context type returned in callbacks for reducer events. */ -export type ReducerEventContext = __ReducerEventContextInterface; -/** The context type returned in callbacks for subscription events. */ -export type SubscriptionEventContext = __SubscriptionEventContextInterface; -/** The context type returned in callbacks for error events. */ -export type ErrorContext = __ErrorContextInterface; -/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ -export type SubscriptionHandle = __SubscriptionHandleImpl; - -/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ -export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} - -/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ -export class DbConnectionBuilder extends __DbConnectionBuilder {} - -/** 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 { - /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ - static builder = (): DbConnectionBuilder => { - return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); - }; - - /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ - override subscriptionBuilder = (): SubscriptionBuilder => { - return new SubscriptionBuilder(this); - }; -} - diff --git a/spacetime-api-keys-ts/example/src/codegen/app/my_access_keys_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/my_access_keys_table.ts deleted file mode 100644 index 63686ec3ca6..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/my_access_keys_table.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 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 { - ApiKeyStatus, -} from "./types"; - - -export default __t.row({ - keyId: __t.string().primaryKey().name("key_id"), - prefix: __t.string(), - ownerSubject: __t.string().name("owner_subject"), - name: __t.string(), - scopesJson: __t.string().name("scopes_json"), - metadataJson: __t.option(__t.string()).name("metadata_json"), - get status() { - return ApiKeyStatus; - }, - createdAt: __t.timestamp().name("created_at"), - expiresAt: __t.option(__t.timestamp()).name("expires_at"), - lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), - revokedAt: __t.option(__t.timestamp()).name("revoked_at"), -}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/plant_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/plant_reducer.ts deleted file mode 100644 index 2b4c9c3b0f5..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/plant_reducer.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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 { - x: __t.i32(), - y: __t.i32(), - kind: __t.option(__t.string()), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/presence_entry_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/presence_entry_table.ts deleted file mode 100644 index 70af5d56d15..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/presence_entry_table.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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({ - key: __t.string().primaryKey(), - scope: __t.string(), - subject: __t.string(), - status: __t.string(), - activity: __t.option(__t.string()), - payloadJson: __t.option(__t.string()).name("payload_json"), - joinedAt: __t.timestamp().name("joined_at"), - lastSeenAt: __t.timestamp().name("last_seen_at"), - expiresAt: __t.timestamp().name("expires_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/presence_heartbeat_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/presence_heartbeat_reducer.ts deleted file mode 100644 index 46b19979f5d..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/presence_heartbeat_reducer.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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 { - scope: __t.string(), - name: __t.string(), - role: __t.string(), - color: __t.string(), - cx: __t.f64(), - cy: __t.f64(), - onGrid: __t.bool(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/presence_leave_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/presence_leave_reducer.ts deleted file mode 100644 index 7a16fc253db..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/presence_leave_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - scope: __t.string(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/reset_world_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/reset_world_reducer.ts deleted file mode 100644 index e18fbc0a086..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/reset_world_reducer.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 {}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/revoke_access_key_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/revoke_access_key_reducer.ts deleted file mode 100644 index 74c389667b3..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/revoke_access_key_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - keyId: __t.string(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/rotate_access_key_procedure.ts b/spacetime-api-keys-ts/example/src/codegen/app/rotate_access_key_procedure.ts deleted file mode 100644 index 8261e1ed9bc..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/rotate_access_key_procedure.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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 { - ApiKeyCreateResult, -} from "./types"; - -export const params = { - keyId: __t.string(), - expiresInSeconds: __t.option(__t.u32()), - keyPrefix: __t.option(__t.string()), -}; -export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/codegen/app/terraform_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/terraform_reducer.ts deleted file mode 100644 index fea18233964..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/terraform_reducer.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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 { - x: __t.i32(), - y: __t.i32(), - terrain: __t.string(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/types.ts b/spacetime-api-keys-ts/example/src/codegen/app/types.ts deleted file mode 100644 index 54619126f70..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/types.ts +++ /dev/null @@ -1,159 +0,0 @@ -// 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 const AccessKeySummary = __t.object("AccessKeySummary", { - keyId: __t.string(), - prefix: __t.string(), - ownerSubject: __t.string(), - name: __t.string(), - scopesJson: __t.string(), - metadataJson: __t.option(__t.string()), - get status() { - return ApiKeyStatus; - }, - createdAt: __t.timestamp(), - expiresAt: __t.option(__t.timestamp()), - lastUsedAt: __t.option(__t.timestamp()), - revokedAt: __t.option(__t.timestamp()), -}); -export type AccessKeySummary = __Infer; - -export const ApiKeyCreateResult = __t.object("ApiKeyCreateResult", { - keyId: __t.string(), - key: __t.string(), - prefix: __t.string(), - ownerSubject: __t.string(), - name: __t.string(), - scopesJson: __t.string(), - metadataJson: __t.option(__t.string()), - get status() { - return ApiKeyStatus; - }, - createdAt: __t.timestamp(), - expiresAt: __t.option(__t.timestamp()), -}); -export type ApiKeyCreateResult = __Infer; - -// The tagged union or sum type for the algebraic type `ApiKeyStatus`. -export const ApiKeyStatus = __t.enum("ApiKeyStatus", { - Active: __t.unit(), - Revoked: __t.unit(), -}); -export type ApiKeyStatus = __Infer; - -export const CellState = __t.object("CellState", { - id: __t.u64(), - gridId: __t.u64(), - x: __t.i32(), - y: __t.i32(), - cost: __t.i32(), - terrain: __t.option(__t.string()), -}); -export type CellState = __Infer; - -export const ColonyCells = __t.object("ColonyCells", {}); -export type ColonyCells = __Infer; - -export const ColonyEntities = __t.object("ColonyEntities", {}); -export type ColonyEntities = __Infer; - -export const ColonyGrid = __t.object("ColonyGrid", {}); -export type ColonyGrid = __Infer; - -export const ColonySweepTick = __t.object("ColonySweepTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), -}); -export type ColonySweepTick = __Infer; - -export const EnsureWorldResult = __t.object("EnsureWorldResult", { - ownerSubject: __t.string(), - gridId: __t.u64(), -}); -export type EnsureWorldResult = __Infer; - -export const Grid = __t.object("Grid", { - id: __t.u64(), - ownerUserId: __t.string(), - name: __t.string(), - kind: __t.string(), - orientation: __t.string(), - width: __t.i32(), - height: __t.i32(), - defaultCost: __t.i32(), - connectivity: __t.i32(), - mode: __t.string(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type Grid = __Infer; - -export const GridEntity = __t.object("GridEntity", { - id: __t.u64(), - gridId: __t.u64(), - ownerUserId: __t.string(), - x: __t.i32(), - y: __t.i32(), - kind: __t.string(), - blocksMovement: __t.bool(), - label: __t.option(__t.string()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type GridEntity = __Infer; - -export const MyAccessKeys = __t.object("MyAccessKeys", {}); -export type MyAccessKeys = __Infer; - -export const PresenceConfig = __t.object("PresenceConfig", { - singleton: __t.bool(), - defaultTtlSeconds: __t.u32(), - sweepBatch: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type PresenceConfig = __Infer; - -export const PresenceEntry = __t.object("PresenceEntry", { - key: __t.string(), - scope: __t.string(), - subject: __t.string(), - status: __t.string(), - activity: __t.option(__t.string()), - payloadJson: __t.option(__t.string()), - joinedAt: __t.timestamp(), - lastSeenAt: __t.timestamp(), - expiresAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type PresenceEntry = __Infer; - -export const World = __t.object("World", { - ownerSubject: __t.string(), - gridId: __t.u64(), - name: __t.string(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type World = __Infer; - -export const WorldEvent = __t.object("WorldEvent", { - eventId: __t.u64(), - ownerSubject: __t.string(), - keyPrefix: __t.string(), - action: __t.string(), - allowed: __t.bool(), - reason: __t.string(), - message: __t.string(), - createdAt: __t.timestamp(), -}); -export type WorldEvent = __Infer; - diff --git a/spacetime-api-keys-ts/example/src/codegen/app/types/procedures.ts b/spacetime-api-keys-ts/example/src/codegen/app/types/procedures.ts deleted file mode 100644 index 4655da2c690..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/types/procedures.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 CreateAccessKeyProcedure from "../create_access_key_procedure"; -import * as EnsureWorldProcedure from "../ensure_world_procedure"; -import * as RotateAccessKeyProcedure from "../rotate_access_key_procedure"; - -export type CreateAccessKeyArgs = __Infer; -export type CreateAccessKeyResult = __Infer; -export type EnsureWorldArgs = __Infer; -export type EnsureWorldResult = __Infer; -export type RotateAccessKeyArgs = __Infer; -export type RotateAccessKeyResult = __Infer; - diff --git a/spacetime-api-keys-ts/example/src/codegen/app/types/reducers.ts b/spacetime-api-keys-ts/example/src/codegen/app/types/reducers.ts deleted file mode 100644 index b44ba89c968..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/types/reducers.ts +++ /dev/null @@ -1,30 +0,0 @@ -// 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 -import BuildReducer from "../build_reducer"; -import ClearReducer from "../clear_reducer"; -import ClearWorldEventsReducer from "../clear_world_events_reducer"; -import PlantReducer from "../plant_reducer"; -import PresenceHeartbeatReducer from "../presence_heartbeat_reducer"; -import PresenceLeaveReducer from "../presence_leave_reducer"; -import ResetWorldReducer from "../reset_world_reducer"; -import RevokeAccessKeyReducer from "../revoke_access_key_reducer"; -import TerraformReducer from "../terraform_reducer"; -import UnbuildReducer from "../unbuild_reducer"; - -export type BuildParams = __Infer; -export type ClearParams = __Infer; -export type ClearWorldEventsParams = __Infer; -export type PlantParams = __Infer; -export type PresenceHeartbeatParams = __Infer; -export type PresenceLeaveParams = __Infer; -export type ResetWorldParams = __Infer; -export type RevokeAccessKeyParams = __Infer; -export type TerraformParams = __Infer; -export type UnbuildParams = __Infer; - diff --git a/spacetime-api-keys-ts/example/src/codegen/app/unbuild_reducer.ts b/spacetime-api-keys-ts/example/src/codegen/app/unbuild_reducer.ts deleted file mode 100644 index c061193b961..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/unbuild_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - x: __t.i32(), - y: __t.i32(), -}; diff --git a/spacetime-api-keys-ts/example/src/codegen/app/world_event_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/world_event_table.ts deleted file mode 100644 index ec04549e0ef..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/world_event_table.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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({ - eventId: __t.u64().primaryKey().name("event_id"), - ownerSubject: __t.string().name("owner_subject"), - keyPrefix: __t.string().name("key_prefix"), - action: __t.string(), - allowed: __t.bool(), - reason: __t.string(), - message: __t.string(), - createdAt: __t.timestamp().name("created_at"), -}); diff --git a/spacetime-api-keys-ts/example/src/codegen/app/world_table.ts b/spacetime-api-keys-ts/example/src/codegen/app/world_table.ts deleted file mode 100644 index 7a4304b505b..00000000000 --- a/spacetime-api-keys-ts/example/src/codegen/app/world_table.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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({ - ownerSubject: __t.string().primaryKey().name("owner_subject"), - gridId: __t.u64().name("grid_id"), - name: __t.string(), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-auth-ts/example/.gitignore b/spacetime-auth-ts/example/.gitignore deleted file mode 100644 index 42aba3bdb4b..00000000000 --- a/spacetime-auth-ts/example/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -node_modules/ -dist/ -src/codegen/ -public/app.js -public/app.js.map -.env - -# Local SpacetimeDB state and secrets. -.stdb-data/ -.secrets/ -*.pid diff --git a/spacetime-cron-ts/example/src/codegen/app/activity_log_table.ts b/spacetime-cron-ts/example/src/codegen/app/activity_log_table.ts deleted file mode 100644 index e6c83267786..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/activity_log_table.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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().primaryKey(), - jobName: __t.string().name("job_name"), - message: __t.string(), - at: __t.timestamp(), -}); diff --git a/spacetime-cron-ts/example/src/codegen/app/cleanup_fire_table.ts b/spacetime-cron-ts/example/src/codegen/app/cleanup_fire_table.ts deleted file mode 100644 index 107696eecfe..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/cleanup_fire_table.ts +++ /dev/null @@ -1,26 +0,0 @@ -// 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 { - CronFireRecovery, -} from "./types"; - - -export default __t.row({ - scheduledId: __t.u64().primaryKey().name("scheduled_id"), - scheduledAt: __t.scheduleAt().name("scheduled_at"), - jobName: __t.string().name("job_name"), - generation: __t.u64(), - targetAt: __t.option(__t.timestamp()).name("target_at"), - get recovery() { - return __t.option(CronFireRecovery); - }, -}); diff --git a/spacetime-cron-ts/example/src/codegen/app/cron_jobs_table.ts b/spacetime-cron-ts/example/src/codegen/app/cron_jobs_table.ts deleted file mode 100644 index a7f49c2639a..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/cron_jobs_table.ts +++ /dev/null @@ -1,30 +0,0 @@ -// 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 { - CronSchedule, -} from "./types"; - - -export default __t.row({ - name: __t.string().primaryKey(), - get schedule() { - return CronSchedule; - }, - enabled: __t.bool(), - maxFailures: __t.u32().name("max_failures"), - consecutiveFailures: __t.u32().name("consecutive_failures"), - fireCount: __t.u64().name("fire_count"), - generation: __t.u64(), - lastRunAt: __t.option(__t.timestamp()).name("last_run_at"), - nextRunAt: __t.option(__t.timestamp()).name("next_run_at"), - disabledReason: __t.option(__t.string()).name("disabled_reason"), -}); diff --git a/spacetime-cron-ts/example/src/codegen/app/cron_reconcile_tick_table.ts b/spacetime-cron-ts/example/src/codegen/app/cron_reconcile_tick_table.ts deleted file mode 100644 index e58ad71b8ca..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/cron_reconcile_tick_table.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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({ - scheduledId: __t.u64().primaryKey().name("scheduled_id"), - scheduledAt: __t.scheduleAt().name("scheduled_at"), - key: __t.string(), -}); diff --git a/spacetime-cron-ts/example/src/codegen/app/cron_run_table.ts b/spacetime-cron-ts/example/src/codegen/app/cron_run_table.ts deleted file mode 100644 index 122a896817f..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/cron_run_table.ts +++ /dev/null @@ -1,28 +0,0 @@ -// 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 { - CronRunStatus, -} from "./types"; - - -export default __t.row({ - invocationId: __t.string().primaryKey().name("invocation_id"), - jobName: __t.string().name("job_name"), - generation: __t.u64(), - sequence: __t.u64(), - scheduledFor: __t.timestamp().name("scheduled_for"), - completedAt: __t.timestamp().name("completed_at"), - get status() { - return CronRunStatus; - }, - error: __t.option(__t.string()), -}); diff --git a/spacetime-cron-ts/example/src/codegen/app/digest_fire_table.ts b/spacetime-cron-ts/example/src/codegen/app/digest_fire_table.ts deleted file mode 100644 index 107696eecfe..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/digest_fire_table.ts +++ /dev/null @@ -1,26 +0,0 @@ -// 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 { - CronFireRecovery, -} from "./types"; - - -export default __t.row({ - scheduledId: __t.u64().primaryKey().name("scheduled_id"), - scheduledAt: __t.scheduleAt().name("scheduled_at"), - jobName: __t.string().name("job_name"), - generation: __t.u64(), - targetAt: __t.option(__t.timestamp()).name("target_at"), - get recovery() { - return __t.option(CronFireRecovery); - }, -}); diff --git a/spacetime-cron-ts/example/src/codegen/app/index.ts b/spacetime-cron-ts/example/src/codegen/app/index.ts deleted file mode 100644 index cac97144152..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/index.ts +++ /dev/null @@ -1,203 +0,0 @@ -// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). - -/* 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 ScheduleCronReducer from "./schedule_cron_reducer"; -import ScheduleEveryReducer from "./schedule_every_reducer"; -import UnscheduleJobReducer from "./unschedule_job_reducer"; - -// Import all procedure arg schemas - -// Import all table schema definitions -import ActivityLogRow from "./activity_log_table"; -import CleanupFireRow from "./cleanup_fire_table"; -import CronJobsRow from "./cron_jobs_table"; -import CronReconcileTickRow from "./cron_reconcile_tick_table"; -import CronRunRow from "./cron_run_table"; -import DigestFireRow from "./digest_fire_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({ - activityLog: __table({ - name: 'activity_log', - indexes: [ - { accessor: 'at', name: 'activity_log_at_idx_btree', algorithm: 'btree', columns: [ - 'at', - ] }, - { accessor: 'id', name: 'activity_log_id_idx_btree', algorithm: 'btree', columns: [ - 'id', - ] }, - { accessor: 'jobName', name: 'activity_log_job_name_idx_btree', algorithm: 'btree', columns: [ - 'jobName', - ] }, - ], - constraints: [ - { name: 'activity_log_id_key', constraint: 'unique', columns: ['id'] }, - ], - }, ActivityLogRow), - cleanupFire: __table({ - name: 'cleanup_fire', - indexes: [ - { accessor: 'jobName', name: 'cleanup_fire_job_name_idx_btree', algorithm: 'btree', columns: [ - 'jobName', - ] }, - { accessor: 'scheduledId', name: 'cleanup_fire_scheduled_id_idx_btree', algorithm: 'btree', columns: [ - 'scheduledId', - ] }, - ], - constraints: [ - { name: 'cleanup_fire_job_name_key', constraint: 'unique', columns: ['jobName'] }, - { name: 'cleanup_fire_scheduled_id_key', constraint: 'unique', columns: ['scheduledId'] }, - ], - }, CleanupFireRow), - cronReconcileTick: __table({ - name: 'cron_reconcile_tick', - indexes: [ - { accessor: 'key', name: 'cron_reconcile_tick_key_idx_btree', algorithm: 'btree', columns: [ - 'key', - ] }, - { accessor: 'scheduledId', name: 'cron_reconcile_tick_scheduled_id_idx_btree', algorithm: 'btree', columns: [ - 'scheduledId', - ] }, - ], - constraints: [ - { name: 'cron_reconcile_tick_key_key', constraint: 'unique', columns: ['key'] }, - { name: 'cron_reconcile_tick_scheduled_id_key', constraint: 'unique', columns: ['scheduledId'] }, - ], - }, CronReconcileTickRow), - cronRun: __table({ - name: 'cron_run', - indexes: [ - { accessor: 'invocationId', name: 'cron_run_invocation_id_idx_btree', algorithm: 'btree', columns: [ - 'invocationId', - ] }, - { accessor: 'jobName', name: 'cron_run_job_name_idx_btree', algorithm: 'btree', columns: [ - 'jobName', - ] }, - ], - constraints: [ - { name: 'cron_run_invocation_id_key', constraint: 'unique', columns: ['invocationId'] }, - ], - }, CronRunRow), - digestFire: __table({ - name: 'digest_fire', - indexes: [ - { accessor: 'jobName', name: 'digest_fire_job_name_idx_btree', algorithm: 'btree', columns: [ - 'jobName', - ] }, - { accessor: 'scheduledId', name: 'digest_fire_scheduled_id_idx_btree', algorithm: 'btree', columns: [ - 'scheduledId', - ] }, - ], - constraints: [ - { name: 'digest_fire_job_name_key', constraint: 'unique', columns: ['jobName'] }, - { name: 'digest_fire_scheduled_id_key', constraint: 'unique', columns: ['scheduledId'] }, - ], - }, DigestFireRow), - cronJobs: __table({ - name: 'cron_jobs', - indexes: [ - ], - constraints: [ - ], - }, CronJobsRow), -}); - -/** 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( - __reducerSchema("schedule_cron", ScheduleCronReducer), - __reducerSchema("schedule_every", ScheduleEveryReducer), - __reducerSchema("unschedule_job", UnscheduleJobReducer), -); - -/** 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( -); - -/** The remote SpacetimeDB module schema, both runtime and type information. */ -const REMOTE_MODULE = { - versionInfo: { - cliVersion: "2.8.3" 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 = __makeQueryBuilder(tablesSchema.schemaType); - -/** The reducers available in this remote SpacetimeDB module. */ -export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); - -/** The procedures available in this remote SpacetimeDB module. */ -export const procedures = __convertToAccessorMap(proceduresSchema.procedures); - -/** The context type returned in callbacks for all possible events. */ -export type EventContext = __EventContextInterface; -/** The context type returned in callbacks for reducer events. */ -export type ReducerEventContext = __ReducerEventContextInterface; -/** The context type returned in callbacks for subscription events. */ -export type SubscriptionEventContext = __SubscriptionEventContextInterface; -/** The context type returned in callbacks for error events. */ -export type ErrorContext = __ErrorContextInterface; -/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ -export type SubscriptionHandle = __SubscriptionHandleImpl; - -/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ -export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} - -/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ -export class DbConnectionBuilder extends __DbConnectionBuilder {} - -/** 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 { - /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ - static builder = (): DbConnectionBuilder => { - return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); - }; - - /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ - override subscriptionBuilder = (): SubscriptionBuilder => { - return new SubscriptionBuilder(this); - }; -} - diff --git a/spacetime-cron-ts/example/src/codegen/app/schedule_cron_reducer.ts b/spacetime-cron-ts/example/src/codegen/app/schedule_cron_reducer.ts deleted file mode 100644 index 708fe42e2e6..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/schedule_cron_reducer.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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 { - name: __t.string(), - expression: __t.string(), - timezone: __t.string(), - keep: __t.u32(), -}; diff --git a/spacetime-cron-ts/example/src/codegen/app/schedule_every_reducer.ts b/spacetime-cron-ts/example/src/codegen/app/schedule_every_reducer.ts deleted file mode 100644 index e054dd57fa5..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/schedule_every_reducer.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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 { - name: __t.string(), - seconds: __t.u32(), - keep: __t.u32(), -}; diff --git a/spacetime-cron-ts/example/src/codegen/app/types.ts b/spacetime-cron-ts/example/src/codegen/app/types.ts deleted file mode 100644 index c538888e35f..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/types.ts +++ /dev/null @@ -1,153 +0,0 @@ -// 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 const ActivityLog = __t.object("ActivityLog", { - id: __t.u64(), - jobName: __t.string(), - message: __t.string(), - at: __t.timestamp(), -}); -export type ActivityLog = __Infer; - -export const CleanupCronArgs = __t.object("CleanupCronArgs", { - keep: __t.u32(), -}); -export type CleanupCronArgs = __Infer; - -export const CleanupFire = __t.object("CleanupFire", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), - jobName: __t.string(), - generation: __t.u64(), - targetAt: __t.option(__t.timestamp()), - get recovery() { - return __t.option(CronFireRecovery); - }, -}); -export type CleanupFire = __Infer; - -export const CronFireRecovery = __t.object("CronFireRecovery", { - sequence: __t.u64(), - scheduledFor: __t.timestamp(), - error: __t.string(), -}); -export type CronFireRecovery = __Infer; - -export const CronJob = __t.object("CronJob", { - name: __t.string(), - get schedule() { - return CronSchedule; - }, - get args() { - return CronJobArgsValue; - }, - enabled: __t.bool(), - maxFailures: __t.u32(), - consecutiveFailures: __t.u32(), - fireCount: __t.u64(), - generation: __t.u64(), - lastRunAt: __t.option(__t.timestamp()), - nextRunAt: __t.option(__t.timestamp()), - disabledReason: __t.option(__t.string()), -}); -export type CronJob = __Infer; - -// The tagged union or sum type for the algebraic type `CronJobArgsValue`. -export const CronJobArgsValue = __t.enum("CronJobArgsValue", { - get Cleanup() { - return CleanupCronArgs; - }, - Digest: __t.unit(), -}); -export type CronJobArgsValue = __Infer; - -export const CronJobView = __t.object("CronJobView", { - name: __t.string(), - get schedule() { - return CronSchedule; - }, - enabled: __t.bool(), - maxFailures: __t.u32(), - consecutiveFailures: __t.u32(), - fireCount: __t.u64(), - generation: __t.u64(), - lastRunAt: __t.option(__t.timestamp()), - nextRunAt: __t.option(__t.timestamp()), - disabledReason: __t.option(__t.string()), -}); -export type CronJobView = __Infer; - -export const CronJobs = __t.object("CronJobs", {}); -export type CronJobs = __Infer; - -export const CronReconcileTick = __t.object("CronReconcileTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), - key: __t.string(), -}); -export type CronReconcileTick = __Infer; - -export const CronRun = __t.object("CronRun", { - invocationId: __t.string(), - jobName: __t.string(), - generation: __t.u64(), - sequence: __t.u64(), - scheduledFor: __t.timestamp(), - completedAt: __t.timestamp(), - get status() { - return CronRunStatus; - }, - error: __t.option(__t.string()), -}); -export type CronRun = __Infer; - -// The tagged union or sum type for the algebraic type `CronRunStatus`. -export const CronRunStatus = __t.enum("CronRunStatus", { - Ok: __t.unit(), - Failed: __t.unit(), -}); -export type CronRunStatus = __Infer; - -// The tagged union or sum type for the algebraic type `CronSchedule`. -export const CronSchedule = __t.enum("CronSchedule", { - get Cron() { - return CronSpec; - }, - get Every() { - return EverySpec; - }, -}); -export type CronSchedule = __Infer; - -export const CronSpec = __t.object("CronSpec", { - expression: __t.string(), - timezone: __t.string(), -}); -export type CronSpec = __Infer; - -export const DigestFire = __t.object("DigestFire", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), - jobName: __t.string(), - generation: __t.u64(), - targetAt: __t.option(__t.timestamp()), - get recovery() { - return __t.option(CronFireRecovery); - }, -}); -export type DigestFire = __Infer; - -export const EverySpec = __t.object("EverySpec", { - seconds: __t.u32(), -}); -export type EverySpec = __Infer; - diff --git a/spacetime-cron-ts/example/src/codegen/app/types/procedures.ts b/spacetime-cron-ts/example/src/codegen/app/types/procedures.ts deleted file mode 100644 index d5ac825c9ab..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/types/procedures.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 - - diff --git a/spacetime-cron-ts/example/src/codegen/app/types/reducers.ts b/spacetime-cron-ts/example/src/codegen/app/types/reducers.ts deleted file mode 100644 index 3f09c30fc78..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/types/reducers.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 -import ScheduleCronReducer from "../schedule_cron_reducer"; -import ScheduleEveryReducer from "../schedule_every_reducer"; -import UnscheduleJobReducer from "../unschedule_job_reducer"; - -export type ScheduleCronParams = __Infer; -export type ScheduleEveryParams = __Infer; -export type UnscheduleJobParams = __Infer; - diff --git a/spacetime-cron-ts/example/src/codegen/app/unschedule_job_reducer.ts b/spacetime-cron-ts/example/src/codegen/app/unschedule_job_reducer.ts deleted file mode 100644 index ce493ee8574..00000000000 --- a/spacetime-cron-ts/example/src/codegen/app/unschedule_job_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - name: __t.string(), -}; diff --git a/spacetime-files-ts/example/src/codegen/app/create_folder_reducer.ts b/spacetime-files-ts/example/src/codegen/app/create_folder_reducer.ts deleted file mode 100644 index 8a2a9b08001..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/create_folder_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - path: __t.string(), -}; diff --git a/spacetime-files-ts/example/src/codegen/app/delete_file_reducer.ts b/spacetime-files-ts/example/src/codegen/app/delete_file_reducer.ts deleted file mode 100644 index 8a2a9b08001..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/delete_file_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - path: __t.string(), -}; diff --git a/spacetime-files-ts/example/src/codegen/app/delete_folder_reducer.ts b/spacetime-files-ts/example/src/codegen/app/delete_folder_reducer.ts deleted file mode 100644 index 8a2a9b08001..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/delete_folder_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - path: __t.string(), -}; diff --git a/spacetime-files-ts/example/src/codegen/app/files/types.ts b/spacetime-files-ts/example/src/codegen/app/files/types.ts deleted file mode 100644 index a8336b9566f..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/files/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -// 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 const File = __t.object("File", { - id: __t.u64(), - ownerPathKey: __t.string(), - path: __t.string(), - ownerUserId: __t.string(), - mimeType: __t.string(), - size: __t.u64(), - sha256Hex: __t.string(), - visibility: __t.string(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type File = __Infer; - -export const FileBlob = __t.object("FileBlob", { - fileId: __t.u64(), - bytes: __t.byteArray(), -}); -export type FileBlob = __Infer; - diff --git a/spacetime-files-ts/example/src/codegen/app/index.ts b/spacetime-files-ts/example/src/codegen/app/index.ts deleted file mode 100644 index 9f863bb3fde..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/index.ts +++ /dev/null @@ -1,142 +0,0 @@ -// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). - -/* 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 CreateFolderReducer from "./create_folder_reducer"; -import DeleteFileReducer from "./delete_file_reducer"; -import DeleteFolderReducer from "./delete_folder_reducer"; -import MoveFileReducer from "./move_file_reducer"; -import RenameFileReducer from "./rename_file_reducer"; -import RenameFolderReducer from "./rename_folder_reducer"; -import SetFileVisibilityReducer from "./set_file_visibility_reducer"; -import UploadFileReducer from "./upload_file_reducer"; - -// Import all procedure arg schemas -import * as ReadFileBytesProcedure from "./read_file_bytes_procedure"; - -// Import all table schema definitions -import MyFileSummariesRow from "./my_file_summaries_table"; -import MyFoldersRow from "./my_folders_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({ - myFileSummaries: __table({ - name: 'my_file_summaries', - indexes: [ - ], - constraints: [ - ], - }, MyFileSummariesRow), - myFolders: __table({ - name: 'my_folders', - indexes: [ - ], - constraints: [ - ], - }, MyFoldersRow), -}); - -/** 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( - __reducerSchema("create_folder", CreateFolderReducer), - __reducerSchema("delete_file", DeleteFileReducer), - __reducerSchema("delete_folder", DeleteFolderReducer), - __reducerSchema("move_file", MoveFileReducer), - __reducerSchema("rename_file", RenameFileReducer), - __reducerSchema("rename_folder", RenameFolderReducer), - __reducerSchema("set_file_visibility", SetFileVisibilityReducer), - __reducerSchema("upload_file", UploadFileReducer), -); - -/** 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("read_file_bytes", ReadFileBytesProcedure.params, ReadFileBytesProcedure.returnType), -); - -/** The remote SpacetimeDB module schema, both runtime and type information. */ -const REMOTE_MODULE = { - versionInfo: { - cliVersion: "2.8.3" 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 = __makeQueryBuilder(tablesSchema.schemaType); - -/** The reducers available in this remote SpacetimeDB module. */ -export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); - -/** The procedures available in this remote SpacetimeDB module. */ -export const procedures = __convertToAccessorMap(proceduresSchema.procedures); - -/** The context type returned in callbacks for all possible events. */ -export type EventContext = __EventContextInterface; -/** The context type returned in callbacks for reducer events. */ -export type ReducerEventContext = __ReducerEventContextInterface; -/** The context type returned in callbacks for subscription events. */ -export type SubscriptionEventContext = __SubscriptionEventContextInterface; -/** The context type returned in callbacks for error events. */ -export type ErrorContext = __ErrorContextInterface; -/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ -export type SubscriptionHandle = __SubscriptionHandleImpl; - -/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ -export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} - -/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ -export class DbConnectionBuilder extends __DbConnectionBuilder {} - -/** 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 { - /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ - static builder = (): DbConnectionBuilder => { - return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); - }; - - /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ - override subscriptionBuilder = (): SubscriptionBuilder => { - return new SubscriptionBuilder(this); - }; -} - diff --git a/spacetime-files-ts/example/src/codegen/app/move_file_reducer.ts b/spacetime-files-ts/example/src/codegen/app/move_file_reducer.ts deleted file mode 100644 index 39be1981f74..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/move_file_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - oldPath: __t.string(), - targetFolderPath: __t.string(), -}; diff --git a/spacetime-files-ts/example/src/codegen/app/my_file_summaries_table.ts b/spacetime-files-ts/example/src/codegen/app/my_file_summaries_table.ts deleted file mode 100644 index 52eae105682..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/my_file_summaries_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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(), - path: __t.string(), - mimeType: __t.string().name("mime_type"), - size: __t.u64(), - sha256Hex: __t.string().name("sha_256_hex"), - visibility: __t.string(), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-files-ts/example/src/codegen/app/my_folders_table.ts b/spacetime-files-ts/example/src/codegen/app/my_folders_table.ts deleted file mode 100644 index dc7c48b3c85..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/my_folders_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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().primaryKey(), - ownerUserId: __t.string().name("owner_user_id"), - path: __t.string(), - name: __t.string(), - parentPath: __t.string().name("parent_path"), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-files-ts/example/src/codegen/app/read_file_bytes_procedure.ts b/spacetime-files-ts/example/src/codegen/app/read_file_bytes_procedure.ts deleted file mode 100644 index 902cf595193..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/read_file_bytes_procedure.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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 { - FileBytes, -} from "./types"; - -export const params = { - path: __t.string(), -}; -export const returnType = FileBytes \ No newline at end of file diff --git a/spacetime-files-ts/example/src/codegen/app/rename_file_reducer.ts b/spacetime-files-ts/example/src/codegen/app/rename_file_reducer.ts deleted file mode 100644 index 50145dc0f1d..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/rename_file_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - oldPath: __t.string(), - newPath: __t.string(), -}; diff --git a/spacetime-files-ts/example/src/codegen/app/rename_folder_reducer.ts b/spacetime-files-ts/example/src/codegen/app/rename_folder_reducer.ts deleted file mode 100644 index 83dd1a2c22b..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/rename_folder_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - path: __t.string(), - newName: __t.string(), -}; diff --git a/spacetime-files-ts/example/src/codegen/app/set_file_visibility_reducer.ts b/spacetime-files-ts/example/src/codegen/app/set_file_visibility_reducer.ts deleted file mode 100644 index f6ffa1c4f04..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/set_file_visibility_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - path: __t.string(), - visibility: __t.string(), -}; diff --git a/spacetime-files-ts/example/src/codegen/app/types.ts b/spacetime-files-ts/example/src/codegen/app/types.ts deleted file mode 100644 index 0d89dc6fd01..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -// 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 const FileBytes = __t.object("FileBytes", { - bytes: __t.byteArray(), - mimeType: __t.string(), -}); -export type FileBytes = __Infer; - -export const FileSummary = __t.object("FileSummary", { - id: __t.u64(), - path: __t.string(), - mimeType: __t.string(), - size: __t.u64(), - sha256Hex: __t.string(), - visibility: __t.string(), - updatedAt: __t.timestamp(), -}); -export type FileSummary = __Infer; - -export const Folder = __t.object("Folder", { - id: __t.u64(), - ownerUserId: __t.string(), - path: __t.string(), - name: __t.string(), - parentPath: __t.string(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type Folder = __Infer; - -export const MyFileSummaries = __t.object("MyFileSummaries", {}); -export type MyFileSummaries = __Infer; - -export const MyFolders = __t.object("MyFolders", {}); -export type MyFolders = __Infer; - diff --git a/spacetime-files-ts/example/src/codegen/app/types/procedures.ts b/spacetime-files-ts/example/src/codegen/app/types/procedures.ts deleted file mode 100644 index f6c24f082b1..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/types/procedures.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 ReadFileBytesProcedure from "../read_file_bytes_procedure"; - -export type ReadFileBytesArgs = __Infer; -export type ReadFileBytesResult = __Infer; - diff --git a/spacetime-files-ts/example/src/codegen/app/types/reducers.ts b/spacetime-files-ts/example/src/codegen/app/types/reducers.ts deleted file mode 100644 index 4584b8cc134..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/types/reducers.ts +++ /dev/null @@ -1,26 +0,0 @@ -// 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 -import CreateFolderReducer from "../create_folder_reducer"; -import DeleteFileReducer from "../delete_file_reducer"; -import DeleteFolderReducer from "../delete_folder_reducer"; -import MoveFileReducer from "../move_file_reducer"; -import RenameFileReducer from "../rename_file_reducer"; -import RenameFolderReducer from "../rename_folder_reducer"; -import SetFileVisibilityReducer from "../set_file_visibility_reducer"; -import UploadFileReducer from "../upload_file_reducer"; - -export type CreateFolderParams = __Infer; -export type DeleteFileParams = __Infer; -export type DeleteFolderParams = __Infer; -export type MoveFileParams = __Infer; -export type RenameFileParams = __Infer; -export type RenameFolderParams = __Infer; -export type SetFileVisibilityParams = __Infer; -export type UploadFileParams = __Infer; - diff --git a/spacetime-files-ts/example/src/codegen/app/upload_file_reducer.ts b/spacetime-files-ts/example/src/codegen/app/upload_file_reducer.ts deleted file mode 100644 index 9d3d5519978..00000000000 --- a/spacetime-files-ts/example/src/codegen/app/upload_file_reducer.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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 { - path: __t.string(), - mimeType: __t.string(), - bytes: __t.byteArray(), - visibility: __t.string(), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/actor_directory_table.ts b/spacetime-grid-ts/example/src/codegen/app/actor_directory_table.ts deleted file mode 100644 index a6151ec4368..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/actor_directory_table.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 { - ActorKind, -} from "./types"; - - -export default __t.row({ - actorId: __t.string().name("actor_id"), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - get kind() { - return ActorKind; - }, -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/ai_take_turn_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/ai_take_turn_procedure.ts deleted file mode 100644 index 8f540212e08..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/ai_take_turn_procedure.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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 { - AiTakeTurnResult, -} from "./types"; - -export const params = { - matchId: __t.u64(), -}; -export const returnType = AiTakeTurnResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/attack_unit_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/attack_unit_procedure.ts deleted file mode 100644 index 6416ac0c492..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/attack_unit_procedure.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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 const params = { - attackerId: __t.u64(), - targetId: __t.u64(), -}; -export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts deleted file mode 100644 index c32c6231beb..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - AuthPubKey, -} from "./types"; - -export const params = { -}; -export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/link_connection_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/link_connection_reducer.ts deleted file mode 100644 index da04a554a3b..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/link_connection_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionToken: __t.string(), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts deleted file mode 100644 index 0fff293b69e..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - MySessions, -} from "./types"; - -export const params = { -}; -export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/my_auth_user_table.ts b/spacetime-grid-ts/example/src/codegen/app/auth/my_auth_user_table.ts deleted file mode 100644 index 6573c3fe132..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/my_auth_user_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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({ - userId: __t.string().primaryKey().name("user_id"), - email: __t.string(), - emailVerified: __t.bool().name("email_verified"), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts deleted file mode 100644 index e39846ca8d9..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - identity: __t.identity(), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts deleted file mode 100644 index 189f539a043..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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({ - key: __t.string().primaryKey(), - scope: __t.string(), - windowStart: __t.timestamp().name("window_start"), - expiresAt: __t.timestamp().name("expires_at"), - count: __t.u32(), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts deleted file mode 100644 index a98b8588aad..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 { - RateLimitConsumeResult, -} from "./types"; - -export const params = { - scope: __t.string(), - actorKey: __t.string(), - limit: __t.u32(), - windowSeconds: __t.u32(), - cost: __t.option(__t.u32()), -}; -export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts deleted file mode 100644 index 66ffe86e399..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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({ - singleton: __t.bool().primaryKey(), - sweepBatch: __t.u32().name("sweep_batch"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts deleted file mode 100644 index a7c5cc5274f..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - maxRows: __t.option(__t.u32()), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts deleted file mode 100644 index 9815c99eb38..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 const params = { - maxRows: __t.option(__t.u32()), -}; -export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/types.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/types.ts deleted file mode 100644 index 151a90e827f..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); -export type AdminRateLimitBuckets = __Infer; - -export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { - identity: __t.identity(), - addedAtMicros: __t.i64(), -}); -export type RateLimitAdminIdentity = __Infer; - -export const RateLimitBucket = __t.object("RateLimitBucket", { - key: __t.string(), - scope: __t.string(), - windowStart: __t.timestamp(), - expiresAt: __t.timestamp(), - count: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type RateLimitBucket = __Infer; - -export const RateLimitConfig = __t.object("RateLimitConfig", { - singleton: __t.bool(), - sweepBatch: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type RateLimitConfig = __Infer; - -export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { - allowed: __t.bool(), - scope: __t.string(), - key: __t.string(), - limit: __t.u32(), - used: __t.u32(), - remaining: __t.u32(), - retryAfterSeconds: __t.u32(), - resetAt: __t.timestamp(), -}); -export type RateLimitConsumeResult = __Infer; - -export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), -}); -export type RateLimitSweepTick = __Infer; - diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts deleted file mode 100644 index 54fcf361af1..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sweepBatch: __t.u32(), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts deleted file mode 100644 index 66f95f66b3f..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionId: __t.string(), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/revoke_session_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/revoke_session_reducer.ts deleted file mode 100644 index 66f95f66b3f..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/revoke_session_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionId: __t.string(), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts deleted file mode 100644 index 790dfa70b07..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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 { - issuerUrl: __t.string(), - baseUrl: __t.option(__t.string()), - cookieName: __t.option(__t.string()), - sessionTtlSeconds: __t.option(__t.u64()), - es256PrivateKeyPem: __t.option(__t.string()), - googleClientId: __t.option(__t.string()), - googleClientSecret: __t.option(__t.string()), - githubClientId: __t.option(__t.string()), - githubClientSecret: __t.option(__t.string()), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/types.ts b/spacetime-grid-ts/example/src/codegen/app/auth/types.ts deleted file mode 100644 index 8df99e3d1b4..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/types.ts +++ /dev/null @@ -1,137 +0,0 @@ -// 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 const AuthAccount = __t.object("AuthAccount", { - accountId: __t.string(), - userId: __t.string(), - providerId: __t.string(), - providerAccountId: __t.string(), - passwordHash: __t.option(__t.string()), - accessToken: __t.option(__t.string()), - refreshToken: __t.option(__t.string()), - accessTokenExpiresAt: __t.option(__t.timestamp()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type AuthAccount = __Infer; - -export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { - identity: __t.identity(), - addedAtMicros: __t.i64(), -}); -export type AuthAdminIdentity = __Infer; - -export const AuthConfig = __t.object("AuthConfig", { - singleton: __t.bool(), - issuerUrl: __t.string(), - baseUrl: __t.string(), - cookieName: __t.string(), - sessionTtlSeconds: __t.u64(), - es256PrivateKeyPem: __t.string(), - es256PublicKeyPem: __t.string(), - keyId: __t.string(), - googleClientId: __t.option(__t.string()), - googleClientSecret: __t.option(__t.string()), - githubClientId: __t.option(__t.string()), - githubClientSecret: __t.option(__t.string()), - updatedAt: __t.timestamp(), -}); -export type AuthConfig = __Infer; - -export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { - stdbIdentity: __t.identity(), - userId: __t.string(), - linkedAt: __t.timestamp(), -}); -export type AuthConnectionBinding = __Infer; - -export const AuthOauthState = __t.object("AuthOauthState", { - state: __t.string(), - provider: __t.string(), - codeVerifier: __t.string(), - redirectTo: __t.string(), - expiresAt: __t.timestamp(), - createdAt: __t.timestamp(), -}); -export type AuthOauthState = __Infer; - -export const AuthPubKey = __t.object("AuthPubKey", { - publicKeyPem: __t.string(), - keyId: __t.string(), - issuerUrl: __t.string(), -}); -export type AuthPubKey = __Infer; - -export const AuthSession = __t.object("AuthSession", { - sessionId: __t.string(), - userId: __t.string(), - token: __t.string(), - expiresAt: __t.timestamp(), - ipAddress: __t.option(__t.string()), - userAgent: __t.option(__t.string()), - createdAt: __t.timestamp(), -}); -export type AuthSession = __Infer; - -export const AuthSweeperTick = __t.object("AuthSweeperTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), -}); -export type AuthSweeperTick = __Infer; - -export const AuthUser = __t.object("AuthUser", { - userId: __t.string(), - email: __t.string(), - emailVerified: __t.bool(), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type AuthUser = __Infer; - -export const AuthVerification = __t.object("AuthVerification", { - verificationId: __t.string(), - identifier: __t.string(), - value: __t.string(), - purpose: __t.string(), - expiresAt: __t.timestamp(), - createdAt: __t.timestamp(), -}); -export type AuthVerification = __Infer; - -export const MyAuthUser = __t.object("MyAuthUser", {}); -export type MyAuthUser = __Infer; - -export const MySession = __t.object("MySession", { - sessionId: __t.string(), - expiresAt: __t.timestamp(), - createdAt: __t.timestamp(), - ipAddress: __t.option(__t.string()), - userAgent: __t.option(__t.string()), - isCurrent: __t.bool(), -}); -export type MySession = __Infer; - -export const MySessions = __t.object("MySessions", { - get sessions() { - return __t.array(MySession); - }, -}); -export type MySessions = __Infer; - -export const WhoAmI = __t.object("WhoAmI", { - userId: __t.option(__t.string()), - senderIdentityHex: __t.string(), -}); -export type WhoAmI = __Infer; - diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts deleted file mode 100644 index e18fbc0a086..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 {}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/update_profile_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/auth/update_profile_reducer.ts deleted file mode 100644 index f940573d72c..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/update_profile_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - name: __t.option(__t.string()), - image: __t.option(__t.string()), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/auth/whoami_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/auth/whoami_procedure.ts deleted file mode 100644 index fb2b14ac8d7..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/auth/whoami_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - WhoAmI, -} from "./types"; - -export const params = { -}; -export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/create_match_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/create_match_procedure.ts deleted file mode 100644 index 19e91eb52ec..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/create_match_procedure.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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 { - CreateMatchResult, -} from "./types"; - -export const params = { - vsAi: __t.bool(), -}; -export const returnType = CreateMatchResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/end_turn_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/end_turn_procedure.ts deleted file mode 100644 index c1f25720383..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/end_turn_procedure.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 const params = { - matchId: __t.u64(), -}; -export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/get_auth_public_key_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/get_auth_public_key_procedure.ts deleted file mode 100644 index c32c6231beb..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/get_auth_public_key_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - AuthPubKey, -} from "./types"; - -export const params = { -}; -export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/get_cells_in_range_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/get_cells_in_range_procedure.ts deleted file mode 100644 index 486c44aee70..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/get_cells_in_range_procedure.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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 { - CellsInRangeResult, -} from "./types"; - -export const params = { - gridId: __t.u64(), - originX: __t.i32(), - originY: __t.i32(), - maxCost: __t.i32(), -}; -export const returnType = CellsInRangeResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/grid/types.ts b/spacetime-grid-ts/example/src/codegen/app/grid/types.ts deleted file mode 100644 index 48f7c6524bd..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/grid/types.ts +++ /dev/null @@ -1,69 +0,0 @@ -// 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 const CellState = __t.object("CellState", { - id: __t.u64(), - gridId: __t.u64(), - x: __t.i32(), - y: __t.i32(), - cost: __t.i32(), - terrain: __t.option(__t.string()), -}); -export type CellState = __Infer; - -export const EntityPath = __t.object("EntityPath", { - entityId: __t.u64(), - gridId: __t.u64(), - get cells() { - return __t.array(PathCell); - }, - cost: __t.i32(), - computedAt: __t.timestamp(), -}); -export type EntityPath = __Infer; - -export const Grid = __t.object("Grid", { - id: __t.u64(), - ownerUserId: __t.string(), - name: __t.string(), - kind: __t.string(), - orientation: __t.string(), - width: __t.i32(), - height: __t.i32(), - defaultCost: __t.i32(), - connectivity: __t.i32(), - mode: __t.string(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type Grid = __Infer; - -export const GridEntity = __t.object("GridEntity", { - id: __t.u64(), - gridId: __t.u64(), - ownerUserId: __t.string(), - x: __t.i32(), - y: __t.i32(), - kind: __t.string(), - blocksMovement: __t.bool(), - label: __t.option(__t.string()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type GridEntity = __Infer; - -export const PathCell = __t.object("PathCell", { - x: __t.i32(), - y: __t.i32(), -}); -export type PathCell = __Infer; - diff --git a/spacetime-grid-ts/example/src/codegen/app/index.ts b/spacetime-grid-ts/example/src/codegen/app/index.ts deleted file mode 100644 index 05a46c2eea7..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/index.ts +++ /dev/null @@ -1,360 +0,0 @@ -// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). - -/* 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 LinkConnectionReducer from "./link_connection_reducer"; -import RevokeMySessionReducer from "./revoke_my_session_reducer"; -import RevokeSessionReducer from "./revoke_session_reducer"; -import SetAuthConfigReducer from "./set_auth_config_reducer"; -import UnlinkConnectionReducer from "./unlink_connection_reducer"; -import UpdateProfileReducer from "./update_profile_reducer"; - -// Import all procedure arg schemas -import * as AiTakeTurnProcedure from "./ai_take_turn_procedure"; -import * as AttackUnitProcedure from "./attack_unit_procedure"; -import * as CreateMatchProcedure from "./create_match_procedure"; -import * as EndTurnProcedure from "./end_turn_procedure"; -import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; -import * as GetCellsInRangeProcedure from "./get_cells_in_range_procedure"; -import * as JoinMatchProcedure from "./join_match_procedure"; -import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; -import * as MoveUnitProcedure from "./move_unit_procedure"; -import * as WhoamiProcedure from "./whoami_procedure"; - -// Import all table schema definitions -import ActorDirectoryRow from "./actor_directory_table"; -import LobbyOpenMatchesRow from "./lobby_open_matches_table"; -import MyAuthUserRow from "./my_auth_user_table"; -import MyCellStatesRow from "./my_cell_states_table"; -import MyGridEntitiesRow from "./my_grid_entities_table"; -import MyGridsRow from "./my_grids_table"; -import MyMatchParticipantsRow from "./my_match_participants_table"; -import MyMatchesRow from "./my_matches_table"; -import MyPlayerUnitsRow from "./my_player_units_table"; -import NpcActorRow from "./npc_actor_table"; -import UnitTypeRow from "./unit_type_table"; - -// Import namespace table schema definitions -import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; -import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; -import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; - -// Import namespace reducer arg schemas -import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; -import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; -import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; -import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; -import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; -import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; -import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; -import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; -import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; - -// Import namespace procedure arg schemas -import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; -import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; -import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; -import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; -import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; - -/** 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({ - npcActor: __table({ - name: 'npc_actor', - indexes: [ - { accessor: 'actorId', name: 'npc_actor_actor_id_idx_btree', algorithm: 'btree', columns: [ - 'actorId', - ] }, - ], - constraints: [ - { name: 'npc_actor_actor_id_key', constraint: 'unique', columns: ['actorId'] }, - ], - }, NpcActorRow), - unitType: __table({ - name: 'unit_type', - indexes: [ - { accessor: 'typeId', name: 'unit_type_type_id_idx_btree', algorithm: 'btree', columns: [ - 'typeId', - ] }, - ], - constraints: [ - { name: 'unit_type_type_id_key', constraint: 'unique', columns: ['typeId'] }, - ], - }, UnitTypeRow), - actorDirectory: __table({ - name: 'actor_directory', - indexes: [ - ], - constraints: [ - ], - }, ActorDirectoryRow), - lobbyOpenMatches: __table({ - name: 'lobby_open_matches', - indexes: [ - ], - constraints: [ - ], - }, LobbyOpenMatchesRow), - myAuthUser: __table({ - name: 'my_auth_user', - indexes: [ - ], - constraints: [ - ], - }, MyAuthUserRow), - myCellStates: __table({ - name: 'my_cell_states', - indexes: [ - ], - constraints: [ - ], - }, MyCellStatesRow), - myGridEntities: __table({ - name: 'my_grid_entities', - indexes: [ - ], - constraints: [ - ], - }, MyGridEntitiesRow), - myGrids: __table({ - name: 'my_grids', - indexes: [ - ], - constraints: [ - ], - }, MyGridsRow), - myMatchParticipants: __table({ - name: 'my_match_participants', - indexes: [ - ], - constraints: [ - ], - }, MyMatchParticipantsRow), - myMatches: __table({ - name: 'my_matches', - indexes: [ - ], - constraints: [ - ], - }, MyMatchesRow), - myPlayerUnits: __table({ - name: 'my_player_units', - indexes: [ - ], - constraints: [ - ], - }, MyPlayerUnitsRow), - "auth.rateLimit.rate_limit_config": __table({ - name: 'auth.rateLimit.rate_limit_config', - indexes: [ - { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ - 'singleton', - ] }, - ], - constraints: [ - { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, - ], - }, AuthRateLimit_RateLimitConfigRow), - "auth.my_auth_user": __table({ - name: 'auth.my_auth_user', - indexes: [ - ], - constraints: [ - ], - }, Auth_MyAuthUserRow), - "auth.rateLimit.admin_rate_limit_buckets": __table({ - name: 'auth.rateLimit.admin_rate_limit_buckets', - indexes: [ - ], - constraints: [ - ], - }, AuthRateLimit_AdminRateLimitBucketsRow), -}); - -/** 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( - __reducerSchema("link_connection", LinkConnectionReducer), - __reducerSchema("revoke_my_session", RevokeMySessionReducer), - __reducerSchema("revoke_session", RevokeSessionReducer), - __reducerSchema("set_auth_config", SetAuthConfigReducer), - __reducerSchema("unlink_connection", UnlinkConnectionReducer), - __reducerSchema("update_profile", UpdateProfileReducer), - __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), - __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), - __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), - __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), - __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), - __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), - __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), - __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), - __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), -); - -/** 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("ai_take_turn", AiTakeTurnProcedure.params, AiTakeTurnProcedure.returnType), - __procedureSchema("attack_unit", AttackUnitProcedure.params, AttackUnitProcedure.returnType), - __procedureSchema("create_match", CreateMatchProcedure.params, CreateMatchProcedure.returnType), - __procedureSchema("end_turn", EndTurnProcedure.params, EndTurnProcedure.returnType), - __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), - __procedureSchema("get_cells_in_range", GetCellsInRangeProcedure.params, GetCellsInRangeProcedure.returnType), - __procedureSchema("join_match", JoinMatchProcedure.params, JoinMatchProcedure.returnType), - __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), - __procedureSchema("move_unit", MoveUnitProcedure.params, MoveUnitProcedure.returnType), - __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), - __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), - __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), - __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), - __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), - __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), -); - -/** The remote SpacetimeDB module schema, both runtime and type information. */ -const REMOTE_MODULE = { - versionInfo: { - cliVersion: "2.8.3" 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. */ -const __qb = __makeQueryBuilder(tablesSchema.schemaType); -export const tables = { - npcActor: __qb.npcActor, - unitType: __qb.unitType, - actorDirectory: __qb.actorDirectory, - lobbyOpenMatches: __qb.lobbyOpenMatches, - myAuthUser: __qb.myAuthUser, - myCellStates: __qb.myCellStates, - myGridEntities: __qb.myGridEntities, - myGrids: __qb.myGrids, - myMatchParticipants: __qb.myMatchParticipants, - myMatches: __qb.myMatches, - myPlayerUnits: __qb.myPlayerUnits, - auth: { - myAuthUser: __qb["auth.my_auth_user"], - rateLimit: { - rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], - adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], - }, - }, -} as const; - -/** The reducers available in this remote SpacetimeDB module. */ -const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); -export const reducers = { - linkConnection: __reducerAccessors.linkConnection, - revokeMySession: __reducerAccessors.revokeMySession, - revokeSession: __reducerAccessors.revokeSession, - setAuthConfig: __reducerAccessors.setAuthConfig, - unlinkConnection: __reducerAccessors.unlinkConnection, - updateProfile: __reducerAccessors.updateProfile, - auth: { - linkConnection: __reducerAccessors["auth.linkConnection"], - revokeMySession: __reducerAccessors["auth.revokeMySession"], - revokeSession: __reducerAccessors["auth.revokeSession"], - setAuthConfig: __reducerAccessors["auth.setAuthConfig"], - unlinkConnection: __reducerAccessors["auth.unlinkConnection"], - updateProfile: __reducerAccessors["auth.updateProfile"], - rateLimit: { - addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], - resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], - updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], - }, - }, -} as const; - -/** The procedures available in this remote SpacetimeDB module. */ -const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); -export const procedures = { - aiTakeTurn: __procedureAccessors.aiTakeTurn, - attackUnit: __procedureAccessors.attackUnit, - createMatch: __procedureAccessors.createMatch, - endTurn: __procedureAccessors.endTurn, - getAuthPublicKey: __procedureAccessors.getAuthPublicKey, - getCellsInRange: __procedureAccessors.getCellsInRange, - joinMatch: __procedureAccessors.joinMatch, - listMySessions: __procedureAccessors.listMySessions, - moveUnit: __procedureAccessors.moveUnit, - whoami: __procedureAccessors.whoami, - auth: { - getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], - listMySessions: __procedureAccessors["auth.listMySessions"], - whoami: __procedureAccessors["auth.whoami"], - rateLimit: { - consume: __procedureAccessors["auth.rateLimit.consume"], - runSweep: __procedureAccessors["auth.rateLimit.runSweep"], - }, - }, -} as const; - -/** The context type returned in callbacks for all possible events. */ -export type EventContext = __EventContextInterface; -/** The context type returned in callbacks for reducer events. */ -export type ReducerEventContext = __ReducerEventContextInterface; -/** The context type returned in callbacks for subscription events. */ -export type SubscriptionEventContext = __SubscriptionEventContextInterface; -/** The context type returned in callbacks for error events. */ -export type ErrorContext = __ErrorContextInterface; -/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ -export type SubscriptionHandle = __SubscriptionHandleImpl; - -/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ -export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} - -/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ -export class DbConnectionBuilder extends __DbConnectionBuilder {} - -/** 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 { - /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ - static builder = (): DbConnectionBuilder => { - return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); - }; - - /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ - override subscriptionBuilder = (): SubscriptionBuilder => { - return new SubscriptionBuilder(this); - }; -} - diff --git a/spacetime-grid-ts/example/src/codegen/app/join_match_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/join_match_procedure.ts deleted file mode 100644 index c1f25720383..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/join_match_procedure.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 const params = { - matchId: __t.u64(), -}; -export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/link_connection_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/link_connection_reducer.ts deleted file mode 100644 index da04a554a3b..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/link_connection_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionToken: __t.string(), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/list_my_sessions_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/list_my_sessions_procedure.ts deleted file mode 100644 index 0fff293b69e..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/list_my_sessions_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - MySessions, -} from "./types"; - -export const params = { -}; -export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/lobby_open_matches_table.ts b/spacetime-grid-ts/example/src/codegen/app/lobby_open_matches_table.ts deleted file mode 100644 index 96bdadeae1c..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/lobby_open_matches_table.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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({ - matchId: __t.u64().name("match_id"), - hostUserId: __t.string().name("host_user_id"), - createdAt: __t.timestamp().name("created_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/move_unit_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/move_unit_procedure.ts deleted file mode 100644 index 75f2e273fb6..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/move_unit_procedure.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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 { - MoveUnitResult, -} from "./types"; - -export const params = { - entityId: __t.u64(), - toX: __t.i32(), - toY: __t.i32(), -}; -export const returnType = MoveUnitResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/codegen/app/my_auth_user_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_auth_user_table.ts deleted file mode 100644 index 5966b094061..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/my_auth_user_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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({ - userId: __t.string().name("user_id"), - email: __t.string(), - emailVerified: __t.bool().name("email_verified"), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_cell_states_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_cell_states_table.ts deleted file mode 100644 index 9f1a3bfc694..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/my_cell_states_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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().primaryKey(), - gridId: __t.u64().name("grid_id"), - x: __t.i32(), - y: __t.i32(), - cost: __t.i32(), - terrain: __t.option(__t.string()), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_grid_entities_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_grid_entities_table.ts deleted file mode 100644 index 1b75cfb7ea0..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/my_grid_entities_table.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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().primaryKey(), - gridId: __t.u64().name("grid_id"), - ownerUserId: __t.string().name("owner_user_id"), - x: __t.i32(), - y: __t.i32(), - kind: __t.string(), - blocksMovement: __t.bool().name("blocks_movement"), - label: __t.option(__t.string()), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_grids_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_grids_table.ts deleted file mode 100644 index ba03c161ea6..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/my_grids_table.ts +++ /dev/null @@ -1,26 +0,0 @@ -// 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().primaryKey(), - ownerUserId: __t.string().name("owner_user_id"), - name: __t.string(), - kind: __t.string(), - orientation: __t.string(), - width: __t.i32(), - height: __t.i32(), - defaultCost: __t.i32().name("default_cost"), - connectivity: __t.i32(), - mode: __t.string(), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_match_participants_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_match_participants_table.ts deleted file mode 100644 index 2a912a235b9..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/my_match_participants_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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().primaryKey(), - matchId: __t.u64().name("match_id"), - userId: __t.string().name("user_id"), - seatIdx: __t.i32().name("seat_idx"), - team: __t.i32(), - joinedAt: __t.timestamp().name("joined_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_matches_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_matches_table.ts deleted file mode 100644 index ca03504bff9..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/my_matches_table.ts +++ /dev/null @@ -1,28 +0,0 @@ -// 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 { - MatchStatus, -} from "./types"; - - -export default __t.row({ - matchId: __t.u64().primaryKey().name("match_id"), - get status() { - return MatchStatus; - }, - currentSeatIdx: __t.i32().name("current_seat_idx"), - turnNumber: __t.i32().name("turn_number"), - winnerUserId: __t.option(__t.string()).name("winner_user_id"), - gridId: __t.u64().name("grid_id"), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/my_player_units_table.ts b/spacetime-grid-ts/example/src/codegen/app/my_player_units_table.ts deleted file mode 100644 index fd18c44eb92..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/my_player_units_table.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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({ - entityId: __t.u64().primaryKey().name("entity_id"), - matchId: __t.u64().name("match_id"), - ownerUserId: __t.string().name("owner_user_id"), - typeId: __t.string().name("type_id"), - currentHp: __t.i32().name("current_hp"), - hasMoved: __t.bool().name("has_moved"), - hasAttacked: __t.bool().name("has_attacked"), - createdAt: __t.timestamp().name("created_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/npc_actor_table.ts b/spacetime-grid-ts/example/src/codegen/app/npc_actor_table.ts deleted file mode 100644 index c4dd4173c26..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/npc_actor_table.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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({ - actorId: __t.string().primaryKey().name("actor_id"), - name: __t.string(), - image: __t.option(__t.string()), - createdAt: __t.timestamp().name("created_at"), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/revoke_my_session_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/revoke_my_session_reducer.ts deleted file mode 100644 index 66f95f66b3f..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/revoke_my_session_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionId: __t.string(), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/revoke_session_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/revoke_session_reducer.ts deleted file mode 100644 index 66f95f66b3f..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/revoke_session_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionId: __t.string(), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/set_auth_config_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/set_auth_config_reducer.ts deleted file mode 100644 index 790dfa70b07..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/set_auth_config_reducer.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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 { - issuerUrl: __t.string(), - baseUrl: __t.option(__t.string()), - cookieName: __t.option(__t.string()), - sessionTtlSeconds: __t.option(__t.u64()), - es256PrivateKeyPem: __t.option(__t.string()), - googleClientId: __t.option(__t.string()), - googleClientSecret: __t.option(__t.string()), - githubClientId: __t.option(__t.string()), - githubClientSecret: __t.option(__t.string()), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/types.ts b/spacetime-grid-ts/example/src/codegen/app/types.ts deleted file mode 100644 index 4727e25df0a..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/types.ts +++ /dev/null @@ -1,276 +0,0 @@ -// 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 const ActorDirectory = __t.object("ActorDirectory", {}); -export type ActorDirectory = __Infer; - -export const ActorDirectoryRow = __t.object("ActorDirectoryRow", { - actorId: __t.string(), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - get kind() { - return ActorKind; - }, -}); -export type ActorDirectoryRow = __Infer; - -// The tagged union or sum type for the algebraic type `ActorKind`. -export const ActorKind = __t.enum("ActorKind", { - User: __t.unit(), - Npc: __t.unit(), -}); -export type ActorKind = __Infer; - -export const AiAttackInfo = __t.object("AiAttackInfo", { - targetId: __t.u64(), - damage: __t.i32(), - killed: __t.bool(), - targetX: __t.i32(), - targetY: __t.i32(), - targetOwner: __t.string(), - targetTypeId: __t.string(), - targetPreHp: __t.i32(), -}); -export type AiAttackInfo = __Infer; - -export const AiPathStep = __t.object("AiPathStep", { - x: __t.i32(), - y: __t.i32(), -}); -export type AiPathStep = __Infer; - -export const AiTakeTurnResult = __t.object("AiTakeTurnResult", { - get events() { - return __t.array(AiTurnEvent); - }, -}); -export type AiTakeTurnResult = __Infer; - -export const AiTurnEvent = __t.object("AiTurnEvent", { - entityId: __t.u64(), - get movePath() { - return __t.option(__t.array(AiPathStep)); - }, - get attack() { - return __t.option(AiAttackInfo); - }, -}); -export type AiTurnEvent = __Infer; - -export const AuthPubKey = __t.object("AuthPubKey", { - publicKeyPem: __t.string(), - keyId: __t.string(), - issuerUrl: __t.string(), -}); -export type AuthPubKey = __Infer; - -export const CellState = __t.object("CellState", { - id: __t.u64(), - gridId: __t.u64(), - x: __t.i32(), - y: __t.i32(), - cost: __t.i32(), - terrain: __t.option(__t.string()), -}); -export type CellState = __Infer; - -export const CellsInRangeResult = __t.object("CellsInRangeResult", { - get cells() { - return __t.array(ReachableCell); - }, -}); -export type CellsInRangeResult = __Infer; - -export const CreateMatchResult = __t.object("CreateMatchResult", { - matchId: __t.u64(), - gridId: __t.u64(), -}); -export type CreateMatchResult = __Infer; - -export const Grid = __t.object("Grid", { - id: __t.u64(), - ownerUserId: __t.string(), - name: __t.string(), - kind: __t.string(), - orientation: __t.string(), - width: __t.i32(), - height: __t.i32(), - defaultCost: __t.i32(), - connectivity: __t.i32(), - mode: __t.string(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type Grid = __Infer; - -export const GridAuthUser = __t.object("GridAuthUser", { - userId: __t.string(), - email: __t.string(), - emailVerified: __t.bool(), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type GridAuthUser = __Infer; - -export const GridEntity = __t.object("GridEntity", { - id: __t.u64(), - gridId: __t.u64(), - ownerUserId: __t.string(), - x: __t.i32(), - y: __t.i32(), - kind: __t.string(), - blocksMovement: __t.bool(), - label: __t.option(__t.string()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type GridEntity = __Infer; - -export const LobbyOpenMatch = __t.object("LobbyOpenMatch", { - matchId: __t.u64(), - hostUserId: __t.string(), - createdAt: __t.timestamp(), -}); -export type LobbyOpenMatch = __Infer; - -export const LobbyOpenMatches = __t.object("LobbyOpenMatches", {}); -export type LobbyOpenMatches = __Infer; - -export const Match = __t.object("Match", { - matchId: __t.u64(), - get status() { - return MatchStatus; - }, - currentSeatIdx: __t.i32(), - turnNumber: __t.i32(), - winnerUserId: __t.option(__t.string()), - gridId: __t.u64(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type Match = __Infer; - -export const MatchParticipant = __t.object("MatchParticipant", { - id: __t.u64(), - matchId: __t.u64(), - userId: __t.string(), - seatIdx: __t.i32(), - team: __t.i32(), - joinedAt: __t.timestamp(), -}); -export type MatchParticipant = __Infer; - -// The tagged union or sum type for the algebraic type `MatchStatus`. -export const MatchStatus = __t.enum("MatchStatus", { - Waiting: __t.unit(), - Active: __t.unit(), - Ended: __t.unit(), -}); -export type MatchStatus = __Infer; - -export const MoveStep = __t.object("MoveStep", { - x: __t.i32(), - y: __t.i32(), -}); -export type MoveStep = __Infer; - -export const MoveUnitResult = __t.object("MoveUnitResult", { - get path() { - return __t.array(MoveStep); - }, -}); -export type MoveUnitResult = __Infer; - -export const MyAuthUser = __t.object("MyAuthUser", {}); -export type MyAuthUser = __Infer; - -export const MyCellStates = __t.object("MyCellStates", {}); -export type MyCellStates = __Infer; - -export const MyGridEntities = __t.object("MyGridEntities", {}); -export type MyGridEntities = __Infer; - -export const MyGrids = __t.object("MyGrids", {}); -export type MyGrids = __Infer; - -export const MyMatchParticipants = __t.object("MyMatchParticipants", {}); -export type MyMatchParticipants = __Infer; - -export const MyMatches = __t.object("MyMatches", {}); -export type MyMatches = __Infer; - -export const MyPlayerUnits = __t.object("MyPlayerUnits", {}); -export type MyPlayerUnits = __Infer; - -export const MySession = __t.object("MySession", { - sessionId: __t.string(), - expiresAt: __t.timestamp(), - createdAt: __t.timestamp(), - ipAddress: __t.option(__t.string()), - userAgent: __t.option(__t.string()), - isCurrent: __t.bool(), -}); -export type MySession = __Infer; - -export const MySessions = __t.object("MySessions", { - get sessions() { - return __t.array(MySession); - }, -}); -export type MySessions = __Infer; - -export const NpcActor = __t.object("NpcActor", { - actorId: __t.string(), - name: __t.string(), - image: __t.option(__t.string()), - createdAt: __t.timestamp(), -}); -export type NpcActor = __Infer; - -export const PlayerUnit = __t.object("PlayerUnit", { - entityId: __t.u64(), - matchId: __t.u64(), - ownerUserId: __t.string(), - typeId: __t.string(), - currentHp: __t.i32(), - hasMoved: __t.bool(), - hasAttacked: __t.bool(), - createdAt: __t.timestamp(), -}); -export type PlayerUnit = __Infer; - -export const ReachableCell = __t.object("ReachableCell", { - x: __t.i32(), - y: __t.i32(), - cost: __t.i32(), -}); -export type ReachableCell = __Infer; - -export const UnitType = __t.object("UnitType", { - typeId: __t.string(), - name: __t.string(), - movement: __t.i32(), - attackRange: __t.i32(), - attackDmg: __t.i32(), - hp: __t.i32(), - glyph: __t.string(), -}); -export type UnitType = __Infer; - -export const WhoAmI = __t.object("WhoAmI", { - userId: __t.option(__t.string()), - senderIdentityHex: __t.string(), -}); -export type WhoAmI = __Infer; - diff --git a/spacetime-grid-ts/example/src/codegen/app/types/procedures.ts b/spacetime-grid-ts/example/src/codegen/app/types/procedures.ts deleted file mode 100644 index ec53ed8c107..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/types/procedures.ts +++ /dev/null @@ -1,40 +0,0 @@ -// 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 AiTakeTurnProcedure from "../ai_take_turn_procedure"; -import * as AttackUnitProcedure from "../attack_unit_procedure"; -import * as CreateMatchProcedure from "../create_match_procedure"; -import * as EndTurnProcedure from "../end_turn_procedure"; -import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; -import * as GetCellsInRangeProcedure from "../get_cells_in_range_procedure"; -import * as JoinMatchProcedure from "../join_match_procedure"; -import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; -import * as MoveUnitProcedure from "../move_unit_procedure"; -import * as WhoamiProcedure from "../whoami_procedure"; - -export type AiTakeTurnArgs = __Infer; -export type AiTakeTurnResult = __Infer; -export type AttackUnitArgs = __Infer; -export type AttackUnitResult = __Infer; -export type CreateMatchArgs = __Infer; -export type CreateMatchResult = __Infer; -export type EndTurnArgs = __Infer; -export type EndTurnResult = __Infer; -export type GetAuthPublicKeyArgs = __Infer; -export type GetAuthPublicKeyResult = __Infer; -export type GetCellsInRangeArgs = __Infer; -export type GetCellsInRangeResult = __Infer; -export type JoinMatchArgs = __Infer; -export type JoinMatchResult = __Infer; -export type ListMySessionsArgs = __Infer; -export type ListMySessionsResult = __Infer; -export type MoveUnitArgs = __Infer; -export type MoveUnitResult = __Infer; -export type WhoamiArgs = __Infer; -export type WhoamiResult = __Infer; - diff --git a/spacetime-grid-ts/example/src/codegen/app/types/reducers.ts b/spacetime-grid-ts/example/src/codegen/app/types/reducers.ts deleted file mode 100644 index db11ee2a71f..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/types/reducers.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 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 -import LinkConnectionReducer from "../link_connection_reducer"; -import RevokeMySessionReducer from "../revoke_my_session_reducer"; -import RevokeSessionReducer from "../revoke_session_reducer"; -import SetAuthConfigReducer from "../set_auth_config_reducer"; -import UnlinkConnectionReducer from "../unlink_connection_reducer"; -import UpdateProfileReducer from "../update_profile_reducer"; - -export type LinkConnectionParams = __Infer; -export type RevokeMySessionParams = __Infer; -export type RevokeSessionParams = __Infer; -export type SetAuthConfigParams = __Infer; -export type UnlinkConnectionParams = __Infer; -export type UpdateProfileParams = __Infer; - diff --git a/spacetime-grid-ts/example/src/codegen/app/unit_type_table.ts b/spacetime-grid-ts/example/src/codegen/app/unit_type_table.ts deleted file mode 100644 index 7cdd49c6677..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/unit_type_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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({ - typeId: __t.string().primaryKey().name("type_id"), - name: __t.string(), - movement: __t.i32(), - attackRange: __t.i32().name("attack_range"), - attackDmg: __t.i32().name("attack_dmg"), - hp: __t.i32(), - glyph: __t.string(), -}); diff --git a/spacetime-grid-ts/example/src/codegen/app/unlink_connection_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/unlink_connection_reducer.ts deleted file mode 100644 index e18fbc0a086..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/unlink_connection_reducer.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 {}; diff --git a/spacetime-grid-ts/example/src/codegen/app/update_profile_reducer.ts b/spacetime-grid-ts/example/src/codegen/app/update_profile_reducer.ts deleted file mode 100644 index f940573d72c..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/update_profile_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - name: __t.option(__t.string()), - image: __t.option(__t.string()), -}; diff --git a/spacetime-grid-ts/example/src/codegen/app/whoami_procedure.ts b/spacetime-grid-ts/example/src/codegen/app/whoami_procedure.ts deleted file mode 100644 index fb2b14ac8d7..00000000000 --- a/spacetime-grid-ts/example/src/codegen/app/whoami_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - WhoAmI, -} from "./types"; - -export const params = { -}; -export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-lobby-ts/example/.gitignore b/spacetime-lobby-ts/example/.gitignore deleted file mode 100644 index b14dc6d3e62..00000000000 --- a/spacetime-lobby-ts/example/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -public/app.js -public/app.js.map -src/codegen -*.log -.env diff --git a/spacetime-posthog-ts/example/.gitignore b/spacetime-posthog-ts/example/.gitignore deleted file mode 100644 index 1232c4118a7..00000000000 --- a/spacetime-posthog-ts/example/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules -public/app.js -public/app.js.map -src/codegen -*.log -.env -.stdb-server-token diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts deleted file mode 100644 index c32c6231beb..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/get_auth_public_key_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - AuthPubKey, -} from "./types"; - -export const params = { -}; -export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/link_connection_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/link_connection_reducer.ts deleted file mode 100644 index da04a554a3b..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/link_connection_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionToken: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts deleted file mode 100644 index 0fff293b69e..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/list_my_sessions_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - MySessions, -} from "./types"; - -export const params = { -}; -export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/my_auth_user_table.ts b/spacetime-presence-ts/example/src/codegen/app/auth/my_auth_user_table.ts deleted file mode 100644 index 6573c3fe132..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/my_auth_user_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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({ - userId: __t.string().primaryKey().name("user_id"), - email: __t.string(), - emailVerified: __t.bool().name("email_verified"), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts deleted file mode 100644 index e39846ca8d9..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/add_rate_limit_admin_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - identity: __t.identity(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts deleted file mode 100644 index 189f539a043..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/admin_rate_limit_buckets_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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({ - key: __t.string().primaryKey(), - scope: __t.string(), - windowStart: __t.timestamp().name("window_start"), - expiresAt: __t.timestamp().name("expires_at"), - count: __t.u32(), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts deleted file mode 100644 index a98b8588aad..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/consume_procedure.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 { - RateLimitConsumeResult, -} from "./types"; - -export const params = { - scope: __t.string(), - actorKey: __t.string(), - limit: __t.u32(), - windowSeconds: __t.u32(), - cost: __t.option(__t.u32()), -}; -export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts deleted file mode 100644 index 66ffe86e399..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/rate_limit_config_table.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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({ - singleton: __t.bool().primaryKey(), - sweepBatch: __t.u32().name("sweep_batch"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts deleted file mode 100644 index a7c5cc5274f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/reset_buckets_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - maxRows: __t.option(__t.u32()), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts deleted file mode 100644 index 9815c99eb38..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/run_sweep_procedure.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 const params = { - maxRows: __t.option(__t.u32()), -}; -export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/types.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/types.ts deleted file mode 100644 index 151a90e827f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); -export type AdminRateLimitBuckets = __Infer; - -export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { - identity: __t.identity(), - addedAtMicros: __t.i64(), -}); -export type RateLimitAdminIdentity = __Infer; - -export const RateLimitBucket = __t.object("RateLimitBucket", { - key: __t.string(), - scope: __t.string(), - windowStart: __t.timestamp(), - expiresAt: __t.timestamp(), - count: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type RateLimitBucket = __Infer; - -export const RateLimitConfig = __t.object("RateLimitConfig", { - singleton: __t.bool(), - sweepBatch: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type RateLimitConfig = __Infer; - -export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { - allowed: __t.bool(), - scope: __t.string(), - key: __t.string(), - limit: __t.u32(), - used: __t.u32(), - remaining: __t.u32(), - retryAfterSeconds: __t.u32(), - resetAt: __t.timestamp(), -}); -export type RateLimitConsumeResult = __Infer; - -export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), -}); -export type RateLimitSweepTick = __Infer; - diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts deleted file mode 100644 index 54fcf361af1..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/rateLimit/update_config_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sweepBatch: __t.u32(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts deleted file mode 100644 index 66f95f66b3f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/revoke_my_session_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionId: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/revoke_session_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/revoke_session_reducer.ts deleted file mode 100644 index 66f95f66b3f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/revoke_session_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionId: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts deleted file mode 100644 index 790dfa70b07..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/set_auth_config_reducer.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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 { - issuerUrl: __t.string(), - baseUrl: __t.option(__t.string()), - cookieName: __t.option(__t.string()), - sessionTtlSeconds: __t.option(__t.u64()), - es256PrivateKeyPem: __t.option(__t.string()), - googleClientId: __t.option(__t.string()), - googleClientSecret: __t.option(__t.string()), - githubClientId: __t.option(__t.string()), - githubClientSecret: __t.option(__t.string()), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/types.ts b/spacetime-presence-ts/example/src/codegen/app/auth/types.ts deleted file mode 100644 index 8df99e3d1b4..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/types.ts +++ /dev/null @@ -1,137 +0,0 @@ -// 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 const AuthAccount = __t.object("AuthAccount", { - accountId: __t.string(), - userId: __t.string(), - providerId: __t.string(), - providerAccountId: __t.string(), - passwordHash: __t.option(__t.string()), - accessToken: __t.option(__t.string()), - refreshToken: __t.option(__t.string()), - accessTokenExpiresAt: __t.option(__t.timestamp()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type AuthAccount = __Infer; - -export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { - identity: __t.identity(), - addedAtMicros: __t.i64(), -}); -export type AuthAdminIdentity = __Infer; - -export const AuthConfig = __t.object("AuthConfig", { - singleton: __t.bool(), - issuerUrl: __t.string(), - baseUrl: __t.string(), - cookieName: __t.string(), - sessionTtlSeconds: __t.u64(), - es256PrivateKeyPem: __t.string(), - es256PublicKeyPem: __t.string(), - keyId: __t.string(), - googleClientId: __t.option(__t.string()), - googleClientSecret: __t.option(__t.string()), - githubClientId: __t.option(__t.string()), - githubClientSecret: __t.option(__t.string()), - updatedAt: __t.timestamp(), -}); -export type AuthConfig = __Infer; - -export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { - stdbIdentity: __t.identity(), - userId: __t.string(), - linkedAt: __t.timestamp(), -}); -export type AuthConnectionBinding = __Infer; - -export const AuthOauthState = __t.object("AuthOauthState", { - state: __t.string(), - provider: __t.string(), - codeVerifier: __t.string(), - redirectTo: __t.string(), - expiresAt: __t.timestamp(), - createdAt: __t.timestamp(), -}); -export type AuthOauthState = __Infer; - -export const AuthPubKey = __t.object("AuthPubKey", { - publicKeyPem: __t.string(), - keyId: __t.string(), - issuerUrl: __t.string(), -}); -export type AuthPubKey = __Infer; - -export const AuthSession = __t.object("AuthSession", { - sessionId: __t.string(), - userId: __t.string(), - token: __t.string(), - expiresAt: __t.timestamp(), - ipAddress: __t.option(__t.string()), - userAgent: __t.option(__t.string()), - createdAt: __t.timestamp(), -}); -export type AuthSession = __Infer; - -export const AuthSweeperTick = __t.object("AuthSweeperTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), -}); -export type AuthSweeperTick = __Infer; - -export const AuthUser = __t.object("AuthUser", { - userId: __t.string(), - email: __t.string(), - emailVerified: __t.bool(), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type AuthUser = __Infer; - -export const AuthVerification = __t.object("AuthVerification", { - verificationId: __t.string(), - identifier: __t.string(), - value: __t.string(), - purpose: __t.string(), - expiresAt: __t.timestamp(), - createdAt: __t.timestamp(), -}); -export type AuthVerification = __Infer; - -export const MyAuthUser = __t.object("MyAuthUser", {}); -export type MyAuthUser = __Infer; - -export const MySession = __t.object("MySession", { - sessionId: __t.string(), - expiresAt: __t.timestamp(), - createdAt: __t.timestamp(), - ipAddress: __t.option(__t.string()), - userAgent: __t.option(__t.string()), - isCurrent: __t.bool(), -}); -export type MySession = __Infer; - -export const MySessions = __t.object("MySessions", { - get sessions() { - return __t.array(MySession); - }, -}); -export type MySessions = __Infer; - -export const WhoAmI = __t.object("WhoAmI", { - userId: __t.option(__t.string()), - senderIdentityHex: __t.string(), -}); -export type WhoAmI = __Infer; - diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts deleted file mode 100644 index e18fbc0a086..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/unlink_connection_reducer.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 {}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/update_profile_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/auth/update_profile_reducer.ts deleted file mode 100644 index f940573d72c..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/update_profile_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - name: __t.option(__t.string()), - image: __t.option(__t.string()), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/auth/whoami_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/auth/whoami_procedure.ts deleted file mode 100644 index fb2b14ac8d7..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/auth/whoami_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - WhoAmI, -} from "./types"; - -export const params = { -}; -export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/create_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/create_room_reducer.ts deleted file mode 100644 index 1382e2f73fb..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/create_room_reducer.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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 { - serverId: __t.u64(), - name: __t.string(), - isPrivate: __t.bool(), - category: __t.option(__t.string()), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/create_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/create_server_reducer.ts deleted file mode 100644 index ce493ee8574..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/create_server_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - name: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/delete_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/delete_message_reducer.ts deleted file mode 100644 index 104809a301c..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/delete_message_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - messageId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/delete_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/delete_room_reducer.ts deleted file mode 100644 index 80a9f7e20dd..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/delete_room_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - roomId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/delete_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/delete_server_reducer.ts deleted file mode 100644 index 8cee31ec781..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/delete_server_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - serverId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/delete_thread_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/delete_thread_message_reducer.ts deleted file mode 100644 index fa89973d643..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/delete_thread_message_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - threadMessageId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/edit_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/edit_message_reducer.ts deleted file mode 100644 index 57eaa7d8f74..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/edit_message_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - messageId: __t.u64(), - content: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/edit_thread_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/edit_thread_message_reducer.ts deleted file mode 100644 index fe1bd145fa1..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/edit_thread_message_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - threadMessageId: __t.u64(), - content: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/files/types.ts b/spacetime-presence-ts/example/src/codegen/app/files/types.ts deleted file mode 100644 index a8336b9566f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/files/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -// 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 const File = __t.object("File", { - id: __t.u64(), - ownerPathKey: __t.string(), - path: __t.string(), - ownerUserId: __t.string(), - mimeType: __t.string(), - size: __t.u64(), - sha256Hex: __t.string(), - visibility: __t.string(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type File = __Infer; - -export const FileBlob = __t.object("FileBlob", { - fileId: __t.u64(), - bytes: __t.byteArray(), -}); -export type FileBlob = __Infer; - diff --git a/spacetime-presence-ts/example/src/codegen/app/get_attachment_file_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/get_attachment_file_procedure.ts deleted file mode 100644 index f365f3373db..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/get_attachment_file_procedure.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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 { - AttachmentFileResult, -} from "./types"; - -export const params = { - fileId: __t.u64(), -}; -export const returnType = AttachmentFileResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/get_auth_public_key_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/get_auth_public_key_procedure.ts deleted file mode 100644 index c32c6231beb..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/get_auth_public_key_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - AuthPubKey, -} from "./types"; - -export const params = { -}; -export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/heartbeat_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/heartbeat_reducer.ts deleted file mode 100644 index e18fbc0a086..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/heartbeat_reducer.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 {}; diff --git a/spacetime-presence-ts/example/src/codegen/app/index.ts b/spacetime-presence-ts/example/src/codegen/app/index.ts deleted file mode 100644 index 603a3478aac..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/index.ts +++ /dev/null @@ -1,488 +0,0 @@ -// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). - -/* 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 CreateRoomReducer from "./create_room_reducer"; -import CreateServerReducer from "./create_server_reducer"; -import DeleteMessageReducer from "./delete_message_reducer"; -import DeleteRoomReducer from "./delete_room_reducer"; -import DeleteServerReducer from "./delete_server_reducer"; -import DeleteThreadMessageReducer from "./delete_thread_message_reducer"; -import EditMessageReducer from "./edit_message_reducer"; -import EditThreadMessageReducer from "./edit_thread_message_reducer"; -import HeartbeatReducer from "./heartbeat_reducer"; -import JoinRoomReducer from "./join_room_reducer"; -import JoinServerReducer from "./join_server_reducer"; -import LeaveRoomReducer from "./leave_room_reducer"; -import LeaveServerReducer from "./leave_server_reducer"; -import LinkConnectionReducer from "./link_connection_reducer"; -import MarkRoomReadReducer from "./mark_room_read_reducer"; -import PinMessageReducer from "./pin_message_reducer"; -import RenameRoomReducer from "./rename_room_reducer"; -import RenameServerReducer from "./rename_server_reducer"; -import RevokeMySessionReducer from "./revoke_my_session_reducer"; -import RevokeSessionReducer from "./revoke_session_reducer"; -import SendMessageReducer from "./send_message_reducer"; -import SendThreadMessageReducer from "./send_thread_message_reducer"; -import SetAuthConfigReducer from "./set_auth_config_reducer"; -import SetDisplayNameReducer from "./set_display_name_reducer"; -import SetRoomCategoryReducer from "./set_room_category_reducer"; -import SetRoomPrivacyReducer from "./set_room_privacy_reducer"; -import SetStatusReducer from "./set_status_reducer"; -import StartTypingReducer from "./start_typing_reducer"; -import StopTypingReducer from "./stop_typing_reducer"; -import ToggleReactionReducer from "./toggle_reaction_reducer"; -import UnlinkConnectionReducer from "./unlink_connection_reducer"; -import UnpinMessageReducer from "./unpin_message_reducer"; -import UpdateProfileReducer from "./update_profile_reducer"; - -// Import all procedure arg schemas -import * as GetAttachmentFileProcedure from "./get_attachment_file_procedure"; -import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; -import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; -import * as SearchMessagesProcedure from "./search_messages_procedure"; -import * as WhoamiProcedure from "./whoami_procedure"; - -// Import all table schema definitions -import MyAuthUserRow from "./my_auth_user_table"; -import MyChatUsersRow from "./my_chat_users_table"; -import MyMessageThreadsRow from "./my_message_threads_table"; -import MyPresenceEntriesRow from "./my_presence_entries_table"; -import MyRateLimitStatusRow from "./my_rate_limit_status_table"; -import MyRoomAttachmentsRow from "./my_room_attachments_table"; -import MyRoomMembersRow from "./my_room_members_table"; -import MyRoomMessageReactionsRow from "./my_room_message_reactions_table"; -import MyRoomMessagesRow from "./my_room_messages_table"; -import MyRoomReadCursorsRow from "./my_room_read_cursors_table"; -import MyRoomsRow from "./my_rooms_table"; -import MyServerMembersRow from "./my_server_members_table"; -import MyServersRow from "./my_servers_table"; -import MyThreadMessagesRow from "./my_thread_messages_table"; - -// Import namespace table schema definitions -import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; -import RateLimit_RateLimitConfigRow from "./rateLimit/rate_limit_config_table"; -import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; -import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; -import RateLimit_AdminRateLimitBucketsRow from "./rateLimit/admin_rate_limit_buckets_table"; - -// Import namespace reducer arg schemas -import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; -import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; -import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; -import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; -import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; -import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; -import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; -import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; -import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; -import RateLimit_AddRateLimitAdminReducer from "./rateLimit/add_rate_limit_admin_reducer"; -import RateLimit_ResetBucketsReducer from "./rateLimit/reset_buckets_reducer"; -import RateLimit_UpdateConfigReducer from "./rateLimit/update_config_reducer"; - -// Import namespace procedure arg schemas -import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; -import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; -import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; -import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; -import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; -import * as RateLimit_ConsumeProcedure from "./rateLimit/consume_procedure"; -import * as RateLimit_RunSweepProcedure from "./rateLimit/run_sweep_procedure"; - -/** 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({ - myAuthUser: __table({ - name: 'my_auth_user', - indexes: [ - ], - constraints: [ - ], - }, MyAuthUserRow), - myChatUsers: __table({ - name: 'my_chat_users', - indexes: [ - ], - constraints: [ - ], - }, MyChatUsersRow), - myMessageThreads: __table({ - name: 'my_message_threads', - indexes: [ - ], - constraints: [ - ], - }, MyMessageThreadsRow), - myPresenceEntries: __table({ - name: 'my_presence_entries', - indexes: [ - ], - constraints: [ - ], - }, MyPresenceEntriesRow), - myRateLimitStatus: __table({ - name: 'my_rate_limit_status', - indexes: [ - ], - constraints: [ - ], - }, MyRateLimitStatusRow), - myRoomAttachments: __table({ - name: 'my_room_attachments', - indexes: [ - ], - constraints: [ - ], - }, MyRoomAttachmentsRow), - myRoomMembers: __table({ - name: 'my_room_members', - indexes: [ - ], - constraints: [ - ], - }, MyRoomMembersRow), - myRoomMessageReactions: __table({ - name: 'my_room_message_reactions', - indexes: [ - ], - constraints: [ - ], - }, MyRoomMessageReactionsRow), - myRoomMessages: __table({ - name: 'my_room_messages', - indexes: [ - ], - constraints: [ - ], - }, MyRoomMessagesRow), - myRoomReadCursors: __table({ - name: 'my_room_read_cursors', - indexes: [ - ], - constraints: [ - ], - }, MyRoomReadCursorsRow), - myRooms: __table({ - name: 'my_rooms', - indexes: [ - ], - constraints: [ - ], - }, MyRoomsRow), - myServerMembers: __table({ - name: 'my_server_members', - indexes: [ - ], - constraints: [ - ], - }, MyServerMembersRow), - myServers: __table({ - name: 'my_servers', - indexes: [ - ], - constraints: [ - ], - }, MyServersRow), - myThreadMessages: __table({ - name: 'my_thread_messages', - indexes: [ - ], - constraints: [ - ], - }, MyThreadMessagesRow), - "auth.rateLimit.rate_limit_config": __table({ - name: 'auth.rateLimit.rate_limit_config', - indexes: [ - { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ - 'singleton', - ] }, - ], - constraints: [ - { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, - ], - }, AuthRateLimit_RateLimitConfigRow), - "rateLimit.rate_limit_config": __table({ - name: 'rateLimit.rate_limit_config', - indexes: [ - { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ - 'singleton', - ] }, - ], - constraints: [ - { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, - ], - }, RateLimit_RateLimitConfigRow), - "auth.my_auth_user": __table({ - name: 'auth.my_auth_user', - indexes: [ - ], - constraints: [ - ], - }, Auth_MyAuthUserRow), - "auth.rateLimit.admin_rate_limit_buckets": __table({ - name: 'auth.rateLimit.admin_rate_limit_buckets', - indexes: [ - ], - constraints: [ - ], - }, AuthRateLimit_AdminRateLimitBucketsRow), - "rateLimit.admin_rate_limit_buckets": __table({ - name: 'rateLimit.admin_rate_limit_buckets', - indexes: [ - ], - constraints: [ - ], - }, RateLimit_AdminRateLimitBucketsRow), -}); - -/** 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( - __reducerSchema("create_room", CreateRoomReducer), - __reducerSchema("create_server", CreateServerReducer), - __reducerSchema("delete_message", DeleteMessageReducer), - __reducerSchema("delete_room", DeleteRoomReducer), - __reducerSchema("delete_server", DeleteServerReducer), - __reducerSchema("delete_thread_message", DeleteThreadMessageReducer), - __reducerSchema("edit_message", EditMessageReducer), - __reducerSchema("edit_thread_message", EditThreadMessageReducer), - __reducerSchema("heartbeat", HeartbeatReducer), - __reducerSchema("join_room", JoinRoomReducer), - __reducerSchema("join_server", JoinServerReducer), - __reducerSchema("leave_room", LeaveRoomReducer), - __reducerSchema("leave_server", LeaveServerReducer), - __reducerSchema("link_connection", LinkConnectionReducer), - __reducerSchema("mark_room_read", MarkRoomReadReducer), - __reducerSchema("pin_message", PinMessageReducer), - __reducerSchema("rename_room", RenameRoomReducer), - __reducerSchema("rename_server", RenameServerReducer), - __reducerSchema("revoke_my_session", RevokeMySessionReducer), - __reducerSchema("revoke_session", RevokeSessionReducer), - __reducerSchema("send_message", SendMessageReducer), - __reducerSchema("send_thread_message", SendThreadMessageReducer), - __reducerSchema("set_auth_config", SetAuthConfigReducer), - __reducerSchema("set_display_name", SetDisplayNameReducer), - __reducerSchema("set_room_category", SetRoomCategoryReducer), - __reducerSchema("set_room_privacy", SetRoomPrivacyReducer), - __reducerSchema("set_status", SetStatusReducer), - __reducerSchema("start_typing", StartTypingReducer), - __reducerSchema("stop_typing", StopTypingReducer), - __reducerSchema("toggle_reaction", ToggleReactionReducer), - __reducerSchema("unlink_connection", UnlinkConnectionReducer), - __reducerSchema("unpin_message", UnpinMessageReducer), - __reducerSchema("update_profile", UpdateProfileReducer), - __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), - __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), - __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), - __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), - __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), - __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), - __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), - __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), - __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), - __reducerSchema("rateLimit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer), - __reducerSchema("rateLimit.reset_buckets", RateLimit_ResetBucketsReducer), - __reducerSchema("rateLimit.update_config", RateLimit_UpdateConfigReducer), -); - -/** 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("get_attachment_file", GetAttachmentFileProcedure.params, GetAttachmentFileProcedure.returnType), - __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), - __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), - __procedureSchema("search_messages", SearchMessagesProcedure.params, SearchMessagesProcedure.returnType), - __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), - __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), - __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), - __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), - __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), - __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), - __procedureSchema("rateLimit.consume", RateLimit_ConsumeProcedure.params, RateLimit_ConsumeProcedure.returnType), - __procedureSchema("rateLimit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType), -); - -/** The remote SpacetimeDB module schema, both runtime and type information. */ -const REMOTE_MODULE = { - versionInfo: { - cliVersion: "2.8.3" 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. */ -const __qb = __makeQueryBuilder(tablesSchema.schemaType); -export const tables = { - myAuthUser: __qb.myAuthUser, - myChatUsers: __qb.myChatUsers, - myMessageThreads: __qb.myMessageThreads, - myPresenceEntries: __qb.myPresenceEntries, - myRateLimitStatus: __qb.myRateLimitStatus, - myRoomAttachments: __qb.myRoomAttachments, - myRoomMembers: __qb.myRoomMembers, - myRoomMessageReactions: __qb.myRoomMessageReactions, - myRoomMessages: __qb.myRoomMessages, - myRoomReadCursors: __qb.myRoomReadCursors, - myRooms: __qb.myRooms, - myServerMembers: __qb.myServerMembers, - myServers: __qb.myServers, - myThreadMessages: __qb.myThreadMessages, - auth: { - myAuthUser: __qb["auth.my_auth_user"], - rateLimit: { - rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], - adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], - }, - }, - rateLimit: { - rateLimitConfig: __qb["rateLimit.rate_limit_config"], - adminRateLimitBuckets: __qb["rateLimit.admin_rate_limit_buckets"], - }, -} as const; - -/** The reducers available in this remote SpacetimeDB module. */ -const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); -export const reducers = { - createRoom: __reducerAccessors.createRoom, - createServer: __reducerAccessors.createServer, - deleteMessage: __reducerAccessors.deleteMessage, - deleteRoom: __reducerAccessors.deleteRoom, - deleteServer: __reducerAccessors.deleteServer, - deleteThreadMessage: __reducerAccessors.deleteThreadMessage, - editMessage: __reducerAccessors.editMessage, - editThreadMessage: __reducerAccessors.editThreadMessage, - heartbeat: __reducerAccessors.heartbeat, - joinRoom: __reducerAccessors.joinRoom, - joinServer: __reducerAccessors.joinServer, - leaveRoom: __reducerAccessors.leaveRoom, - leaveServer: __reducerAccessors.leaveServer, - linkConnection: __reducerAccessors.linkConnection, - markRoomRead: __reducerAccessors.markRoomRead, - pinMessage: __reducerAccessors.pinMessage, - renameRoom: __reducerAccessors.renameRoom, - renameServer: __reducerAccessors.renameServer, - revokeMySession: __reducerAccessors.revokeMySession, - revokeSession: __reducerAccessors.revokeSession, - sendMessage: __reducerAccessors.sendMessage, - sendThreadMessage: __reducerAccessors.sendThreadMessage, - setAuthConfig: __reducerAccessors.setAuthConfig, - setDisplayName: __reducerAccessors.setDisplayName, - setRoomCategory: __reducerAccessors.setRoomCategory, - setRoomPrivacy: __reducerAccessors.setRoomPrivacy, - setStatus: __reducerAccessors.setStatus, - startTyping: __reducerAccessors.startTyping, - stopTyping: __reducerAccessors.stopTyping, - toggleReaction: __reducerAccessors.toggleReaction, - unlinkConnection: __reducerAccessors.unlinkConnection, - unpinMessage: __reducerAccessors.unpinMessage, - updateProfile: __reducerAccessors.updateProfile, - auth: { - linkConnection: __reducerAccessors["auth.linkConnection"], - revokeMySession: __reducerAccessors["auth.revokeMySession"], - revokeSession: __reducerAccessors["auth.revokeSession"], - setAuthConfig: __reducerAccessors["auth.setAuthConfig"], - unlinkConnection: __reducerAccessors["auth.unlinkConnection"], - updateProfile: __reducerAccessors["auth.updateProfile"], - rateLimit: { - addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], - resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], - updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], - }, - }, - rateLimit: { - addRateLimitAdmin: __reducerAccessors["rateLimit.addRateLimitAdmin"], - resetBuckets: __reducerAccessors["rateLimit.resetBuckets"], - updateConfig: __reducerAccessors["rateLimit.updateConfig"], - }, -} as const; - -/** The procedures available in this remote SpacetimeDB module. */ -const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); -export const procedures = { - getAttachmentFile: __procedureAccessors.getAttachmentFile, - getAuthPublicKey: __procedureAccessors.getAuthPublicKey, - listMySessions: __procedureAccessors.listMySessions, - searchMessages: __procedureAccessors.searchMessages, - whoami: __procedureAccessors.whoami, - auth: { - getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], - listMySessions: __procedureAccessors["auth.listMySessions"], - whoami: __procedureAccessors["auth.whoami"], - rateLimit: { - consume: __procedureAccessors["auth.rateLimit.consume"], - runSweep: __procedureAccessors["auth.rateLimit.runSweep"], - }, - }, - rateLimit: { - consume: __procedureAccessors["rateLimit.consume"], - runSweep: __procedureAccessors["rateLimit.runSweep"], - }, -} as const; - -/** The context type returned in callbacks for all possible events. */ -export type EventContext = __EventContextInterface; -/** The context type returned in callbacks for reducer events. */ -export type ReducerEventContext = __ReducerEventContextInterface; -/** The context type returned in callbacks for subscription events. */ -export type SubscriptionEventContext = __SubscriptionEventContextInterface; -/** The context type returned in callbacks for error events. */ -export type ErrorContext = __ErrorContextInterface; -/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ -export type SubscriptionHandle = __SubscriptionHandleImpl; - -/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ -export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} - -/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ -export class DbConnectionBuilder extends __DbConnectionBuilder {} - -/** 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 { - /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ - static builder = (): DbConnectionBuilder => { - return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); - }; - - /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ - override subscriptionBuilder = (): SubscriptionBuilder => { - return new SubscriptionBuilder(this); - }; -} - diff --git a/spacetime-presence-ts/example/src/codegen/app/join_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/join_room_reducer.ts deleted file mode 100644 index 80a9f7e20dd..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/join_room_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - roomId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/join_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/join_server_reducer.ts deleted file mode 100644 index 8cee31ec781..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/join_server_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - serverId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/leave_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/leave_room_reducer.ts deleted file mode 100644 index 80a9f7e20dd..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/leave_room_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - roomId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/leave_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/leave_server_reducer.ts deleted file mode 100644 index 8cee31ec781..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/leave_server_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - serverId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/link_connection_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/link_connection_reducer.ts deleted file mode 100644 index da04a554a3b..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/link_connection_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionToken: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/list_my_sessions_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/list_my_sessions_procedure.ts deleted file mode 100644 index 0fff293b69e..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/list_my_sessions_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - MySessions, -} from "./types"; - -export const params = { -}; -export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/mark_room_read_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/mark_room_read_reducer.ts deleted file mode 100644 index 80a9f7e20dd..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/mark_room_read_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - roomId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/my_auth_user_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_auth_user_table.ts deleted file mode 100644 index 5966b094061..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_auth_user_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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({ - userId: __t.string().name("user_id"), - email: __t.string(), - emailVerified: __t.bool().name("email_verified"), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_chat_users_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_chat_users_table.ts deleted file mode 100644 index 10a1254c677..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_chat_users_table.ts +++ /dev/null @@ -1,27 +0,0 @@ -// 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 { - ChatUserStatus, -} from "./types"; - - -export default __t.row({ - identity: __t.identity().primaryKey(), - userId: __t.string().name("user_id"), - displayName: __t.string().name("display_name"), - get status() { - return ChatUserStatus; - }, - createdAt: __t.timestamp().name("created_at"), - lastActiveAt: __t.timestamp().name("last_active_at"), - lastMessageAt: __t.timestamp().name("last_message_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_message_threads_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_message_threads_table.ts deleted file mode 100644 index 5d3160c8709..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_message_threads_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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().primaryKey(), - rootMessageId: __t.u64().name("root_message_id"), - roomId: __t.u64().name("room_id"), - createdBy: __t.identity().name("created_by"), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_presence_entries_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_presence_entries_table.ts deleted file mode 100644 index 70af5d56d15..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_presence_entries_table.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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({ - key: __t.string().primaryKey(), - scope: __t.string(), - subject: __t.string(), - status: __t.string(), - activity: __t.option(__t.string()), - payloadJson: __t.option(__t.string()).name("payload_json"), - joinedAt: __t.timestamp().name("joined_at"), - lastSeenAt: __t.timestamp().name("last_seen_at"), - expiresAt: __t.timestamp().name("expires_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_rate_limit_status_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_rate_limit_status_table.ts deleted file mode 100644 index 00e9df8e608..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_rate_limit_status_table.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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({ - scope: __t.string(), - limit: __t.u32(), - used: __t.u32(), - remaining: __t.u32(), - resetAt: __t.timestamp().name("reset_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_attachments_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_attachments_table.ts deleted file mode 100644 index 8aa2b599cea..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_room_attachments_table.ts +++ /dev/null @@ -1,27 +0,0 @@ -// 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(), - messageId: __t.u64().name("message_id"), - fileId: __t.u64().name("file_id"), - ownerUserId: __t.string().name("owner_user_id"), - ordinal: __t.u32(), - filename: __t.option(__t.string()), - path: __t.string(), - mimeType: __t.string().name("mime_type"), - size: __t.u64(), - sha256Hex: __t.string().name("sha_256_hex"), - visibility: __t.string(), - createdAt: __t.timestamp().name("created_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_members_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_members_table.ts deleted file mode 100644 index 5f1878a592e..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_room_members_table.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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().primaryKey(), - roomId: __t.u64().name("room_id"), - userId: __t.string().name("user_id"), - role: __t.string(), - joinedAt: __t.timestamp().name("joined_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_message_reactions_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_message_reactions_table.ts deleted file mode 100644 index 2d86b514fed..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_room_message_reactions_table.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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().primaryKey(), - messageId: __t.u64().name("message_id"), - identity: __t.identity(), - emoji: __t.string(), - createdAt: __t.timestamp().name("created_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_messages_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_messages_table.ts deleted file mode 100644 index be6f0a662b9..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_room_messages_table.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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().primaryKey(), - roomId: __t.u64().name("room_id"), - author: __t.identity(), - content: __t.string(), - createdAt: __t.timestamp().name("created_at"), - editedAt: __t.option(__t.timestamp()).name("edited_at"), - replyToMessageId: __t.option(__t.u64()).name("reply_to_message_id"), - pinnedAt: __t.option(__t.timestamp()).name("pinned_at"), - pinnedBy: __t.option(__t.identity()).name("pinned_by"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_room_read_cursors_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_room_read_cursors_table.ts deleted file mode 100644 index d52cd7f1251..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_room_read_cursors_table.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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().primaryKey(), - roomId: __t.u64().name("room_id"), - identity: __t.identity(), - lastReadMessageId: __t.u64().name("last_read_message_id"), - lastReadAt: __t.timestamp().name("last_read_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_rooms_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_rooms_table.ts deleted file mode 100644 index a2a98c957a1..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_rooms_table.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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().primaryKey(), - serverId: __t.u64().name("server_id"), - name: __t.string(), - category: __t.option(__t.string()), - createdByUserId: __t.string().name("created_by_user_id"), - createdAt: __t.timestamp().name("created_at"), - isPrivate: __t.bool().name("is_private"), - activityLabel: __t.string().name("activity_label"), - activityScore: __t.u32().name("activity_score"), - lastActivityAt: __t.option(__t.timestamp()).name("last_activity_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_server_members_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_server_members_table.ts deleted file mode 100644 index 2cf02263ab3..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_server_members_table.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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().primaryKey(), - serverId: __t.u64().name("server_id"), - userId: __t.string().name("user_id"), - role: __t.string(), - joinedAt: __t.timestamp().name("joined_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_servers_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_servers_table.ts deleted file mode 100644 index bb9731a93c9..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_servers_table.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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().primaryKey(), - name: __t.string(), - createdByUserId: __t.string().name("created_by_user_id"), - createdAt: __t.timestamp().name("created_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/my_thread_messages_table.ts b/spacetime-presence-ts/example/src/codegen/app/my_thread_messages_table.ts deleted file mode 100644 index 07d885a44b7..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/my_thread_messages_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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().primaryKey(), - threadId: __t.u64().name("thread_id"), - author: __t.identity(), - content: __t.string(), - createdAt: __t.timestamp().name("created_at"), - editedAt: __t.option(__t.timestamp()).name("edited_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/pin_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/pin_message_reducer.ts deleted file mode 100644 index 104809a301c..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/pin_message_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - messageId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts deleted file mode 100644 index e39846ca8d9..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - identity: __t.identity(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts deleted file mode 100644 index 189f539a043..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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({ - key: __t.string().primaryKey(), - scope: __t.string(), - windowStart: __t.timestamp().name("window_start"), - expiresAt: __t.timestamp().name("expires_at"), - count: __t.u32(), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/consume_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/consume_procedure.ts deleted file mode 100644 index a98b8588aad..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rateLimit/consume_procedure.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 { - RateLimitConsumeResult, -} from "./types"; - -export const params = { - scope: __t.string(), - actorKey: __t.string(), - limit: __t.u32(), - windowSeconds: __t.u32(), - cost: __t.option(__t.u32()), -}; -export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts deleted file mode 100644 index 66ffe86e399..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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({ - singleton: __t.bool().primaryKey(), - sweepBatch: __t.u32().name("sweep_batch"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts deleted file mode 100644 index a7c5cc5274f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - maxRows: __t.option(__t.u32()), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts deleted file mode 100644 index 9815c99eb38..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 const params = { - maxRows: __t.option(__t.u32()), -}; -export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/types.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/types.ts deleted file mode 100644 index 151a90e827f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rateLimit/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); -export type AdminRateLimitBuckets = __Infer; - -export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { - identity: __t.identity(), - addedAtMicros: __t.i64(), -}); -export type RateLimitAdminIdentity = __Infer; - -export const RateLimitBucket = __t.object("RateLimitBucket", { - key: __t.string(), - scope: __t.string(), - windowStart: __t.timestamp(), - expiresAt: __t.timestamp(), - count: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type RateLimitBucket = __Infer; - -export const RateLimitConfig = __t.object("RateLimitConfig", { - singleton: __t.bool(), - sweepBatch: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type RateLimitConfig = __Infer; - -export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { - allowed: __t.bool(), - scope: __t.string(), - key: __t.string(), - limit: __t.u32(), - used: __t.u32(), - remaining: __t.u32(), - retryAfterSeconds: __t.u32(), - resetAt: __t.timestamp(), -}); -export type RateLimitConsumeResult = __Infer; - -export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), -}); -export type RateLimitSweepTick = __Infer; - diff --git a/spacetime-presence-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts deleted file mode 100644 index 54fcf361af1..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sweepBatch: __t.u32(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rename_room_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rename_room_reducer.ts deleted file mode 100644 index 8d0f5f1d2de..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rename_room_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - roomId: __t.u64(), - name: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/rename_server_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/rename_server_reducer.ts deleted file mode 100644 index 71d58e8cf73..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/rename_server_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - serverId: __t.u64(), - name: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/revoke_my_session_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/revoke_my_session_reducer.ts deleted file mode 100644 index 66f95f66b3f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/revoke_my_session_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionId: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/revoke_session_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/revoke_session_reducer.ts deleted file mode 100644 index 66f95f66b3f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/revoke_session_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sessionId: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/search_messages_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/search_messages_procedure.ts deleted file mode 100644 index 5226a56b306..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/search_messages_procedure.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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 { - Message, -} from "./types"; - -export const params = { - roomId: __t.u64(), - query: __t.string(), -}; -export const returnType = __t.array(Message) \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/codegen/app/send_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/send_message_reducer.ts deleted file mode 100644 index 37fa347fa30..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/send_message_reducer.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 { - AttachmentInput, -} from "./types"; - -export default { - roomId: __t.u64(), - content: __t.string(), - replyToMessageId: __t.option(__t.u64()), - get attachments() { - return __t.array(AttachmentInput); - }, -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/send_thread_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/send_thread_message_reducer.ts deleted file mode 100644 index f846302477c..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/send_thread_message_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - rootMessageId: __t.u64(), - content: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_auth_config_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_auth_config_reducer.ts deleted file mode 100644 index 790dfa70b07..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/set_auth_config_reducer.ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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 { - issuerUrl: __t.string(), - baseUrl: __t.option(__t.string()), - cookieName: __t.option(__t.string()), - sessionTtlSeconds: __t.option(__t.u64()), - es256PrivateKeyPem: __t.option(__t.string()), - googleClientId: __t.option(__t.string()), - googleClientSecret: __t.option(__t.string()), - githubClientId: __t.option(__t.string()), - githubClientSecret: __t.option(__t.string()), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_display_name_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_display_name_reducer.ts deleted file mode 100644 index 547493ef073..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/set_display_name_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - displayName: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_room_category_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_room_category_reducer.ts deleted file mode 100644 index 9feeae5be80..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/set_room_category_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - roomId: __t.u64(), - category: __t.option(__t.string()), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_room_privacy_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_room_privacy_reducer.ts deleted file mode 100644 index 15b49b76a1e..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/set_room_privacy_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - roomId: __t.u64(), - isPrivate: __t.bool(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/set_status_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/set_status_reducer.ts deleted file mode 100644 index 15a4e2758f8..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/set_status_reducer.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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 { - ChatUserStatus, -} from "./types"; - -export default { - get status() { - return ChatUserStatus; - }, -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/start_typing_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/start_typing_reducer.ts deleted file mode 100644 index 80a9f7e20dd..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/start_typing_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - roomId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/stop_typing_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/stop_typing_reducer.ts deleted file mode 100644 index 80a9f7e20dd..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/stop_typing_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - roomId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/toggle_reaction_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/toggle_reaction_reducer.ts deleted file mode 100644 index bf62b01c66f..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/toggle_reaction_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - messageId: __t.u64(), - emoji: __t.string(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/types.ts b/spacetime-presence-ts/example/src/codegen/app/types.ts deleted file mode 100644 index a77ec4af952..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/types.ts +++ /dev/null @@ -1,296 +0,0 @@ -// 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 const Attachment = __t.object("Attachment", { - id: __t.u64(), - messageId: __t.u64(), - fileId: __t.u64(), - ownerUserId: __t.string(), - ordinal: __t.u32(), - filename: __t.option(__t.string()), - createdAt: __t.timestamp(), -}); -export type Attachment = __Infer; - -export const AttachmentFileResult = __t.object("AttachmentFileResult", { - filename: __t.option(__t.string()), - mimeType: __t.string(), - bytes: __t.byteArray(), -}); -export type AttachmentFileResult = __Infer; - -export const AttachmentInput = __t.object("AttachmentInput", { - mimeType: __t.string(), - filename: __t.option(__t.string()), - bytes: __t.byteArray(), -}); -export type AttachmentInput = __Infer; - -export const AuthPubKey = __t.object("AuthPubKey", { - publicKeyPem: __t.string(), - keyId: __t.string(), - issuerUrl: __t.string(), -}); -export type AuthPubKey = __Infer; - -export const ChatAuthUser = __t.object("ChatAuthUser", { - userId: __t.string(), - email: __t.string(), - emailVerified: __t.bool(), - name: __t.option(__t.string()), - image: __t.option(__t.string()), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type ChatAuthUser = __Infer; - -export const ChatRateLimitStatus = __t.object("ChatRateLimitStatus", { - scope: __t.string(), - limit: __t.u32(), - used: __t.u32(), - remaining: __t.u32(), - resetAt: __t.timestamp(), -}); -export type ChatRateLimitStatus = __Infer; - -export const ChatSweepTick = __t.object("ChatSweepTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), -}); -export type ChatSweepTick = __Infer; - -export const ChatUser = __t.object("ChatUser", { - identity: __t.identity(), - userId: __t.string(), - displayName: __t.string(), - get status() { - return ChatUserStatus; - }, - createdAt: __t.timestamp(), - lastActiveAt: __t.timestamp(), - lastMessageAt: __t.timestamp(), -}); -export type ChatUser = __Infer; - -// The tagged union or sum type for the algebraic type `ChatUserStatus`. -export const ChatUserStatus = __t.enum("ChatUserStatus", { - Online: __t.unit(), - Away: __t.unit(), - Dnd: __t.unit(), - Invisible: __t.unit(), -}); -export type ChatUserStatus = __Infer; - -export const Message = __t.object("Message", { - id: __t.u64(), - roomId: __t.u64(), - author: __t.identity(), - content: __t.string(), - createdAt: __t.timestamp(), - editedAt: __t.option(__t.timestamp()), - replyToMessageId: __t.option(__t.u64()), - pinnedAt: __t.option(__t.timestamp()), - pinnedBy: __t.option(__t.identity()), -}); -export type Message = __Infer; - -export const MessageReaction = __t.object("MessageReaction", { - id: __t.u64(), - messageId: __t.u64(), - identity: __t.identity(), - emoji: __t.string(), - createdAt: __t.timestamp(), -}); -export type MessageReaction = __Infer; - -export const MessageThread = __t.object("MessageThread", { - id: __t.u64(), - rootMessageId: __t.u64(), - roomId: __t.u64(), - createdBy: __t.identity(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type MessageThread = __Infer; - -export const MyAuthUser = __t.object("MyAuthUser", {}); -export type MyAuthUser = __Infer; - -export const MyChatUsers = __t.object("MyChatUsers", {}); -export type MyChatUsers = __Infer; - -export const MyMessageThreads = __t.object("MyMessageThreads", {}); -export type MyMessageThreads = __Infer; - -export const MyPresenceEntries = __t.object("MyPresenceEntries", {}); -export type MyPresenceEntries = __Infer; - -export const MyRateLimitStatus = __t.object("MyRateLimitStatus", {}); -export type MyRateLimitStatus = __Infer; - -export const MyRoomAttachments = __t.object("MyRoomAttachments", {}); -export type MyRoomAttachments = __Infer; - -export const MyRoomMembers = __t.object("MyRoomMembers", {}); -export type MyRoomMembers = __Infer; - -export const MyRoomMessageReactions = __t.object("MyRoomMessageReactions", {}); -export type MyRoomMessageReactions = __Infer; - -export const MyRoomMessages = __t.object("MyRoomMessages", {}); -export type MyRoomMessages = __Infer; - -export const MyRoomReadCursors = __t.object("MyRoomReadCursors", {}); -export type MyRoomReadCursors = __Infer; - -export const MyRooms = __t.object("MyRooms", {}); -export type MyRooms = __Infer; - -export const MyServerMembers = __t.object("MyServerMembers", {}); -export type MyServerMembers = __Infer; - -export const MyServers = __t.object("MyServers", {}); -export type MyServers = __Infer; - -export const MySession = __t.object("MySession", { - sessionId: __t.string(), - expiresAt: __t.timestamp(), - createdAt: __t.timestamp(), - ipAddress: __t.option(__t.string()), - userAgent: __t.option(__t.string()), - isCurrent: __t.bool(), -}); -export type MySession = __Infer; - -export const MySessions = __t.object("MySessions", { - get sessions() { - return __t.array(MySession); - }, -}); -export type MySessions = __Infer; - -export const MyThreadMessages = __t.object("MyThreadMessages", {}); -export type MyThreadMessages = __Infer; - -export const PresenceConfig = __t.object("PresenceConfig", { - singleton: __t.bool(), - defaultTtlSeconds: __t.u32(), - sweepBatch: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type PresenceConfig = __Infer; - -export const PresenceEntry = __t.object("PresenceEntry", { - key: __t.string(), - scope: __t.string(), - subject: __t.string(), - status: __t.string(), - activity: __t.option(__t.string()), - payloadJson: __t.option(__t.string()), - joinedAt: __t.timestamp(), - lastSeenAt: __t.timestamp(), - expiresAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type PresenceEntry = __Infer; - -export const Room = __t.object("Room", { - id: __t.u64(), - serverId: __t.u64(), - name: __t.string(), - category: __t.option(__t.string()), - createdByUserId: __t.string(), - createdAt: __t.timestamp(), - isPrivate: __t.bool(), - activityLabel: __t.string(), - activityScore: __t.u32(), - lastActivityAt: __t.option(__t.timestamp()), -}); -export type Room = __Infer; - -export const RoomActivityEvent = __t.object("RoomActivityEvent", { - id: __t.u64(), - roomId: __t.u64(), - createdAt: __t.timestamp(), -}); -export type RoomActivityEvent = __Infer; - -export const RoomAttachment = __t.object("RoomAttachment", { - id: __t.u64(), - messageId: __t.u64(), - fileId: __t.u64(), - ownerUserId: __t.string(), - ordinal: __t.u32(), - filename: __t.option(__t.string()), - path: __t.string(), - mimeType: __t.string(), - size: __t.u64(), - sha256Hex: __t.string(), - visibility: __t.string(), - createdAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type RoomAttachment = __Infer; - -export const RoomMember = __t.object("RoomMember", { - id: __t.u64(), - roomId: __t.u64(), - userId: __t.string(), - role: __t.string(), - joinedAt: __t.timestamp(), -}); -export type RoomMember = __Infer; - -export const RoomReadCursor = __t.object("RoomReadCursor", { - id: __t.u64(), - roomId: __t.u64(), - identity: __t.identity(), - lastReadMessageId: __t.u64(), - lastReadAt: __t.timestamp(), -}); -export type RoomReadCursor = __Infer; - -export const Server = __t.object("Server", { - id: __t.u64(), - name: __t.string(), - createdByUserId: __t.string(), - createdAt: __t.timestamp(), -}); -export type Server = __Infer; - -export const ServerMember = __t.object("ServerMember", { - id: __t.u64(), - serverId: __t.u64(), - userId: __t.string(), - role: __t.string(), - joinedAt: __t.timestamp(), -}); -export type ServerMember = __Infer; - -export const ThreadMessage = __t.object("ThreadMessage", { - id: __t.u64(), - threadId: __t.u64(), - author: __t.identity(), - content: __t.string(), - createdAt: __t.timestamp(), - editedAt: __t.option(__t.timestamp()), -}); -export type ThreadMessage = __Infer; - -export const WhoAmI = __t.object("WhoAmI", { - userId: __t.option(__t.string()), - senderIdentityHex: __t.string(), - userDisplayName: __t.option(__t.string()), - userStatus: __t.option(__t.string()), -}); -export type WhoAmI = __Infer; - diff --git a/spacetime-presence-ts/example/src/codegen/app/types/procedures.ts b/spacetime-presence-ts/example/src/codegen/app/types/procedures.ts deleted file mode 100644 index f70528b40b1..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/types/procedures.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 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 GetAttachmentFileProcedure from "../get_attachment_file_procedure"; -import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; -import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; -import * as SearchMessagesProcedure from "../search_messages_procedure"; -import * as WhoamiProcedure from "../whoami_procedure"; - -export type GetAttachmentFileArgs = __Infer; -export type GetAttachmentFileResult = __Infer; -export type GetAuthPublicKeyArgs = __Infer; -export type GetAuthPublicKeyResult = __Infer; -export type ListMySessionsArgs = __Infer; -export type ListMySessionsResult = __Infer; -export type SearchMessagesArgs = __Infer; -export type SearchMessagesResult = __Infer; -export type WhoamiArgs = __Infer; -export type WhoamiResult = __Infer; - diff --git a/spacetime-presence-ts/example/src/codegen/app/types/reducers.ts b/spacetime-presence-ts/example/src/codegen/app/types/reducers.ts deleted file mode 100644 index 16775962995..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/types/reducers.ts +++ /dev/null @@ -1,76 +0,0 @@ -// 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 -import CreateRoomReducer from "../create_room_reducer"; -import CreateServerReducer from "../create_server_reducer"; -import DeleteMessageReducer from "../delete_message_reducer"; -import DeleteRoomReducer from "../delete_room_reducer"; -import DeleteServerReducer from "../delete_server_reducer"; -import DeleteThreadMessageReducer from "../delete_thread_message_reducer"; -import EditMessageReducer from "../edit_message_reducer"; -import EditThreadMessageReducer from "../edit_thread_message_reducer"; -import HeartbeatReducer from "../heartbeat_reducer"; -import JoinRoomReducer from "../join_room_reducer"; -import JoinServerReducer from "../join_server_reducer"; -import LeaveRoomReducer from "../leave_room_reducer"; -import LeaveServerReducer from "../leave_server_reducer"; -import LinkConnectionReducer from "../link_connection_reducer"; -import MarkRoomReadReducer from "../mark_room_read_reducer"; -import PinMessageReducer from "../pin_message_reducer"; -import RenameRoomReducer from "../rename_room_reducer"; -import RenameServerReducer from "../rename_server_reducer"; -import RevokeMySessionReducer from "../revoke_my_session_reducer"; -import RevokeSessionReducer from "../revoke_session_reducer"; -import SendMessageReducer from "../send_message_reducer"; -import SendThreadMessageReducer from "../send_thread_message_reducer"; -import SetAuthConfigReducer from "../set_auth_config_reducer"; -import SetDisplayNameReducer from "../set_display_name_reducer"; -import SetRoomCategoryReducer from "../set_room_category_reducer"; -import SetRoomPrivacyReducer from "../set_room_privacy_reducer"; -import SetStatusReducer from "../set_status_reducer"; -import StartTypingReducer from "../start_typing_reducer"; -import StopTypingReducer from "../stop_typing_reducer"; -import ToggleReactionReducer from "../toggle_reaction_reducer"; -import UnlinkConnectionReducer from "../unlink_connection_reducer"; -import UnpinMessageReducer from "../unpin_message_reducer"; -import UpdateProfileReducer from "../update_profile_reducer"; - -export type CreateRoomParams = __Infer; -export type CreateServerParams = __Infer; -export type DeleteMessageParams = __Infer; -export type DeleteRoomParams = __Infer; -export type DeleteServerParams = __Infer; -export type DeleteThreadMessageParams = __Infer; -export type EditMessageParams = __Infer; -export type EditThreadMessageParams = __Infer; -export type HeartbeatParams = __Infer; -export type JoinRoomParams = __Infer; -export type JoinServerParams = __Infer; -export type LeaveRoomParams = __Infer; -export type LeaveServerParams = __Infer; -export type LinkConnectionParams = __Infer; -export type MarkRoomReadParams = __Infer; -export type PinMessageParams = __Infer; -export type RenameRoomParams = __Infer; -export type RenameServerParams = __Infer; -export type RevokeMySessionParams = __Infer; -export type RevokeSessionParams = __Infer; -export type SendMessageParams = __Infer; -export type SendThreadMessageParams = __Infer; -export type SetAuthConfigParams = __Infer; -export type SetDisplayNameParams = __Infer; -export type SetRoomCategoryParams = __Infer; -export type SetRoomPrivacyParams = __Infer; -export type SetStatusParams = __Infer; -export type StartTypingParams = __Infer; -export type StopTypingParams = __Infer; -export type ToggleReactionParams = __Infer; -export type UnlinkConnectionParams = __Infer; -export type UnpinMessageParams = __Infer; -export type UpdateProfileParams = __Infer; - diff --git a/spacetime-presence-ts/example/src/codegen/app/unlink_connection_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/unlink_connection_reducer.ts deleted file mode 100644 index e18fbc0a086..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/unlink_connection_reducer.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 {}; diff --git a/spacetime-presence-ts/example/src/codegen/app/unpin_message_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/unpin_message_reducer.ts deleted file mode 100644 index 104809a301c..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/unpin_message_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - messageId: __t.u64(), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/update_profile_reducer.ts b/spacetime-presence-ts/example/src/codegen/app/update_profile_reducer.ts deleted file mode 100644 index f940573d72c..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/update_profile_reducer.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 { - name: __t.option(__t.string()), - image: __t.option(__t.string()), -}; diff --git a/spacetime-presence-ts/example/src/codegen/app/whoami_procedure.ts b/spacetime-presence-ts/example/src/codegen/app/whoami_procedure.ts deleted file mode 100644 index fb2b14ac8d7..00000000000 --- a/spacetime-presence-ts/example/src/codegen/app/whoami_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - WhoAmI, -} from "./types"; - -export const params = { -}; -export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/buy_upgrade_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/buy_upgrade_procedure.ts deleted file mode 100644 index 709e3a65208..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/buy_upgrade_procedure.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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 { - ReactorActionResult, -} from "./types"; - -export const params = { - upgradeId: __t.string(), -}; -export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/index.ts b/spacetime-rate-limit-ts/example/src/codegen/app/index.ts deleted file mode 100644 index e7b95496647..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/index.ts +++ /dev/null @@ -1,257 +0,0 @@ -// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). - -/* 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 ResetDemoReducer from "./reset_demo_reducer"; -import SetPlayerColorReducer from "./set_player_color_reducer"; -import UpdateConfigReducer from "./update_config_reducer"; - -// Import all procedure arg schemas -import * as BuyUpgradeProcedure from "./buy_upgrade_procedure"; -import * as OverchargeProcedure from "./overcharge_procedure"; -import * as RepairReactorProcedure from "./repair_reactor_procedure"; -import * as RunSweepProcedure from "./run_sweep_procedure"; -import * as StartReactorProcedure from "./start_reactor_procedure"; -import * as TapReactorProcedure from "./tap_reactor_procedure"; - -// Import all table schema definitions -import RateLimitDemoConfigRow from "./rate_limit_demo_config_table"; -import RateLimitEventsAdminRow from "./rate_limit_events_admin_table"; -import ReactorEventsRow from "./reactor_events_table"; -import ReactorLimitStatusRow from "./reactor_limit_status_table"; -import ReactorPlayersRow from "./reactor_players_table"; -import ReactorShopRow from "./reactor_shop_table"; -import ReactorStateRow from "./reactor_state_table"; - -// Import namespace table schema definitions -import RateLimit_RateLimitConfigRow from "./rateLimit/rate_limit_config_table"; -import RateLimit_AdminRateLimitBucketsRow from "./rateLimit/admin_rate_limit_buckets_table"; - -// Import namespace reducer arg schemas -import RateLimit_AddRateLimitAdminReducer from "./rateLimit/add_rate_limit_admin_reducer"; -import RateLimit_ResetBucketsReducer from "./rateLimit/reset_buckets_reducer"; -import RateLimit_UpdateConfigReducer from "./rateLimit/update_config_reducer"; - -// Import namespace procedure arg schemas -import * as RateLimit_ConsumeProcedure from "./rateLimit/consume_procedure"; -import * as RateLimit_RunSweepProcedure from "./rateLimit/run_sweep_procedure"; - -/** 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({ - rateLimitDemoConfig: __table({ - name: 'rate_limit_demo_config', - indexes: [ - { accessor: 'singleton', name: 'rate_limit_demo_config_singleton_idx_btree', algorithm: 'btree', columns: [ - 'singleton', - ] }, - ], - constraints: [ - { name: 'rate_limit_demo_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, - ], - }, RateLimitDemoConfigRow), - rateLimitEventsAdmin: __table({ - name: 'rate_limit_events_admin', - indexes: [ - ], - constraints: [ - ], - }, RateLimitEventsAdminRow), - reactorEvents: __table({ - name: 'reactor_events', - indexes: [ - ], - constraints: [ - ], - }, ReactorEventsRow), - reactorLimitStatus: __table({ - name: 'reactor_limit_status', - indexes: [ - ], - constraints: [ - ], - }, ReactorLimitStatusRow), - reactorPlayers: __table({ - name: 'reactor_players', - indexes: [ - ], - constraints: [ - ], - }, ReactorPlayersRow), - reactorShop: __table({ - name: 'reactor_shop', - indexes: [ - ], - constraints: [ - ], - }, ReactorShopRow), - reactorState: __table({ - name: 'reactor_state', - indexes: [ - ], - constraints: [ - ], - }, ReactorStateRow), - "rateLimit.rate_limit_config": __table({ - name: 'rateLimit.rate_limit_config', - indexes: [ - { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ - 'singleton', - ] }, - ], - constraints: [ - { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, - ], - }, RateLimit_RateLimitConfigRow), - "rateLimit.admin_rate_limit_buckets": __table({ - name: 'rateLimit.admin_rate_limit_buckets', - indexes: [ - ], - constraints: [ - ], - }, RateLimit_AdminRateLimitBucketsRow), -}); - -/** 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( - __reducerSchema("reset_demo", ResetDemoReducer), - __reducerSchema("set_player_color", SetPlayerColorReducer), - __reducerSchema("update_config", UpdateConfigReducer), - __reducerSchema("rateLimit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer), - __reducerSchema("rateLimit.reset_buckets", RateLimit_ResetBucketsReducer), - __reducerSchema("rateLimit.update_config", RateLimit_UpdateConfigReducer), -); - -/** 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("buy_upgrade", BuyUpgradeProcedure.params, BuyUpgradeProcedure.returnType), - __procedureSchema("overcharge", OverchargeProcedure.params, OverchargeProcedure.returnType), - __procedureSchema("repair_reactor", RepairReactorProcedure.params, RepairReactorProcedure.returnType), - __procedureSchema("run_sweep", RunSweepProcedure.params, RunSweepProcedure.returnType), - __procedureSchema("start_reactor", StartReactorProcedure.params, StartReactorProcedure.returnType), - __procedureSchema("tap_reactor", TapReactorProcedure.params, TapReactorProcedure.returnType), - __procedureSchema("rateLimit.consume", RateLimit_ConsumeProcedure.params, RateLimit_ConsumeProcedure.returnType), - __procedureSchema("rateLimit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType), -); - -/** The remote SpacetimeDB module schema, both runtime and type information. */ -const REMOTE_MODULE = { - versionInfo: { - cliVersion: "2.8.3" 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. */ -const __qb = __makeQueryBuilder(tablesSchema.schemaType); -export const tables = { - rateLimitDemoConfig: __qb.rateLimitDemoConfig, - rateLimitEventsAdmin: __qb.rateLimitEventsAdmin, - reactorEvents: __qb.reactorEvents, - reactorLimitStatus: __qb.reactorLimitStatus, - reactorPlayers: __qb.reactorPlayers, - reactorShop: __qb.reactorShop, - reactorState: __qb.reactorState, - rateLimit: { - rateLimitConfig: __qb["rateLimit.rate_limit_config"], - adminRateLimitBuckets: __qb["rateLimit.admin_rate_limit_buckets"], - }, -} as const; - -/** The reducers available in this remote SpacetimeDB module. */ -const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); -export const reducers = { - resetDemo: __reducerAccessors.resetDemo, - setPlayerColor: __reducerAccessors.setPlayerColor, - updateConfig: __reducerAccessors.updateConfig, - rateLimit: { - addRateLimitAdmin: __reducerAccessors["rateLimit.addRateLimitAdmin"], - resetBuckets: __reducerAccessors["rateLimit.resetBuckets"], - updateConfig: __reducerAccessors["rateLimit.updateConfig"], - }, -} as const; - -/** The procedures available in this remote SpacetimeDB module. */ -const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); -export const procedures = { - buyUpgrade: __procedureAccessors.buyUpgrade, - overcharge: __procedureAccessors.overcharge, - repairReactor: __procedureAccessors.repairReactor, - runSweep: __procedureAccessors.runSweep, - startReactor: __procedureAccessors.startReactor, - tapReactor: __procedureAccessors.tapReactor, - rateLimit: { - consume: __procedureAccessors["rateLimit.consume"], - runSweep: __procedureAccessors["rateLimit.runSweep"], - }, -} as const; - -/** The context type returned in callbacks for all possible events. */ -export type EventContext = __EventContextInterface; -/** The context type returned in callbacks for reducer events. */ -export type ReducerEventContext = __ReducerEventContextInterface; -/** The context type returned in callbacks for subscription events. */ -export type SubscriptionEventContext = __SubscriptionEventContextInterface; -/** The context type returned in callbacks for error events. */ -export type ErrorContext = __ErrorContextInterface; -/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ -export type SubscriptionHandle = __SubscriptionHandleImpl; - -/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ -export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} - -/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ -export class DbConnectionBuilder extends __DbConnectionBuilder {} - -/** 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 { - /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ - static builder = (): DbConnectionBuilder => { - return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); - }; - - /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ - override subscriptionBuilder = (): SubscriptionBuilder => { - return new SubscriptionBuilder(this); - }; -} - diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/overcharge_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/overcharge_procedure.ts deleted file mode 100644 index d53ce96cf57..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/overcharge_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - ReactorActionResult, -} from "./types"; - -export const params = { -}; -export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts deleted file mode 100644 index e39846ca8d9..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/add_rate_limit_admin_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - identity: __t.identity(), -}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts deleted file mode 100644 index 189f539a043..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/admin_rate_limit_buckets_table.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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({ - key: __t.string().primaryKey(), - scope: __t.string(), - windowStart: __t.timestamp().name("window_start"), - expiresAt: __t.timestamp().name("expires_at"), - count: __t.u32(), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/consume_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/consume_procedure.ts deleted file mode 100644 index a98b8588aad..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/consume_procedure.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 { - RateLimitConsumeResult, -} from "./types"; - -export const params = { - scope: __t.string(), - actorKey: __t.string(), - limit: __t.u32(), - windowSeconds: __t.u32(), - cost: __t.option(__t.u32()), -}; -export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts deleted file mode 100644 index 66ffe86e399..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/rate_limit_config_table.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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({ - singleton: __t.bool().primaryKey(), - sweepBatch: __t.u32().name("sweep_batch"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts deleted file mode 100644 index a7c5cc5274f..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/reset_buckets_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - maxRows: __t.option(__t.u32()), -}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts deleted file mode 100644 index 9815c99eb38..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/run_sweep_procedure.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 const params = { - maxRows: __t.option(__t.u32()), -}; -export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/types.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/types.ts deleted file mode 100644 index 151a90e827f..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); -export type AdminRateLimitBuckets = __Infer; - -export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { - identity: __t.identity(), - addedAtMicros: __t.i64(), -}); -export type RateLimitAdminIdentity = __Infer; - -export const RateLimitBucket = __t.object("RateLimitBucket", { - key: __t.string(), - scope: __t.string(), - windowStart: __t.timestamp(), - expiresAt: __t.timestamp(), - count: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type RateLimitBucket = __Infer; - -export const RateLimitConfig = __t.object("RateLimitConfig", { - singleton: __t.bool(), - sweepBatch: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type RateLimitConfig = __Infer; - -export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { - allowed: __t.bool(), - scope: __t.string(), - key: __t.string(), - limit: __t.u32(), - used: __t.u32(), - remaining: __t.u32(), - retryAfterSeconds: __t.u32(), - resetAt: __t.timestamp(), -}); -export type RateLimitConsumeResult = __Infer; - -export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), -}); -export type RateLimitSweepTick = __Infer; - diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts deleted file mode 100644 index 54fcf361af1..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rateLimit/update_config_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - sweepBatch: __t.u32(), -}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_demo_config_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_demo_config_table.ts deleted file mode 100644 index bcb7aa20309..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_demo_config_table.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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({ - singleton: __t.bool().primaryKey(), - retainEvents: __t.u32().name("retain_events"), - eventPruneBatch: __t.u32().name("event_prune_batch"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_events_admin_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_events_admin_table.ts deleted file mode 100644 index d6d861cbb6f..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/rate_limit_events_admin_table.ts +++ /dev/null @@ -1,26 +0,0 @@ -// 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().primaryKey(), - scope: __t.string(), - key: __t.string(), - allowed: __t.bool(), - limit: __t.u32(), - used: __t.u32(), - remaining: __t.u32(), - retryAfterSeconds: __t.u32().name("retry_after_seconds"), - windowSeconds: __t.u32().name("window_seconds"), - cost: __t.u32(), - resetAt: __t.timestamp().name("reset_at"), - createdAt: __t.timestamp().name("created_at"), -}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_events_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_events_table.ts deleted file mode 100644 index 207ec768f97..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_events_table.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 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().primaryKey(), - identity: __t.identity(), - actorName: __t.string().name("actor_name"), - actorColor: __t.string().name("actor_color"), - kind: __t.string(), - scope: __t.string(), - message: __t.string(), - allowed: __t.bool(), - energyDelta: __t.i64().name("energy_delta"), - retryAfterSeconds: __t.u32().name("retry_after_seconds"), - createdAt: __t.timestamp().name("created_at"), -}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_limit_status_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_limit_status_table.ts deleted file mode 100644 index fc9f0392d1e..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_limit_status_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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({ - scope: __t.string(), - label: __t.string(), - limit: __t.u32(), - windowSeconds: __t.u32().name("window_seconds"), - used: __t.u32(), - remaining: __t.u32(), - resetAt: __t.option(__t.timestamp()).name("reset_at"), -}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_players_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_players_table.ts deleted file mode 100644 index e9c658484b7..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_players_table.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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({ - identity: __t.identity(), - displayName: __t.string().name("display_name"), - color: __t.string(), - contributedEnergy: __t.u64().name("contributed_energy"), - taps: __t.u32(), - surges: __t.u32(), - coolantUses: __t.u32().name("coolant_uses"), - upgradesBought: __t.u32().name("upgrades_bought"), - joinedAt: __t.timestamp().name("joined_at"), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_shop_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_shop_table.ts deleted file mode 100644 index 5a0217589e6..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_shop_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 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({ - slot: __t.u32(), - id: __t.string(), - name: __t.string(), - description: __t.string(), - effect: __t.string(), - cost: __t.u64(), - available: __t.bool(), -}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_state_table.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reactor_state_table.ts deleted file mode 100644 index 422838dd710..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/reactor_state_table.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 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({ - singleton: __t.bool().primaryKey(), - energy: __t.u64(), - reactorLevel: __t.u32().name("reactor_level"), - upgradeCount: __t.u32().name("upgrade_count"), - powerUpgradeCount: __t.u32().name("power_upgrade_count"), - coolingUpgradeCount: __t.u32().name("cooling_upgrade_count"), - capacityUpgradeCount: __t.u32().name("capacity_upgrade_count"), - chargeUpgradeCount: __t.u32().name("charge_upgrade_count"), - bayUpgradeCount: __t.u32().name("bay_upgrade_count"), - combo: __t.u32(), - bestCombo: __t.u32().name("best_combo"), - heat: __t.u32(), - heatCapacity: __t.u32().name("heat_capacity"), - coolingPerSecond: __t.u32().name("cooling_per_second"), - tapHeatGain: __t.u32().name("tap_heat_gain"), - overheated: __t.bool(), - updatedAt: __t.timestamp().name("updated_at"), -}); diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/repair_reactor_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/repair_reactor_procedure.ts deleted file mode 100644 index d53ce96cf57..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/repair_reactor_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - ReactorActionResult, -} from "./types"; - -export const params = { -}; -export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/reset_demo_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/reset_demo_reducer.ts deleted file mode 100644 index e18fbc0a086..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/reset_demo_reducer.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 {}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/run_sweep_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/run_sweep_procedure.ts deleted file mode 100644 index 9815c99eb38..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/run_sweep_procedure.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 const params = { - maxRows: __t.option(__t.u32()), -}; -export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/set_player_color_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/set_player_color_reducer.ts deleted file mode 100644 index 42ec3238c75..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/set_player_color_reducer.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 { - color: __t.string(), -}; diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/start_reactor_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/start_reactor_procedure.ts deleted file mode 100644 index d53ce96cf57..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/start_reactor_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - ReactorActionResult, -} from "./types"; - -export const params = { -}; -export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/tap_reactor_procedure.ts b/spacetime-rate-limit-ts/example/src/codegen/app/tap_reactor_procedure.ts deleted file mode 100644 index d53ce96cf57..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/tap_reactor_procedure.ts +++ /dev/null @@ -1,19 +0,0 @@ -// 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 { - ReactorActionResult, -} from "./types"; - -export const params = { -}; -export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/types.ts b/spacetime-rate-limit-ts/example/src/codegen/app/types.ts deleted file mode 100644 index fd9c231c367..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/types.ts +++ /dev/null @@ -1,157 +0,0 @@ -// 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 const RateLimitDemoConfig = __t.object("RateLimitDemoConfig", { - singleton: __t.bool(), - retainEvents: __t.u32(), - eventPruneBatch: __t.u32(), - updatedAt: __t.timestamp(), -}); -export type RateLimitDemoConfig = __Infer; - -export const RateLimitDemoSweepTick = __t.object("RateLimitDemoSweepTick", { - scheduledId: __t.u64(), - scheduledAt: __t.scheduleAt(), -}); -export type RateLimitDemoSweepTick = __Infer; - -export const RateLimitEvent = __t.object("RateLimitEvent", { - id: __t.u64(), - scope: __t.string(), - key: __t.string(), - allowed: __t.bool(), - limit: __t.u32(), - used: __t.u32(), - remaining: __t.u32(), - retryAfterSeconds: __t.u32(), - windowSeconds: __t.u32(), - cost: __t.u32(), - resetAt: __t.timestamp(), - createdAt: __t.timestamp(), -}); -export type RateLimitEvent = __Infer; - -export const RateLimitEventsAdmin = __t.object("RateLimitEventsAdmin", {}); -export type RateLimitEventsAdmin = __Infer; - -export const ReactorActionResult = __t.object("ReactorActionResult", { - allowed: __t.bool(), - action: __t.string(), - message: __t.string(), - energy: __t.u64(), - energyDelta: __t.i64(), - retryAfterSeconds: __t.u32(), - resetAt: __t.timestamp(), -}); -export type ReactorActionResult = __Infer; - -export const ReactorEvent = __t.object("ReactorEvent", { - id: __t.u64(), - identity: __t.identity(), - actorName: __t.string(), - actorColor: __t.string(), - kind: __t.string(), - scope: __t.string(), - message: __t.string(), - allowed: __t.bool(), - energyDelta: __t.i64(), - retryAfterSeconds: __t.u32(), - createdAt: __t.timestamp(), -}); -export type ReactorEvent = __Infer; - -export const ReactorEvents = __t.object("ReactorEvents", {}); -export type ReactorEvents = __Infer; - -export const ReactorLimitStatus = __t.object("ReactorLimitStatus", {}); -export type ReactorLimitStatus = __Infer; - -export const ReactorLimitStatusRow = __t.object("ReactorLimitStatusRow", { - scope: __t.string(), - label: __t.string(), - limit: __t.u32(), - windowSeconds: __t.u32(), - used: __t.u32(), - remaining: __t.u32(), - resetAt: __t.option(__t.timestamp()), -}); -export type ReactorLimitStatusRow = __Infer; - -export const ReactorPlayerRow = __t.object("ReactorPlayerRow", { - identity: __t.identity(), - displayName: __t.string(), - color: __t.string(), - contributedEnergy: __t.u64(), - taps: __t.u32(), - surges: __t.u32(), - coolantUses: __t.u32(), - upgradesBought: __t.u32(), - joinedAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type ReactorPlayerRow = __Infer; - -export const ReactorPlayerState = __t.object("ReactorPlayerState", { - identity: __t.identity(), - displayName: __t.string(), - color: __t.string(), - contributedEnergy: __t.u64(), - taps: __t.u32(), - surges: __t.u32(), - coolantUses: __t.u32(), - upgradesBought: __t.u32(), - joinedAt: __t.timestamp(), - updatedAt: __t.timestamp(), -}); -export type ReactorPlayerState = __Infer; - -export const ReactorPlayers = __t.object("ReactorPlayers", {}); -export type ReactorPlayers = __Infer; - -export const ReactorRoomState = __t.object("ReactorRoomState", { - singleton: __t.bool(), - energy: __t.u64(), - reactorLevel: __t.u32(), - upgradeCount: __t.u32(), - powerUpgradeCount: __t.u32(), - coolingUpgradeCount: __t.u32(), - capacityUpgradeCount: __t.u32(), - chargeUpgradeCount: __t.u32(), - bayUpgradeCount: __t.u32(), - combo: __t.u32(), - bestCombo: __t.u32(), - heat: __t.u32(), - heatCapacity: __t.u32(), - coolingPerSecond: __t.u32(), - tapHeatGain: __t.u32(), - overheated: __t.bool(), - updatedAt: __t.timestamp(), -}); -export type ReactorRoomState = __Infer; - -export const ReactorShop = __t.object("ReactorShop", {}); -export type ReactorShop = __Infer; - -export const ReactorShopItemRow = __t.object("ReactorShopItemRow", { - slot: __t.u32(), - id: __t.string(), - name: __t.string(), - description: __t.string(), - effect: __t.string(), - cost: __t.u64(), - available: __t.bool(), -}); -export type ReactorShopItemRow = __Infer; - -export const ReactorState = __t.object("ReactorState", {}); -export type ReactorState = __Infer; - diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/types/procedures.ts b/spacetime-rate-limit-ts/example/src/codegen/app/types/procedures.ts deleted file mode 100644 index 44dde79c5fd..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/types/procedures.ts +++ /dev/null @@ -1,28 +0,0 @@ -// 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 BuyUpgradeProcedure from "../buy_upgrade_procedure"; -import * as OverchargeProcedure from "../overcharge_procedure"; -import * as RepairReactorProcedure from "../repair_reactor_procedure"; -import * as RunSweepProcedure from "../run_sweep_procedure"; -import * as StartReactorProcedure from "../start_reactor_procedure"; -import * as TapReactorProcedure from "../tap_reactor_procedure"; - -export type BuyUpgradeArgs = __Infer; -export type BuyUpgradeResult = __Infer; -export type OverchargeArgs = __Infer; -export type OverchargeResult = __Infer; -export type RepairReactorArgs = __Infer; -export type RepairReactorResult = __Infer; -export type RunSweepArgs = __Infer; -export type RunSweepResult = __Infer; -export type StartReactorArgs = __Infer; -export type StartReactorResult = __Infer; -export type TapReactorArgs = __Infer; -export type TapReactorResult = __Infer; - diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/types/reducers.ts b/spacetime-rate-limit-ts/example/src/codegen/app/types/reducers.ts deleted file mode 100644 index 3bed08e992d..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/types/reducers.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 -import ResetDemoReducer from "../reset_demo_reducer"; -import SetPlayerColorReducer from "../set_player_color_reducer"; -import UpdateConfigReducer from "../update_config_reducer"; - -export type ResetDemoParams = __Infer; -export type SetPlayerColorParams = __Infer; -export type UpdateConfigParams = __Infer; - diff --git a/spacetime-rate-limit-ts/example/src/codegen/app/update_config_reducer.ts b/spacetime-rate-limit-ts/example/src/codegen/app/update_config_reducer.ts deleted file mode 100644 index d7ddcf900e6..00000000000 --- a/spacetime-rate-limit-ts/example/src/codegen/app/update_config_reducer.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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 { - sweepBatch: __t.option(__t.u32()), - retainEvents: __t.option(__t.u32()), - eventPruneBatch: __t.option(__t.u32()), -}; diff --git a/spacetime-resend-ts/example/.gitignore b/spacetime-resend-ts/example/.gitignore deleted file mode 100644 index 5882ce447a9..00000000000 --- a/spacetime-resend-ts/example/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules -dist -src/codegen -public/app.js -public/app.js.map -*.tsbuildinfo -.DS_Store -dev-server.log -.env -.stdb-server-token diff --git a/spacetime-stripe-ts/example/.gitignore b/spacetime-stripe-ts/example/.gitignore deleted file mode 100644 index 52c85422453..00000000000 --- a/spacetime-stripe-ts/example/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Node -node_modules/ -.env -.stdb-server-token - -# Generated / built outputs -src/codegen/ -public/app.js -public/app.js.map -dist/ -*.tsbuildinfo diff --git a/tools/check-example-assets.mjs b/tools/check-example-assets.mjs deleted file mode 100644 index 52d56c73111..00000000000 --- a/tools/check-example-assets.mjs +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node - -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { releasePackages } from './release-packages.mjs'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const failures = []; -let checked = 0; - -for (const packageDir of releasePackages) { - const exampleDir = join(root, packageDir, 'example'); - if (!existsSync(join(exampleDir, 'package.json'))) continue; - - const publicDir = join(exampleDir, 'public'); - const indexPath = join(publicDir, 'index.html'); - if (!existsSync(indexPath)) { - failures.push(`${packageDir}: example/public/index.html is missing`); - continue; - } - - checked++; - const html = readFileSync(indexPath, 'utf8'); - const stylesPath = join(publicDir, 'styles.css'); - const uiPath = join(publicDir, 'ui.js'); - - if (/)/i.test(html)) { - failures.push(`${packageDir}: index.html contains an inline style block`); - } - if (/]*\bsrc=)[^>]*>/i.test(html)) { - failures.push(`${packageDir}: index.html contains an inline script block`); - } - if (!existsSync(stylesPath)) { - failures.push(`${packageDir}: public/styles.css is missing`); - } - if (!/]+href=["'](?:\.\/|\/)styles\.css["'][^>]*>/i.test(html)) { - failures.push(`${packageDir}: index.html does not load styles.css`); - } - - const loadsUi = /]+src=["']\.\/ui\.js["'][^>]*>/i.test(html); - if (existsSync(uiPath) !== loadsUi) { - failures.push( - `${packageDir}: public/ui.js and its index.html script tag do not match` - ); - } -} - -if (failures.length > 0) { - console.error('Example asset check failed:'); - for (const failure of failures) console.error(`- ${failure}`); - process.exit(1); -} - -console.log(`Example asset check passed for ${checked} browser examples.`); From b917c2ce7c70360787f9313371180852c099225b Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 18:56:38 -0400 Subject: [PATCH 08/33] Correct submodule setup documentation --- spacetime-agents-ts/README.md | 4 ++-- spacetime-api-keys-ts/README.md | 2 +- spacetime-api-keys-ts/example/README.md | 2 +- spacetime-cron-ts/README.md | 6 ++++-- spacetime-cron-ts/example/README.md | 2 +- spacetime-crypto-ts/README.md | 4 ++-- spacetime-files-ts/README.md | 2 +- spacetime-files-ts/example/README.md | 2 +- spacetime-resend-ts/README.md | 6 +++--- spacetime-resend-ts/example/README.md | 4 ++-- spacetime-retry-ts/README.md | 6 +++--- spacetime-stripe-ts/README.md | 6 +++--- spacetime-stripe-ts/example/README.md | 2 +- 13 files changed, 25 insertions(+), 23 deletions(-) diff --git a/spacetime-agents-ts/README.md b/spacetime-agents-ts/README.md index 1aa355b50dd..2499f7ce36c 100644 --- a/spacetime-agents-ts/README.md +++ b/spacetime-agents-ts/README.md @@ -129,8 +129,8 @@ Package entrypoints: ## Testing ```bash -npm test --workspace @spacetimedb/agents -npm run lint --workspace @spacetimedb/agents +pnpm test +pnpm run lint ``` The unit suite uses mocked HTTP with deterministic provider fixtures. See the diff --git a/spacetime-api-keys-ts/README.md b/spacetime-api-keys-ts/README.md index e510b1e71c6..f8986eefe30 100644 --- a/spacetime-api-keys-ts/README.md +++ b/spacetime-api-keys-ts/README.md @@ -9,7 +9,7 @@ admin-gated views. Host apps own what scopes mean. ## Install ```bash -npm install @spacetimedb/api-keys @spacetimedb/crypto spacetimedb@^2.8.3 +npm install @spacetimedb/api-keys spacetimedb@^2.8.3 ``` Requires SpacetimeDB 2.8.3 or later for submodule mounting. diff --git a/spacetime-api-keys-ts/example/README.md b/spacetime-api-keys-ts/example/README.md index 429b91e8414..31a3b1d5411 100644 --- a/spacetime-api-keys-ts/example/README.md +++ b/spacetime-api-keys-ts/example/README.md @@ -59,7 +59,7 @@ local rows. This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash -npm install @spacetimedb/api-keys @spacetimedb/crypto spacetimedb@^2.8.3 +npm install @spacetimedb/api-keys spacetimedb@^2.8.3 ``` Follow the package's diff --git a/spacetime-cron-ts/README.md b/spacetime-cron-ts/README.md index f1ca1bb0921..8d1093a98bf 100644 --- a/spacetime-cron-ts/README.md +++ b/spacetime-cron-ts/README.md @@ -10,8 +10,10 @@ transactions are available. ## Requirements -- SpacetimeDB CLI 2.8.3 or later -- `spacetimedb` npm package 2.8.3 or later within the 2.x release line +- SpacetimeDB CLI 2.8.3 +- `spacetimedb` npm package 2.8.3 +- Host support for the `spacetime:sys@2.0` volatile procedure used by failure + recovery - Node.js 20 or later for package tooling ## Install diff --git a/spacetime-cron-ts/example/README.md b/spacetime-cron-ts/example/README.md index 5f578b94c26..7a747455529 100644 --- a/spacetime-cron-ts/example/README.md +++ b/spacetime-cron-ts/example/README.md @@ -24,7 +24,7 @@ This is a local development example. Its scheduling reducers accept any connecte - Node.js 20 or later - pnpm 10 -- SpacetimeDB CLI 2.8.3 or later +- SpacetimeDB CLI 2.8.3 with `spacetime:sys@2.0` volatile procedure support - A local SpacetimeDB server Select the supported CLI release: diff --git a/spacetime-crypto-ts/README.md b/spacetime-crypto-ts/README.md index 7ad5febc853..f6a7dc2d516 100644 --- a/spacetime-crypto-ts/README.md +++ b/spacetime-crypto-ts/README.md @@ -60,8 +60,8 @@ Package entrypoints: ## Testing ```bash -npm test --workspace @spacetimedb/crypto -npm run lint --workspace @spacetimedb/crypto +pnpm test +pnpm run lint ``` Tests use published vendor vectors and local fixtures; no network is required. diff --git a/spacetime-files-ts/README.md b/spacetime-files-ts/README.md index 072aff20c9c..8d74298e89f 100644 --- a/spacetime-files-ts/README.md +++ b/spacetime-files-ts/README.md @@ -9,7 +9,7 @@ that streams cached responses through the module's route. ## Install ```bash -npm install @spacetimedb/files @spacetimedb/crypto spacetimedb@^2.8.3 +npm install @spacetimedb/files spacetimedb@^2.8.3 ``` Requires SpacetimeDB 2.8.3 or later for submodule mounting. diff --git a/spacetime-files-ts/example/README.md b/spacetime-files-ts/example/README.md index 4f85155a7e2..f8c88628512 100644 --- a/spacetime-files-ts/example/README.md +++ b/spacetime-files-ts/example/README.md @@ -58,7 +58,7 @@ database. Use `pnpm run build:module` when existing local files must be preserve This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash -npm install @spacetimedb/files @spacetimedb/crypto spacetimedb@^2.8.3 +npm install @spacetimedb/files spacetimedb@^2.8.3 ``` Follow the package's diff --git a/spacetime-resend-ts/README.md b/spacetime-resend-ts/README.md index 4bca6eff134..67543b8d458 100644 --- a/spacetime-resend-ts/README.md +++ b/spacetime-resend-ts/README.md @@ -9,7 +9,7 @@ state, synchronous procedures, and valibot-validated webhook payloads. ## Install ```bash -npm install @spacetimedb/resend @spacetimedb/crypto spacetimedb@^2.8.3 +npm install @spacetimedb/resend spacetimedb@^2.8.3 ``` Requires SpacetimeDB 2.8.3 or later for submodule mounting. @@ -221,8 +221,8 @@ To exercise inbound webhooks end-to-end, expose your local relay via ngrok, regi ## Testing ```bash -npm test --workspace @spacetimedb/resend -npm run lint --workspace @spacetimedb/resend +pnpm test +pnpm run lint ``` Credentialed smoke coverage is described in **Integration testing** above. diff --git a/spacetime-resend-ts/example/README.md b/spacetime-resend-ts/example/README.md index dec27a57a1a..9615a9224e8 100644 --- a/spacetime-resend-ts/example/README.md +++ b/spacetime-resend-ts/example/README.md @@ -66,7 +66,7 @@ database. Use `pnpm run build:module` to preserve existing data. This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash -npm install @spacetimedb/resend @spacetimedb/rate-limit @spacetimedb/crypto spacetimedb@^2.8.3 +npm install @spacetimedb/resend @spacetimedb/rate-limit spacetimedb@^2.8.3 ``` Follow the package's @@ -169,7 +169,7 @@ or unsigned webhook fixtures must return a non-success status. secret matches `RESEND_WEBHOOK_SECRET`. - **Connection targets disagree:** make `STDB_URI`, `STDB_HTTP`, and the publish target refer to the same SpacetimeDB instance. -- **Changing the server identity intentionally:** stop the server, remove +- **Replace the server identity:** stop the server, remove `.stdb-server-token`, and restart while logged in as a database administrator. ## Important files diff --git a/spacetime-retry-ts/README.md b/spacetime-retry-ts/README.md index e29e807b1c6..d2f6823df24 100644 --- a/spacetime-retry-ts/README.md +++ b/spacetime-retry-ts/README.md @@ -112,9 +112,9 @@ factory is available from `./submodule`. ## Testing ```bash -npm test --workspace @spacetimedb/retry -npm run lint --workspace @spacetimedb/retry -npm run build +pnpm test +pnpm run lint +pnpm run build ``` The repository build compiles the local fixture module that mounts the factory. diff --git a/spacetime-stripe-ts/README.md b/spacetime-stripe-ts/README.md index e623b7409d9..de893f7f88f 100644 --- a/spacetime-stripe-ts/README.md +++ b/spacetime-stripe-ts/README.md @@ -10,7 +10,7 @@ synchronous and webhook payloads use valibot validation. ## Install ```bash -npm install @spacetimedb/stripe @spacetimedb/crypto spacetimedb@^2.8.3 +npm install @spacetimedb/stripe spacetimedb@^2.8.3 ``` Requires SpacetimeDB 2.8.3 or later for submodule mounting. @@ -228,8 +228,8 @@ ephemeral listener secret. ## Testing ```bash -npm test --workspace @spacetimedb/stripe -npm run lint --workspace @spacetimedb/stripe +pnpm test +pnpm run lint ``` Credentialed sandbox coverage is described in **Integration testing** above. diff --git a/spacetime-stripe-ts/example/README.md b/spacetime-stripe-ts/example/README.md index 00acfe5821e..d80e95dd06d 100644 --- a/spacetime-stripe-ts/example/README.md +++ b/spacetime-stripe-ts/example/README.md @@ -69,7 +69,7 @@ database. Use `pnpm run build:module` to preserve existing data. This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash -npm install @spacetimedb/stripe @spacetimedb/crypto spacetimedb@^2.8.3 +npm install @spacetimedb/stripe spacetimedb@^2.8.3 ``` Follow the package's From 9c75ec5e9574398fbfa0a41f927f7624b7820e87 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 19:00:37 -0400 Subject: [PATCH 09/33] Exercise example data operations in browser smoke tests --- tools/run-example-smokes.mjs | 39 ++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tools/run-example-smokes.mjs b/tools/run-example-smokes.mjs index f9bfa76ef0d..5f6c0d73c51 100644 --- a/tools/run-example-smokes.mjs +++ b/tools/run-example-smokes.mjs @@ -441,9 +441,29 @@ async function checkExampleInteraction(example, page) { await page.waitForFunction( () => document.querySelector('#connChip')?.dataset.state === 'connected' ); - await page.locator('#rosterBtn').click(); + await page.locator('#shareBtn').click(); + await page.locator('#keyNameInput').fill('Browser smoke'); + await page.locator('#createKeyBtn').click(); await page.waitForFunction( - () => !document.querySelector('#rosterPanel')?.hasAttribute('hidden') + () => + !document.querySelector('#linkBox')?.hasAttribute('hidden') && + document.querySelectorAll('#keyList [data-key]').length === 1 + ); + { + const firstLink = await page + .locator('#linkBox .link-code') + .textContent(); + await page.locator('#keyList [data-rotate]').click(); + await page.waitForFunction( + previous => + document.querySelector('#linkBox .link-code')?.textContent !== + previous, + firstLink + ); + } + await page.locator('#keyList [data-revoke]').click(); + await page.waitForFunction( + () => document.querySelectorAll('#keyList [data-key]').length === 0 ); return; @@ -471,6 +491,21 @@ async function checkExampleInteraction(example, page) { document.querySelectorAll('[data-file].selected').length === 3 && document.querySelector('#bulk-count')?.textContent === '3 selected' ); + await page.locator('#bulk-public').click(); + await page.waitForFunction( + () => + document.querySelectorAll( + '[data-file] .vis-dot.public, [data-file] .badge.public' + ).length === 3 + ); + await page.locator('#bulk-delete').click(); + await page.waitForFunction(() => + document.querySelector('#dialog')?.classList.contains('open') + ); + await page.locator('#dialog-ok').click(); + await page.waitForFunction( + () => document.querySelectorAll('[data-file]').length === 0 + ); return; default: From 4f2a810651f852e46717c7b2237bb8577080f41b Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 19:04:01 -0400 Subject: [PATCH 10/33] Organize API keys example server code --- .../example/spacetimedb/src/http.ts | 61 ++++++ .../example/spacetimedb/src/index.ts | 183 +++--------------- .../example/spacetimedb/src/schema.ts | 119 ++++++++++++ 3 files changed, 202 insertions(+), 161 deletions(-) create mode 100644 spacetime-api-keys-ts/example/spacetimedb/src/http.ts create mode 100644 spacetime-api-keys-ts/example/spacetimedb/src/schema.ts diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/http.ts b/spacetime-api-keys-ts/example/spacetimedb/src/http.ts new file mode 100644 index 00000000000..2bc168fd2d3 --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/src/http.ts @@ -0,0 +1,61 @@ +import { SenderError, SyncResponse, type Request } from 'spacetimedb/server'; + +export function jsonResponse(body: unknown, status = 200): SyncResponse { + return new SyncResponse( + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value + ), + { + status, + headers: { 'content-type': 'application/json' }, + } + ); +} + +export function errorResponse( + error: string, + status: number, + extra: Record = {} +): SyncResponse { + return jsonResponse({ ok: false, error, ...extra }, status); +} + +export function readBearer(req: Request): string | undefined { + const header = req.headers.get('authorization') ?? ''; + if (!header.toLowerCase().startsWith('bearer ')) return undefined; + const token = header.slice(7).trim(); + return token.length > 0 ? token : undefined; +} + +export function safeJson(req: Request): unknown { + try { + return req.json(); + } catch { + throw new SenderError('world.invalid_json'); + } +} + +export function asObject(value: unknown): Record { + if (value === null || Array.isArray(value) || typeof value !== 'object') { + throw new SenderError('world.invalid_json'); + } + return value as Record; +} + +export function asI32(value: unknown, field: string): number { + if (!Number.isInteger(value)) throw new SenderError(`world.invalid_${field}`); + return value as number; +} + +export function asOptionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function asString(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new SenderError(`world.invalid_${field}`); + } + const out = value.trim(); + if (!out) throw new SenderError(`world.invalid_${field}`); + return out; +} diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/index.ts b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts index e228579a343..510d8af2e0f 100644 --- a/spacetime-api-keys-ts/example/spacetimedb/src/index.ts +++ b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts @@ -2,17 +2,10 @@ import { Router, Range, SenderError, - SyncResponse, - schema, - table, t, - type HandlerContext, type Infer, - type InferSchema, type Request, - type ReducerCtx, - type TransactionCtx, - type ViewCtx, + type SyncResponse, } from 'spacetimedb/server'; import { ScheduleAt } from 'spacetimedb'; import * as apiKeys from '@spacetimedb/api-keys/submodule'; @@ -28,6 +21,25 @@ import { runPresenceSweep, upsertPresence, } from '@spacetimedb/presence'; +import { + accessKeySummary, + colonySweepTick, + setColonySweepReducer, + spacetimedb, + type HttpCtx, + type ReadCtx, + type Tx, +} from './schema'; +import { + asI32, + asObject, + asOptionalString, + asString, + errorResponse, + jsonResponse, + readBearer, + safeJson, +} from './http'; // A small colony you build (terraform / build / plant) and share by handing // out scoped API keys. The api-keys submodule grants @@ -61,102 +73,8 @@ const SCOPE_TERRAFORM = 'colony:terraform'; const SCOPE_BUILD = 'colony:build'; const SCOPE_PLANT = 'colony:plant'; -const world = table( - { name: 'world', public: true }, - { - ownerSubject: t.string().primaryKey(), - gridId: t.u64().index(), - name: t.string(), - createdAt: t.timestamp(), - updatedAt: t.timestamp(), - } -); - -const worldEvent = table( - { name: 'world_event', public: true }, - { - eventId: t.u64().primaryKey().autoInc(), - ownerSubject: t.string().index(), - keyPrefix: t.string(), - action: t.string().index(), - allowed: t.bool().index(), - reason: t.string(), - message: t.string(), - createdAt: t.timestamp().index(), - } -); - -const accessKeySummary = table( - { name: 'access_key_summary', public: false }, - { - keyId: t.string().primaryKey(), - prefix: t.string(), - ownerSubject: t.string().index(), - name: t.string(), - scopesJson: t.string(), - metadataJson: t.option(t.string()), - status: apiKeys.apiKeyStatus.index(), - createdAt: t.timestamp().index(), - expiresAt: t.option(t.timestamp()), - lastUsedAt: t.option(t.timestamp()), - revokedAt: t.option(t.timestamp()), - } -); - -// Presence-ts tables, declared locally so the submodule helpers can read -// and write them. presence_entry is public so anyone in a colony can see the -// live roster and cursors (scope === colony id). -const presenceEntry = table( - { name: 'presence_entry', public: true }, - { - key: t.string().primaryKey(), - scope: t.string().index(), - subject: t.string().index(), - status: t.string().index(), - activity: t.option(t.string()), - payloadJson: t.option(t.string()), - joinedAt: t.timestamp().index(), - lastSeenAt: t.timestamp().index(), - expiresAt: t.timestamp().index(), - updatedAt: t.timestamp(), - } -); - -const presenceConfig = table( - { name: 'presence_config', public: false }, - { - singleton: t.bool().primaryKey(), - defaultTtlSeconds: t.u32(), - sweepBatch: t.u32(), - updatedAt: t.timestamp(), - } -); - -const colonySweepTick = table( - { name: 'colony_sweep_tick', scheduled: (): any => colony_sweep }, - { - scheduledId: t.u64().primaryKey().autoInc(), - scheduledAt: t.scheduleAt(), - } -); - -const spacetimedb = schema({ - apiKeys, - grid: gridSubmodule, - world, - worldEvent, - accessKeySummary, - presenceEntry, - presenceConfig, - colonySweepTick, -}); - export default spacetimedb; -type Schema = InferSchema; -type Tx = ReducerCtx | TransactionCtx; -type ReadCtx = Tx | ViewCtx; -type HttpCtx = HandlerContext; type ApiKeyCreateResult = Infer; // Surface terrain. regolith is the default (no row); the rest are stored. @@ -194,65 +112,6 @@ function senderSubject(ctx: { sender: unknown }): string { : String(ctx.sender); } -function jsonResponse(body: unknown, status = 200): SyncResponse { - return new SyncResponse( - JSON.stringify(body, (_key, value) => - typeof value === 'bigint' ? value.toString() : value - ), - { - status, - headers: { 'content-type': 'application/json' }, - } - ); -} - -function errorResponse( - error: string, - status: number, - extra: Record = {} -): SyncResponse { - return jsonResponse({ ok: false, error, ...extra }, status); -} - -function readBearer(req: Request): string | undefined { - const header = req.headers.get('authorization') ?? ''; - if (!header.toLowerCase().startsWith('bearer ')) return undefined; - const token = header.slice(7).trim(); - return token.length > 0 ? token : undefined; -} - -function safeJson(req: Request): unknown { - try { - return req.json(); - } catch { - throw new SenderError('world.invalid_json'); - } -} - -function asObject(value: unknown): Record { - if (value === null || Array.isArray(value) || typeof value !== 'object') { - throw new SenderError('world.invalid_json'); - } - return value as Record; -} - -function asI32(value: unknown, field: string): number { - if (!Number.isInteger(value)) throw new SenderError(`world.invalid_${field}`); - return value as number; -} - -function asOptionalString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -function asString(value: unknown, field: string): string { - if (typeof value !== 'string') - throw new SenderError(`world.invalid_${field}`); - const out = value.trim(); - if (!out) throw new SenderError(`world.invalid_${field}`); - return out; -} - function assertInBounds(x: number, y: number): void { if (x < 0 || y < 0 || x >= COLONY_WIDTH || y >= COLONY_HEIGHT) { throw new SenderError(`world.out_of_bounds:${x},${y}`); @@ -849,6 +708,8 @@ export const colony_sweep = spacetimedb.reducer( } ); +setColonySweepReducer(colony_sweep); + // Reads. world, world_event, and presence_entry are public tables the // client subscribes to with a WHERE on the colony id. The grid submodule's // tables are reached through these public projection views, filtered by diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts b/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..716262f0622 --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,119 @@ +import * as apiKeys from '@spacetimedb/api-keys/submodule'; +import * as grid from '@spacetimedb/grid/submodule'; +import { + schema, + table, + t, + type HandlerContext, + type InferSchema, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; + +export const world = table( + { name: 'world', public: true }, + { + ownerSubject: t.string().primaryKey(), + gridId: t.u64().index(), + name: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +export const worldEvent = table( + { name: 'world_event', public: true }, + { + eventId: t.u64().primaryKey().autoInc(), + ownerSubject: t.string().index(), + keyPrefix: t.string(), + action: t.string().index(), + allowed: t.bool().index(), + reason: t.string(), + message: t.string(), + createdAt: t.timestamp().index(), + } +); + +export const accessKeySummary = table( + { name: 'access_key_summary', public: false }, + { + keyId: t.string().primaryKey(), + prefix: t.string(), + ownerSubject: t.string().index(), + name: t.string(), + scopesJson: t.string(), + metadataJson: t.option(t.string()), + status: apiKeys.apiKeyStatus.index(), + createdAt: t.timestamp().index(), + expiresAt: t.option(t.timestamp()), + lastUsedAt: t.option(t.timestamp()), + revokedAt: t.option(t.timestamp()), + } +); + +// The public presence table supplies the colony roster and cursors. +export const presenceEntry = table( + { name: 'presence_entry', public: true }, + { + key: t.string().primaryKey(), + scope: t.string().index(), + subject: t.string().index(), + status: t.string().index(), + activity: t.option(t.string()), + payloadJson: t.option(t.string()), + joinedAt: t.timestamp().index(), + lastSeenAt: t.timestamp().index(), + expiresAt: t.timestamp().index(), + updatedAt: t.timestamp(), + } +); + +export const presenceConfig = table( + { name: 'presence_config', public: false }, + { + singleton: t.bool().primaryKey(), + defaultTtlSeconds: t.u32(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), + } +); + +let colonySweepReducer: unknown; + +export const colonySweepTick = table( + { + name: 'colony_sweep_tick', + scheduled: (): any => { + if (!colonySweepReducer) { + throw new Error('colony.sweep_reducer_not_registered'); + } + return colonySweepReducer; + }, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +export function setColonySweepReducer(reducer: unknown): void { + colonySweepReducer = reducer; +} + +export const spacetimedb = schema({ + apiKeys, + grid, + world, + worldEvent, + accessKeySummary, + presenceEntry, + presenceConfig, + colonySweepTick, +}); + +export type Schema = InferSchema; +export type Tx = ReducerCtx | TransactionCtx; +export type ReadCtx = Tx | ViewCtx; +export type HttpCtx = HandlerContext; From 3da6bc0febf0a94343be83c3b27e7b32d6f79b07 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 19:05:27 -0400 Subject: [PATCH 11/33] Organize rate limit example schema --- .../example/spacetimedb/src/index.ts | 51 +++----------- .../example/spacetimedb/src/schema.ts | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 43 deletions(-) create mode 100644 spacetime-rate-limit-ts/example/spacetimedb/src/schema.ts diff --git a/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts b/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts index 08f54b2ed44..7af9a61c281 100644 --- a/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts +++ b/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts @@ -1,13 +1,4 @@ -import { - schema, - table, - t, - Range, - SenderError, - type InferSchema, - type ReducerCtx, - type ViewCtx, -} from 'spacetimedb/server'; +import { t, Range, SenderError, type ViewCtx } from 'spacetimedb/server'; import * as rateLimit from '@spacetimedb/rate-limit/submodule'; import { ScheduleAt, Timestamp } from 'spacetimedb'; import { @@ -52,44 +43,16 @@ const ADMIN_EVENT_VIEW_LIMIT = 1000; import { rateLimitEvent, - reactorRoomState, - reactorPlayerState, reactorEvent, -} from './model'; - -const rateLimitDemoConfig = table( - { name: 'rate_limit_demo_config', public: true }, - { - singleton: t.bool().primaryKey(), - retainEvents: t.u32(), - eventPruneBatch: t.u32(), - updatedAt: t.timestamp(), - } -); -const rateLimitDemoSweepTick = table( - { - name: 'rate_limit_demo_sweep_tick', - scheduled: (): any => rate_limit_demo_sweep, - }, - { - scheduledId: t.u64().primaryKey().autoInc(), - scheduledAt: t.scheduleAt(), - } -); - -const spacetimedb = schema({ - rateLimit, - rateLimitEvent, reactorRoomState, - reactorPlayerState, - reactorEvent, - rateLimitDemoConfig, rateLimitDemoSweepTick, -}); + setSweepReducer, + spacetimedb, + type Schema, + type Tx, +} from './schema'; export default spacetimedb; -type Schema = InferSchema; -type Tx = ReducerCtx; type ReactorStateRow = NonNullable< ReturnType >; @@ -1165,3 +1128,5 @@ export const rate_limit_demo_sweep = spacetimedb.reducer( pruneRateLimitEvents(ctx, retainEvents, pruneBatch); } ); + +setSweepReducer(rate_limit_demo_sweep); diff --git a/spacetime-rate-limit-ts/example/spacetimedb/src/schema.ts b/spacetime-rate-limit-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..b69e98fee96 --- /dev/null +++ b/spacetime-rate-limit-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,66 @@ +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { + schema, + table, + t, + type InferSchema, + type ReducerCtx, +} from 'spacetimedb/server'; +import { + rateLimitEvent, + reactorEvent, + reactorPlayerState, + reactorRoomState, +} from './model'; + +export { + rateLimitEvent, + reactorEvent, + reactorPlayerState, + reactorRoomState, +} from './model'; + +export const rateLimitDemoConfig = table( + { name: 'rate_limit_demo_config', public: true }, + { + singleton: t.bool().primaryKey(), + retainEvents: t.u32(), + eventPruneBatch: t.u32(), + updatedAt: t.timestamp(), + } +); + +let sweepReducer: unknown; + +export const rateLimitDemoSweepTick = table( + { + name: 'rate_limit_demo_sweep_tick', + scheduled: (): any => { + if (!sweepReducer) { + throw new Error('rate_limit_demo.sweep_reducer_not_registered'); + } + return sweepReducer; + }, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +export function setSweepReducer(reducer: unknown): void { + sweepReducer = reducer; +} + +export const spacetimedb = schema({ + rateLimit, + rateLimitEvent, + reactorRoomState, + reactorPlayerState, + reactorEvent, + rateLimitDemoConfig, + rateLimitDemoSweepTick, +}); + +export type Schema = InferSchema; +export type Tx = ReducerCtx; From 41357be41e099db4ae324df3c13c8b2a9b189264 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 19:07:18 -0400 Subject: [PATCH 12/33] Organize presence example schema --- .../example/spacetimedb/src/domain.ts | 2 +- .../example/spacetimedb/src/index.ts | 75 +++---------------- .../example/spacetimedb/src/schema.ts | 74 ++++++++++++++++++ .../example/spacetimedb/src/views.ts | 4 +- 4 files changed, 88 insertions(+), 67 deletions(-) create mode 100644 spacetime-presence-ts/example/spacetimedb/src/schema.ts diff --git a/spacetime-presence-ts/example/spacetimedb/src/domain.ts b/spacetime-presence-ts/example/spacetimedb/src/domain.ts index 32f8095743f..0684306c0e4 100644 --- a/spacetime-presence-ts/example/spacetimedb/src/domain.ts +++ b/spacetime-presence-ts/example/spacetimedb/src/domain.ts @@ -10,7 +10,7 @@ import { consumeRateLimit } from '@spacetimedb/rate-limit/submodule'; import { removePresence, upsertPresence } from '@spacetimedb/presence'; import { PRESENCE_SCOPE_GLOBAL, typingScope } from './chat-policy'; import { ChatUserStatus } from './model'; -import type { DbSchema } from './index'; +import type { DbSchema } from './schema'; const ONE_SECOND_MICROS = 1_000_000n; const GLOBAL_PRESENCE_TTL_SECONDS = 35; diff --git a/spacetime-presence-ts/example/spacetimedb/src/index.ts b/spacetime-presence-ts/example/spacetimedb/src/index.ts index a36457da775..d021147098f 100644 --- a/spacetime-presence-ts/example/spacetimedb/src/index.ts +++ b/spacetime-presence-ts/example/spacetimedb/src/index.ts @@ -1,13 +1,5 @@ import { ScheduleAt } from 'spacetimedb'; -import { - Router, - Range, - schema, - table, - t, - type InferSchema, - type TransactionCtx, -} from 'spacetimedb/server'; +import { Router, Range, t, type TransactionCtx } from 'spacetimedb/server'; import { installPresenceConfig, removePresence, @@ -58,6 +50,12 @@ import { typingScope, } from './chat-policy'; import { registerChatViews } from './views'; +import { + chatSweepTick, + setSweepReducer, + spacetimedb, + type DbSchema, +} from './schema'; const ONE_SECOND_MICROS = 1_000_000n; const TYPING_TTL_SECONDS = 4; @@ -84,22 +82,7 @@ const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { // Chat presence states. The presence-ts submodule's presence_entry.status // stays a free-form string (the submodule is consumer-agnostic); chat_user // pins down the exact set of values this app supports. -import { - chatUserStatus, - chatUser, - server, - serverMember, - room, - roomMember, - message, - messageReaction, - messageThread, - threadMessage, - attachment, - roomReadCursor, - roomActivityEvent, - presenceEntry, -} from './model'; +import { chatUserStatus, message } from './model'; import { canModerateRoom, canReadAttachmentFile, @@ -127,47 +110,9 @@ import { type Tx, } from './domain'; -const presenceConfig = table( - { name: 'presence_config', public: false }, - { - singleton: t.bool().primaryKey(), - defaultTtlSeconds: t.u32(), - sweepBatch: t.u32(), - updatedAt: t.timestamp(), - } -); - -const chatSweepTick = table( - { name: 'chat_sweep_tick', scheduled: (): any => chat_sweep }, - { - scheduledId: t.u64().primaryKey().autoInc(), - scheduledAt: t.scheduleAt(), - } -); - -const spacetimedb = schema({ - auth, - files, - rateLimit, - chatUser, - server, - serverMember, - room, - roomMember, - message, - messageReaction, - messageThread, - threadMessage, - attachment, - roomReadCursor, - roomActivityEvent, - presenceEntry, - presenceConfig, - chatSweepTick, -}); export default spacetimedb; -export type DbSchema = InferSchema; +export type { DbSchema } from './schema'; export const { myServers, myServerMembers, @@ -1135,6 +1080,8 @@ export const chat_sweep = spacetimedb.reducer( } ); +setSweepReducer(chat_sweep); + export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => passwordSignupHandler(ctx.as.auth, req) ); diff --git a/spacetime-presence-ts/example/spacetimedb/src/schema.ts b/spacetime-presence-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..fb018877f55 --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,74 @@ +import * as auth from '@spacetimedb/auth/submodule'; +import * as files from '@spacetimedb/files/submodule'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { schema, table, t, type InferSchema } from 'spacetimedb/server'; +import { + attachment, + chatUser, + message, + messageReaction, + messageThread, + presenceEntry, + room, + roomActivityEvent, + roomMember, + roomReadCursor, + server, + serverMember, + threadMessage, +} from './model'; + +export const presenceConfig = table( + { name: 'presence_config', public: false }, + { + singleton: t.bool().primaryKey(), + defaultTtlSeconds: t.u32(), + sweepBatch: t.u32(), + updatedAt: t.timestamp(), + } +); + +let sweepReducer: unknown; + +export const chatSweepTick = table( + { + name: 'chat_sweep_tick', + scheduled: (): any => { + if (!sweepReducer) { + throw new Error('chat.sweep_reducer_not_registered'); + } + return sweepReducer; + }, + }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +export function setSweepReducer(reducer: unknown): void { + sweepReducer = reducer; +} + +export const spacetimedb = schema({ + auth, + files, + rateLimit, + chatUser, + server, + serverMember, + room, + roomMember, + message, + messageReaction, + messageThread, + threadMessage, + attachment, + roomReadCursor, + roomActivityEvent, + presenceEntry, + presenceConfig, + chatSweepTick, +}); + +export type DbSchema = InferSchema; diff --git a/spacetime-presence-ts/example/spacetimedb/src/views.ts b/spacetime-presence-ts/example/spacetimedb/src/views.ts index 90b11e7cb52..03c9ac6f6ce 100644 --- a/spacetime-presence-ts/example/spacetimedb/src/views.ts +++ b/spacetime-presence-ts/example/spacetimedb/src/views.ts @@ -22,9 +22,9 @@ import { serverMember, threadMessage, } from './model'; -import type { DbSchema } from './index'; +import type { DbSchema } from './schema'; -type SpacetimeDb = typeof import('./index').default; +type SpacetimeDb = typeof import('./schema').spacetimedb; function myRoomIds(ctx: ViewCtx): Set { const out = new Set(); From bfec51674de064affe0f527da885813c5daeb690 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 19:47:22 -0400 Subject: [PATCH 13/33] Align submodules with repository conventions --- .gitignore | 1 - .prettierignore | 2 +- eslint.config.js | 2 +- spacetime-agents-ts/example/package.json | 8 +- .../example/spacetimedb/src/index.ts | 3 +- spacetime-agents-ts/example/src/app.ts | 7 +- .../app/add_agent_admin_identity_reducer.ts | 15 + .../add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/agentRateLimit/consume_procedure.ts | 24 + .../agentRateLimit/rate_limit_config_table.ts | 17 + .../agentRateLimit/reset_buckets_reducer.ts | 15 + .../app/agentRateLimit/run_sweep_procedure.ts | 16 + .../app/agentRateLimit/types.ts | 56 ++ .../agentRateLimit/update_config_reducer.ts | 15 + .../app/agent_override_table.ts | 23 + .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../app/auth/rateLimit/types.ts | 56 ++ .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../src/module_bindings/app/auth/types.ts | 137 +++++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../app/auth/whoami_procedure.ts | 19 + .../app/clear_agent_override_reducer.ts | 15 + .../app/clear_api_key_reducer.ts | 15 + .../app/clear_thread_lock_reducer.ts | 15 + .../app/delete_thread_reducer.ts | 15 + .../src/module_bindings/app/files/types.ts | 32 ++ .../app/generate_thread_title_procedure.ts | 16 + .../app/get_agent_config_status_procedure.ts | 19 + .../app/get_auth_public_key_procedure.ts | 19 + .../example/src/module_bindings/app/index.ts | 387 ++++++++++++++ .../app/link_connection_procedure.ts | 20 + .../app/list_my_sessions_procedure.ts | 19 + .../module_bindings/app/my_auth_user_table.ts | 21 + .../src/module_bindings/app/my_files_table.ts | 28 + .../app/my_message_embeddings_table.ts | 20 + .../module_bindings/app/my_messages_table.ts | 25 + .../app/my_thread_locks_table.ts | 18 + .../module_bindings/app/my_threads_table.ts | 25 + .../app/regenerate_response_procedure.ts | 16 + .../remove_agent_admin_identity_reducer.ts | 15 + .../app/request_cancel_reducer.ts | 15 + .../app/revoke_my_session_reducer.ts | 15 + .../app/revoke_session_reducer.ts | 15 + .../app/send_message_procedure.ts | 24 + .../app/set_agent_override_reducer.ts | 22 + .../app/set_agent_secret_reducer.ts | 17 + .../app/set_api_key_reducer.ts | 16 + .../app/set_auth_config_reducer.ts | 23 + .../app/start_thread_procedure.ts | 19 + .../example/src/module_bindings/app/types.ts | 215 ++++++++ .../module_bindings/app/types/procedures.ts | 34 ++ .../src/module_bindings/app/types/reducers.ts | 42 ++ .../app/unlink_connection_reducer.ts | 13 + .../app/update_profile_reducer.ts | 16 + .../app/update_thread_reducer.ts | 23 + spacetime-agents-ts/spacetimedb/src/index.ts | 3 +- spacetime-api-keys-ts/example/package.json | 8 +- .../example/spacetimedb/src/index.ts | 4 +- .../example/spacetimedb/src/schema.ts | 16 +- spacetime-api-keys-ts/example/src/app.ts | 2 +- .../app/apiKeys/add_admin_identity_reducer.ts | 15 + .../app/apiKeys/api_key_usage_admin_table.ts | 22 + .../app/apiKeys/api_keys_admin_table.ts | 31 ++ .../create_api_key_for_subject_procedure.ts | 25 + .../app/apiKeys/create_api_key_procedure.ts | 24 + .../app/apiKeys/my_api_keys_table.ts | 31 ++ .../apiKeys/remove_admin_identity_reducer.ts | 15 + .../revoke_api_key_for_subject_reducer.ts | 16 + .../app/apiKeys/revoke_api_key_reducer.ts | 15 + .../app/apiKeys/rotate_api_key_procedure.ts | 22 + .../apiKeys/sweep_api_key_usage_reducer.ts | 16 + .../src/module_bindings/app/apiKeys/types.ts | 111 ++++ .../src/module_bindings/app/build_reducer.ts | 18 + .../src/module_bindings/app/clear_reducer.ts | 16 + .../app/clear_world_events_reducer.ts | 13 + .../module_bindings/app/colony_cells_table.ts | 20 + .../app/colony_entities_table.ts | 24 + .../module_bindings/app/colony_grid_table.ts | 26 + .../app/create_access_key_procedure.ts | 24 + .../app/ensure_world_procedure.ts | 19 + .../src/module_bindings/app/grid/types.ts | 69 +++ .../example/src/module_bindings/app/index.ts | 324 ++++++++++++ .../app/my_access_keys_table.ts | 31 ++ .../src/module_bindings/app/plant_reducer.ts | 17 + .../app/presence_entry_table.ts | 24 + .../app/presence_heartbeat_reducer.ts | 21 + .../app/presence_leave_reducer.ts | 15 + .../app/reset_world_reducer.ts | 13 + .../app/revoke_access_key_reducer.ts | 15 + .../app/rotate_access_key_procedure.ts | 22 + .../module_bindings/app/terraform_reducer.ts | 17 + .../example/src/module_bindings/app/types.ts | 159 ++++++ .../module_bindings/app/types/procedures.ts | 19 + .../src/module_bindings/app/types/reducers.ts | 30 ++ .../module_bindings/app/unbuild_reducer.ts | 16 + .../module_bindings/app/world_event_table.ts | 22 + .../src/module_bindings/app/world_table.ts | 19 + spacetime-auth-ts/example/package.json | 8 +- spacetime-auth-ts/example/src/app.ts | 2 +- .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../app/auth/rateLimit/types.ts | 56 ++ .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../src/module_bindings/app/auth/types.ts | 137 +++++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../app/auth/whoami_procedure.ts | 19 + .../app/create_note_reducer.ts | 16 + .../app/delete_note_reducer.ts | 15 + .../app/get_auth_public_key_procedure.ts | 19 + .../example/src/module_bindings/app/index.ts | 259 ++++++++++ .../app/link_connection_reducer.ts | 15 + .../app/list_my_sessions_procedure.ts | 19 + .../module_bindings/app/my_auth_user_table.ts | 21 + .../src/module_bindings/app/my_notes_table.ts | 19 + .../app/revoke_my_session_reducer.ts | 15 + .../app/revoke_session_reducer.ts | 15 + .../app/set_auth_config_reducer.ts | 23 + .../example/src/module_bindings/app/types.ts | 68 +++ .../module_bindings/app/types/procedures.ts | 19 + .../src/module_bindings/app/types/reducers.ts | 28 + .../app/unlink_connection_reducer.ts | 13 + .../app/update_note_reducer.ts | 17 + .../app/update_profile_reducer.ts | 16 + .../module_bindings/app/whoami_procedure.ts | 19 + spacetime-auth-ts/src/mounted/index.ts | 3 +- spacetime-cron-ts/example/package.json | 8 +- spacetime-cron-ts/example/src/app.ts | 4 +- .../module_bindings/app/activity_log_table.ts | 18 + .../module_bindings/app/cleanup_fire_table.ts | 26 + .../module_bindings/app/cron_jobs_table.ts | 30 ++ .../app/cron_reconcile_tick_table.ts | 17 + .../src/module_bindings/app/cron_run_table.ts | 28 + .../module_bindings/app/digest_fire_table.ts | 26 + .../example/src/module_bindings/app/index.ts | 203 ++++++++ .../app/schedule_cron_reducer.ts | 18 + .../app/schedule_every_reducer.ts | 17 + .../example/src/module_bindings/app/types.ts | 153 ++++++ .../module_bindings/app/types/procedures.ts | 10 + .../src/module_bindings/app/types/reducers.ts | 16 + .../app/unschedule_job_reducer.ts | 15 + spacetime-files-ts/example/package.json | 8 +- .../example/scripts/test-downloads.ts | 2 +- spacetime-files-ts/example/src/app.ts | 4 +- .../example/src/context-menu.ts | 2 +- spacetime-files-ts/example/src/downloads.ts | 2 +- .../example/src/list-actions.ts | 2 +- .../app/create_folder_reducer.ts | 15 + .../app/delete_file_reducer.ts | 15 + .../app/delete_folder_reducer.ts | 15 + .../src/module_bindings/app/files/types.ts | 32 ++ .../example/src/module_bindings/app/index.ts | 142 +++++ .../module_bindings/app/move_file_reducer.ts | 16 + .../app/my_file_summaries_table.ts | 21 + .../module_bindings/app/my_folders_table.ts | 21 + .../app/read_file_bytes_procedure.ts | 20 + .../app/rename_file_reducer.ts | 16 + .../app/rename_folder_reducer.ts | 16 + .../app/set_file_visibility_reducer.ts | 16 + .../example/src/module_bindings/app/types.ts | 46 ++ .../module_bindings/app/types/procedures.ts | 13 + .../src/module_bindings/app/types/reducers.ts | 26 + .../app/upload_file_reducer.ts | 18 + spacetime-files-ts/example/src/rendering.ts | 2 +- spacetime-files-ts/example/src/uploads.ts | 2 +- spacetime-files-ts/example/src/viewer.ts | 2 +- spacetime-grid-ts/example/package.json | 8 +- spacetime-grid-ts/example/src/app.ts | 2 +- .../app/actor_directory_table.ts | 24 + .../app/ai_take_turn_procedure.ts | 20 + .../app/attack_unit_procedure.ts | 17 + .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../app/auth/rateLimit/types.ts | 56 ++ .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../src/module_bindings/app/auth/types.ts | 137 +++++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../app/auth/whoami_procedure.ts | 19 + .../app/create_match_procedure.ts | 20 + .../module_bindings/app/end_turn_procedure.ts | 16 + .../app/get_auth_public_key_procedure.ts | 19 + .../app/get_cells_in_range_procedure.ts | 23 + .../src/module_bindings/app/grid/types.ts | 69 +++ .../example/src/module_bindings/app/index.ts | 360 +++++++++++++ .../app/join_match_procedure.ts | 16 + .../app/link_connection_reducer.ts | 15 + .../app/list_my_sessions_procedure.ts | 19 + .../app/lobby_open_matches_table.ts | 17 + .../app/move_unit_procedure.ts | 22 + .../module_bindings/app/my_auth_user_table.ts | 21 + .../app/my_cell_states_table.ts | 20 + .../app/my_grid_entities_table.ts | 24 + .../src/module_bindings/app/my_grids_table.ts | 26 + .../app/my_match_participants_table.ts | 20 + .../module_bindings/app/my_matches_table.ts | 28 + .../app/my_player_units_table.ts | 22 + .../module_bindings/app/npc_actor_table.ts | 18 + .../app/revoke_my_session_reducer.ts | 15 + .../app/revoke_session_reducer.ts | 15 + .../app/set_auth_config_reducer.ts | 23 + .../example/src/module_bindings/app/types.ts | 276 ++++++++++ .../module_bindings/app/types/procedures.ts | 40 ++ .../src/module_bindings/app/types/reducers.ts | 22 + .../module_bindings/app/unit_type_table.ts | 21 + .../app/unlink_connection_reducer.ts | 13 + .../app/update_profile_reducer.ts | 16 + .../module_bindings/app/whoami_procedure.ts | 19 + spacetime-lobby-ts/example/package.json | 8 +- spacetime-lobby-ts/example/src/app.ts | 2 +- spacetime-lobby-ts/example/src/model.ts | 2 +- .../module_bindings/advance_duel_reducer.ts | 15 + .../choose_maneuver_reducer.ts | 22 + .../module_bindings/fallback_to_ai_reducer.ts | 13 + .../src/module_bindings/find_duel_reducer.ts | 13 + .../example/src/module_bindings/index.ts | 422 +++++++++++++++ .../module_bindings/join_duel_room_reducer.ts | 15 + .../src/module_bindings/leave_duel_reducer.ts | 15 + .../lobby/add_admin_identity_reducer.ts | 15 + .../lobby/cancel_ticket_reducer.ts | 15 + .../lobby/close_room_reducer.ts | 15 + .../lobby/expire_tickets_reducer.ts | 15 + .../lobby/get_lobby_status_procedure.ts | 15 + .../lobby/join_queue_reducer.ts | 18 + .../lobby/join_ranked_queue_reducer.ts | 19 + .../lobby/join_room_reducer.ts | 15 + .../lobby/leave_room_reducer.ts | 15 + .../lobby/lobby_admin_match_results_table.ts | 26 + .../lobby/lobby_admin_room_seats_table.ts | 30 ++ .../lobby/lobby_admin_rooms_table.ts | 28 + .../lobby/lobby_admin_tickets_table.ts | 34 ++ .../lobby/lobby_queue_summary_table.ts | 18 + .../lobby/lobby_ranked_leaderboard_table.ts | 21 + .../lobby/my_lobby_ratings_table.ts | 21 + .../lobby/my_lobby_room_seats_table.ts | 30 ++ .../lobby/my_lobby_rooms_table.ts | 28 + .../lobby/my_lobby_tickets_table.ts | 34 ++ .../lobby/remove_admin_identity_reducer.ts | 15 + .../lobby/set_rating_reducer.ts | 17 + .../src/module_bindings/lobby/types.ts | 182 +++++++ .../lobby/update_config_reducer.ts | 16 + .../lobby_queue_summary_table.ts | 18 + .../lobby_ranked_leaderboard_table.ts | 21 + .../module_bindings/maneuver_catalog_table.ts | 34 ++ .../my_duel_combatants_table.ts | 35 ++ .../my_duel_maneuvers_table.ts | 27 + .../my_duel_round_logs_table.ts | 19 + .../src/module_bindings/my_duels_table.ts | 26 + .../module_bindings/my_lobby_ratings_table.ts | 21 + .../my_lobby_room_seats_table.ts | 30 ++ .../module_bindings/my_lobby_rooms_table.ts | 28 + .../module_bindings/my_lobby_tickets_table.ts | 34 ++ .../src/module_bindings/my_profile_table.ts | 25 + .../src/module_bindings/players_table.ts | 25 + .../module_bindings/queue_again_reducer.ts | 15 + .../module_bindings/select_ship_reducer.ts | 21 + .../set_display_name_reducer.ts | 15 + .../src/module_bindings/ship_catalog_table.ts | 31 ++ .../example/src/module_bindings/types.ts | 272 ++++++++++ .../src/module_bindings/types/procedures.ts | 10 + .../src/module_bindings/types/reducers.ts | 28 + spacetime-posthog-ts/example/package.json | 8 +- spacetime-posthog-ts/example/server.ts | 2 +- spacetime-posthog-ts/example/src/app.ts | 2 +- .../src/module_bindings/buy_supply_reducer.ts | 16 + .../module_bindings/buy_upgrade_reducer.ts | 15 + .../cafe_analytics_summary_table.ts | 17 + .../src/module_bindings/cafe_config_table.ts | 20 + .../src/module_bindings/cafe_econ_table.ts | 28 + .../src/module_bindings/cafe_metrics_table.ts | 23 + .../module_bindings/cafe_products_table.ts | 24 + .../src/module_bindings/cafe_queue_table.ts | 25 + .../cafe_recent_activity_table.ts | 24 + .../cafe_recent_purchases_table.ts | 24 + .../cafe_recent_sessions_table.ts | 26 + .../module_bindings/cafe_scenarios_table.ts | 25 + .../module_bindings/cafe_variants_table.ts | 29 ++ .../flush_analytics_procedure.ts | 16 + .../example/src/module_bindings/index.ts | 327 ++++++++++++ .../module_bindings/init_session_reducer.ts | 13 + .../posthog/add_admin_identity_procedure.ts | 16 + .../posthog/capture_now_procedure.ts | 18 + .../posthog/enqueue_event_reducer.ts | 18 + .../posthog/flush_outbox_procedure.ts | 16 + .../posthog/get_feature_flag_procedure.ts | 19 + .../get_posthog_config_status_procedure.ts | 15 + .../posthog_delivery_log_admin_table.ts | 30 ++ .../posthog/posthog_outbox_admin_table.ts | 35 ++ .../remove_admin_identity_procedure.ts | 16 + .../posthog/set_posthog_config_procedure.ts | 17 + .../src/module_bindings/posthog/types.ts | 112 ++++ .../posthog_delivery_log_admin_table.ts | 22 + .../posthog_outbox_admin_table.ts | 35 ++ .../reset_simulation_reducer.ts | 15 + .../select_scenario_reducer.ts | 15 + .../set_experiment_variant_reducer.ts | 16 + .../set_featured_variant_reducer.ts | 15 + .../set_product_active_reducer.ts | 16 + .../set_variant_active_reducer.ts | 16 + .../set_variant_discount_reducer.ts | 16 + .../set_variant_price_reducer.ts | 16 + .../module_bindings/simulate_tick_reducer.ts | 16 + .../module_bindings/sync_catalog_reducer.ts | 16 + .../example/src/module_bindings/types.ts | 273 ++++++++++ .../src/module_bindings/types/procedures.ts | 13 + .../src/module_bindings/types/reducers.ts | 36 ++ spacetime-presence-ts/README.md | 3 +- spacetime-presence-ts/example/package.json | 8 +- .../example/spacetimedb/src/index.ts | 10 +- .../example/spacetimedb/src/schema.ts | 16 +- spacetime-presence-ts/example/src/app.ts | 4 +- .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../app/auth/rateLimit/types.ts | 56 ++ .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../src/module_bindings/app/auth/types.ts | 137 +++++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../app/auth/whoami_procedure.ts | 19 + .../app/create_room_reducer.ts | 18 + .../app/create_server_reducer.ts | 15 + .../app/delete_message_reducer.ts | 15 + .../app/delete_room_reducer.ts | 15 + .../app/delete_server_reducer.ts | 15 + .../app/delete_thread_message_reducer.ts | 15 + .../app/edit_message_reducer.ts | 16 + .../app/edit_thread_message_reducer.ts | 16 + .../src/module_bindings/app/files/types.ts | 32 ++ .../app/get_attachment_file_procedure.ts | 20 + .../app/get_auth_public_key_procedure.ts | 19 + .../module_bindings/app/heartbeat_reducer.ts | 13 + .../example/src/module_bindings/app/index.ts | 488 ++++++++++++++++++ .../module_bindings/app/join_room_reducer.ts | 15 + .../app/join_server_reducer.ts | 15 + .../module_bindings/app/leave_room_reducer.ts | 15 + .../app/leave_server_reducer.ts | 15 + .../app/link_connection_reducer.ts | 15 + .../app/list_my_sessions_procedure.ts | 19 + .../app/mark_room_read_reducer.ts | 15 + .../module_bindings/app/my_auth_user_table.ts | 21 + .../app/my_chat_users_table.ts | 27 + .../app/my_message_threads_table.ts | 20 + .../app/my_presence_entries_table.ts | 24 + .../app/my_rate_limit_status_table.ts | 19 + .../app/my_room_attachments_table.ts | 27 + .../app/my_room_members_table.ts | 19 + .../app/my_room_message_reactions_table.ts | 19 + .../app/my_room_messages_table.ts | 23 + .../app/my_room_read_cursors_table.ts | 19 + .../src/module_bindings/app/my_rooms_table.ts | 24 + .../app/my_server_members_table.ts | 19 + .../module_bindings/app/my_servers_table.ts | 18 + .../app/my_thread_messages_table.ts | 20 + .../app/pin_message_reducer.ts | 15 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/rateLimit/consume_procedure.ts | 24 + .../app/rateLimit/rate_limit_config_table.ts | 17 + .../app/rateLimit/reset_buckets_reducer.ts | 15 + .../app/rateLimit/run_sweep_procedure.ts | 16 + .../module_bindings/app/rateLimit/types.ts | 56 ++ .../app/rateLimit/update_config_reducer.ts | 15 + .../app/rename_room_reducer.ts | 16 + .../app/rename_server_reducer.ts | 16 + .../app/revoke_my_session_reducer.ts | 15 + .../app/revoke_session_reducer.ts | 15 + .../app/search_messages_procedure.ts | 21 + .../app/send_message_reducer.ts | 24 + .../app/send_thread_message_reducer.ts | 16 + .../app/set_auth_config_reducer.ts | 23 + .../app/set_display_name_reducer.ts | 15 + .../app/set_room_category_reducer.ts | 16 + .../app/set_room_privacy_reducer.ts | 16 + .../module_bindings/app/set_status_reducer.ts | 21 + .../app/start_typing_reducer.ts | 15 + .../app/stop_typing_reducer.ts | 15 + .../app/toggle_reaction_reducer.ts | 16 + .../example/src/module_bindings/app/types.ts | 296 +++++++++++ .../module_bindings/app/types/procedures.ts | 25 + .../src/module_bindings/app/types/reducers.ts | 76 +++ .../app/unlink_connection_reducer.ts | 13 + .../app/unpin_message_reducer.ts | 15 + .../app/update_profile_reducer.ts | 16 + .../module_bindings/app/whoami_procedure.ts | 19 + spacetime-presence-ts/src/mounted/index.ts | 3 +- spacetime-rate-limit-ts/example/package.json | 8 +- .../example/spacetimedb/src/index.ts | 4 +- .../example/spacetimedb/src/schema.ts | 16 +- spacetime-rate-limit-ts/example/src/app.ts | 2 +- .../app/buy_upgrade_procedure.ts | 20 + .../example/src/module_bindings/app/index.ts | 257 +++++++++ .../app/overcharge_procedure.ts | 19 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/rateLimit/consume_procedure.ts | 24 + .../app/rateLimit/rate_limit_config_table.ts | 17 + .../app/rateLimit/reset_buckets_reducer.ts | 15 + .../app/rateLimit/run_sweep_procedure.ts | 16 + .../module_bindings/app/rateLimit/types.ts | 56 ++ .../app/rateLimit/update_config_reducer.ts | 15 + .../app/rate_limit_demo_config_table.ts | 18 + .../app/rate_limit_events_admin_table.ts | 26 + .../app/reactor_events_table.ts | 25 + .../app/reactor_limit_status_table.ts | 21 + .../app/reactor_players_table.ts | 24 + .../module_bindings/app/reactor_shop_table.ts | 21 + .../app/reactor_state_table.ts | 31 ++ .../app/repair_reactor_procedure.ts | 19 + .../module_bindings/app/reset_demo_reducer.ts | 13 + .../app/run_sweep_procedure.ts | 16 + .../app/set_player_color_reducer.ts | 15 + .../app/start_reactor_procedure.ts | 19 + .../app/tap_reactor_procedure.ts | 19 + .../example/src/module_bindings/app/types.ts | 157 ++++++ .../module_bindings/app/types/procedures.ts | 28 + .../src/module_bindings/app/types/reducers.ts | 16 + .../app/update_config_reducer.ts | 17 + .../src/submodule/operations.ts | 4 +- .../src/submodule/schema.ts | 16 +- spacetime-resend-ts/example/package.json | 8 +- spacetime-resend-ts/example/server.ts | 2 +- spacetime-resend-ts/example/src/app.ts | 2 +- .../clear_dispatches_procedure.ts | 19 + .../delete_dispatch_procedure.ts | 20 + .../example/src/module_bindings/index.ts | 239 +++++++++ .../my_dispatch_delivery_events_table.ts | 20 + .../my_dispatch_emails_table.ts | 45 ++ .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../rateLimit/consume_procedure.ts | 24 + .../rateLimit/rate_limit_config_table.ts | 17 + .../rateLimit/reset_buckets_reducer.ts | 15 + .../rateLimit/run_sweep_procedure.ts | 16 + .../src/module_bindings/rateLimit/types.ts | 56 ++ .../rateLimit/update_config_reducer.ts | 15 + .../resend/add_admin_identity_procedure.ts | 16 + .../resend/cancel_email_procedure.ts | 16 + .../resend/get_email_procedure.ts | 20 + .../get_resend_config_status_procedure.ts | 19 + .../resend/ingest_resend_webhook_reducer.ts | 19 + ...ist_delivery_events_for_email_procedure.ts | 20 + .../resend/list_emails_by_org_id_procedure.ts | 20 + .../resend/list_emails_by_status_procedure.ts | 23 + .../list_emails_by_user_id_procedure.ts | 20 + .../resend/remove_admin_identity_procedure.ts | 16 + .../resend/replay_webhook_event_reducer.ts | 15 + .../resend/resend_api_request_procedure.ts | 23 + .../resend/send_email_procedure.ts | 31 ++ .../resend/set_resend_config_procedure.ts | 18 + .../src/module_bindings/resend/types.ts | 123 +++++ .../send_dispatch_procedure.ts | 22 + .../set_dispatch_policy_procedure.ts | 16 + .../example/src/module_bindings/types.ts | 91 ++++ .../src/module_bindings/types/procedures.ts | 22 + .../src/module_bindings/types/reducers.ts | 10 + spacetime-retry-ts/README.md | 6 +- spacetime-retry-ts/spacetimedb/src/index.ts | 2 +- spacetime-retry-ts/src/submodule.ts | 12 - spacetime-stripe-ts/example/package.json | 9 +- spacetime-stripe-ts/example/server.ts | 6 +- spacetime-stripe-ts/example/src/app.ts | 2 +- .../app/add_admin_identity_procedure.ts | 16 + .../app/clear_store_product_price_reducer.ts | 15 + .../app/configure_stripe_procedure.ts | 18 + ...create_store_checkout_session_procedure.ts | 30 ++ .../get_or_create_store_customer_procedure.ts | 22 + ...get_store_webhook_event_count_procedure.ts | 15 + .../example/src/module_bindings/app/index.ts | 313 +++++++++++ .../app/list_store_products_json_procedure.ts | 15 + .../app/remove_admin_identity_procedure.ts | 16 + .../seed_default_store_products_reducer.ts | 15 + .../app/set_store_product_price_reducer.ts | 16 + .../app/store_product_table.ts | 25 + .../app/store_stripe_api_request_procedure.ts | 23 + .../stripe/add_admin_identity_procedure.ts | 16 + .../stripe/cancel_subscription_procedure.ts | 17 + .../create_checkout_session_procedure.ts | 30 ++ ...reate_customer_portal_session_procedure.ts | 21 + .../app/stripe/create_customer_procedure.ts | 23 + .../create_or_update_customer_procedure.ts | 19 + .../stripe/get_checkout_session_procedure.ts | 20 + .../stripe/get_customer_by_email_procedure.ts | 20 + .../get_customer_by_user_id_procedure.ts | 20 + .../app/stripe/get_customer_procedure.ts | 20 + .../get_or_create_customer_procedure.ts | 22 + .../app/stripe/get_payment_procedure.ts | 20 + .../get_remote_checkout_session_procedure.ts | 20 + .../get_stripe_config_status_procedure.ts | 19 + .../get_subscription_by_org_id_procedure.ts | 20 + .../app/stripe/get_subscription_procedure.ts | 20 + .../get_webhook_event_count_procedure.ts | 15 + .../stripe/ingest_stripe_webhook_reducer.ts | 19 + .../list_checkout_sessions_procedure.ts | 20 + .../list_invoices_by_org_id_procedure.ts | 20 + .../list_invoices_by_user_id_procedure.ts | 20 + .../app/stripe/list_invoices_procedure.ts | 20 + .../list_payments_by_org_id_procedure.ts | 20 + .../list_payments_by_user_id_procedure.ts | 20 + .../app/stripe/list_payments_procedure.ts | 20 + .../list_subscriptions_by_org_id_procedure.ts | 20 + ...list_subscriptions_by_user_id_procedure.ts | 20 + .../stripe/list_subscriptions_procedure.ts | 20 + ...scriptions_with_creation_time_procedure.ts | 20 + .../reactivate_subscription_procedure.ts | 16 + .../stripe/remove_admin_identity_procedure.ts | 16 + .../stripe/replay_webhook_event_reducer.ts | 15 + .../app/stripe/set_stripe_config_procedure.ts | 18 + ...stripe_webhook_signing_secret_procedure.ts | 16 + .../stripe/stripe_api_request_procedure.ts | 23 + .../src/module_bindings/app/stripe/types.ts | 202 ++++++++ .../stripe/update_payment_customer_reducer.ts | 16 + .../update_subscription_metadata_procedure.ts | 19 + ..._subscription_quantity_internal_reducer.ts | 16 + .../update_subscription_quantity_procedure.ts | 17 + .../app/stripe/upsert_customer_reducer.ts | 20 + .../app/stripe/upsert_subscription_reducer.ts | 25 + .../stripe/validate_stripe_price_procedure.ts | 20 + ...nc_store_products_with_stripe_procedure.ts | 15 + .../example/src/module_bindings/app/types.ts | 71 +++ .../module_bindings/app/types/procedures.ts | 40 ++ .../src/module_bindings/app/types/reducers.ts | 18 + .../app/upsert_store_product_reducer.ts | 23 + .../validate_store_stripe_price_procedure.ts | 20 + tools/run-example-smokes.mjs | 2 +- 571 files changed, 17001 insertions(+), 179 deletions(-) create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/add_agent_admin_identity_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/consume_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/rate_limit_config_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/run_sweep_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/update_config_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agent_override_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/whoami_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/clear_agent_override_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/clear_api_key_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/clear_thread_lock_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/delete_thread_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/files/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/generate_thread_title_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/get_agent_config_status_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/link_connection_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_auth_user_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_files_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_message_embeddings_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_messages_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_thread_locks_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_threads_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/regenerate_response_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/remove_agent_admin_identity_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/request_cancel_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/revoke_session_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/send_message_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/set_agent_override_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/set_agent_secret_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/set_api_key_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/set_auth_config_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/start_thread_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/unlink_connection_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/update_profile_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/update_thread_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/add_admin_identity_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_key_usage_admin_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_keys_admin_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_for_subject_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/my_api_keys_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/remove_admin_identity_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_for_subject_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/rotate_api_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/sweep_api_key_usage_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/types.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/build_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/clear_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/clear_world_events_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/colony_cells_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/colony_entities_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/colony_grid_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/create_access_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/ensure_world_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/grid/types.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/my_access_keys_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/plant_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/presence_entry_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/presence_heartbeat_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/presence_leave_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/reset_world_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/revoke_access_key_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/rotate_access_key_procedure.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/terraform_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/unbuild_reducer.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/world_event_table.ts create mode 100644 spacetime-api-keys-ts/example/src/module_bindings/app/world_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/types.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/types.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/whoami_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/create_note_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/delete_note_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/link_connection_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/my_auth_user_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/my_notes_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/revoke_session_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/set_auth_config_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/unlink_connection_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/update_note_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/update_profile_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/whoami_procedure.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/activity_log_table.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/cleanup_fire_table.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/cron_jobs_table.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/cron_reconcile_tick_table.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/cron_run_table.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/digest_fire_table.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/schedule_cron_reducer.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/schedule_every_reducer.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-cron-ts/example/src/module_bindings/app/unschedule_job_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/create_folder_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/delete_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/delete_folder_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/files/types.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/move_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/my_file_summaries_table.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/my_folders_table.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/read_file_bytes_procedure.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/rename_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/rename_folder_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/set_file_visibility_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/upload_file_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/actor_directory_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/ai_take_turn_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/attack_unit_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/types.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/types.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/auth/whoami_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/create_match_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/end_turn_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/get_cells_in_range_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/grid/types.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/join_match_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/link_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/lobby_open_matches_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/move_unit_procedure.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_auth_user_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_cell_states_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_grid_entities_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_grids_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_match_participants_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_matches_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/my_player_units_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/npc_actor_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/revoke_session_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/set_auth_config_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/unit_type_table.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/unlink_connection_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/update_profile_reducer.ts create mode 100644 spacetime-grid-ts/example/src/module_bindings/app/whoami_procedure.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/advance_duel_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/choose_maneuver_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/fallback_to_ai_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/find_duel_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/index.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/join_duel_room_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/leave_duel_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/add_admin_identity_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/cancel_ticket_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/close_room_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/expire_tickets_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/get_lobby_status_procedure.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/join_queue_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/join_ranked_queue_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/join_room_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/leave_room_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_match_results_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_room_seats_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_rooms_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_tickets_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_queue_summary_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_ranked_leaderboard_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_ratings_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_room_seats_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_rooms_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_tickets_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/remove_admin_identity_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/set_rating_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/types.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby/update_config_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby_queue_summary_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/lobby_ranked_leaderboard_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/maneuver_catalog_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_duel_combatants_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_duel_maneuvers_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_duel_round_logs_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_duels_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_lobby_ratings_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_lobby_room_seats_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_lobby_rooms_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_lobby_tickets_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/my_profile_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/players_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/queue_again_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/select_ship_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/set_display_name_reducer.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/ship_catalog_table.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/types.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/types/procedures.ts create mode 100644 spacetime-lobby-ts/example/src/module_bindings/types/reducers.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/buy_supply_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/buy_upgrade_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_analytics_summary_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_config_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_econ_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_metrics_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_products_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_queue_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_recent_activity_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_recent_purchases_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_recent_sessions_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_scenarios_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_variants_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/flush_analytics_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/index.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/init_session_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/add_admin_identity_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/capture_now_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/enqueue_event_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/flush_outbox_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/get_feature_flag_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/get_posthog_config_status_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_delivery_log_admin_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_outbox_admin_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/remove_admin_identity_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/set_posthog_config_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/types.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog_delivery_log_admin_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog_outbox_admin_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/reset_simulation_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/select_scenario_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_experiment_variant_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_featured_variant_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_product_active_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_variant_active_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_variant_discount_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_variant_price_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/simulate_tick_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/sync_catalog_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/types.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/types/procedures.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/types/reducers.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/auth/whoami_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/create_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/create_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/delete_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/delete_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/delete_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/delete_thread_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/edit_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/edit_thread_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/files/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/get_attachment_file_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/heartbeat_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/join_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/join_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/leave_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/leave_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/link_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/mark_room_read_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_auth_user_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_chat_users_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_message_threads_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_presence_entries_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_rate_limit_status_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_attachments_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_members_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_message_reactions_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_messages_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_room_read_cursors_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_rooms_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_server_members_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_servers_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/my_thread_messages_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/pin_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rename_room_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/rename_server_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/revoke_session_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/search_messages_procedure.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/send_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/send_thread_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_auth_config_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_display_name_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_room_category_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_room_privacy_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/set_status_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/start_typing_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/stop_typing_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/toggle_reaction_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/unlink_connection_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/unpin_message_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/update_profile_reducer.ts create mode 100644 spacetime-presence-ts/example/src/module_bindings/app/whoami_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/buy_upgrade_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/overcharge_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/types.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rate_limit_demo_config_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/rate_limit_events_admin_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_events_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_limit_status_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_players_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_shop_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_state_table.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/repair_reactor_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/reset_demo_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/run_sweep_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/set_player_color_reducer.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/start_reactor_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/tap_reactor_procedure.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-rate-limit-ts/example/src/module_bindings/app/update_config_reducer.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/clear_dispatches_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/delete_dispatch_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/index.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/my_dispatch_delivery_events_table.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/my_dispatch_emails_table.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/rateLimit/consume_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/rateLimit/types.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/rateLimit/update_config_reducer.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/add_admin_identity_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/cancel_email_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/get_email_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/get_resend_config_status_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/ingest_resend_webhook_reducer.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/list_delivery_events_for_email_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_org_id_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_status_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_user_id_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/remove_admin_identity_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/replay_webhook_event_reducer.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/resend_api_request_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/send_email_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/set_resend_config_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/resend/types.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/send_dispatch_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/set_dispatch_policy_procedure.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/types.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/types/procedures.ts create mode 100644 spacetime-resend-ts/example/src/module_bindings/types/reducers.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts create mode 100644 spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts diff --git a/.gitignore b/.gitignore index cbb51dbb814..c4bd1df8b38 100644 --- a/.gitignore +++ b/.gitignore @@ -210,7 +210,6 @@ __pycache__/ spacetime-*-ts/example/.stdb-* spacetime-*-ts/example/public/app.js spacetime-*-ts/example/public/app.js.map -spacetime-*-ts/example/src/codegen/ spacetime-*-ts/ts-codegen/ /protobuf diff --git a/.prettierignore b/.prettierignore index 635f97ef4d8..373df442c74 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,6 @@ target coverage **/public/app.js **/public/app.js.map -**/src/codegen/** +**/src/module_bindings/** **/ts-codegen/** .stdb-* diff --git a/eslint.config.js b/eslint.config.js index cd3133ccd5d..bef1d92015a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,7 +21,7 @@ export default tseslint.config( '**/build/**', '**/coverage/**', '**/public/app.js', - '**/src/codegen/**', + '**/src/module_bindings/**', '**/ts-codegen/**', '**/templates/angular-ts/.angular/**', ], diff --git a/spacetime-agents-ts/example/package.json b/spacetime-agents-ts/example/package.json index ca7c1c40f95..80779362c00 100644 --- a/spacetime-agents-ts/example/package.json +++ b/spacetime-agents-ts/example/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", - "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-agents-example && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-agents-example && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-agents-example && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-agents-example && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "test:unit": "node scripts/test-markdown.mjs", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-agents-ts/example/spacetimedb/src/index.ts b/spacetime-agents-ts/example/spacetimedb/src/index.ts index f32cf7bc9a7..6104bccc195 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/index.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/index.ts @@ -92,7 +92,7 @@ import { } from './model'; const threadLockSweeperTick = table( - { name: 'thread_lock_sweeper_tick', scheduled: (): any => thread_lock_sweep }, + { name: 'thread_lock_sweeper_tick' }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), @@ -1033,6 +1033,7 @@ export const regenerate_response = spacetimedb.procedure( ); export const thread_lock_sweep = spacetimedb.reducer( + { onSchedule: threadLockSweeperTick }, { arg: threadLockSweeperTick.rowType }, (ctx, _arg) => { const secret = ctx.db.agentSecret.singleton.find(true); diff --git a/spacetime-agents-ts/example/src/app.ts b/spacetime-agents-ts/example/src/app.ts index 764e0d07f88..594fcb48b8e 100644 --- a/spacetime-agents-ts/example/src/app.ts +++ b/spacetime-agents-ts/example/src/app.ts @@ -5,8 +5,11 @@ import { type ErrorContext, type EventContext, type SubscriptionHandle, -} from './codegen/app'; -import type { File as FileRow, AgentConfigStatus } from './codegen/app/types'; +} from './module_bindings/app'; +import type { + File as FileRow, + AgentConfigStatus, +} from './module_bindings/app/types'; interface AuthUser { userId: string; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/add_agent_admin_identity_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/add_agent_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/add_agent_admin_identity_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/add_rate_limit_admin_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/admin_rate_limit_buckets_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/consume_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/rate_limit_config_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/reset_buckets_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/run_sweep_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/update_config_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agent_override_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/agent_override_table.ts new file mode 100644 index 00000000000..e960774db2d --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agent_override_table.ts @@ -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({ + agentName: __t.string().primaryKey().name("agent_name"), + provider: __t.option(__t.string()), + model: __t.option(__t.string()), + systemPrompt: __t.option(__t.string()).name("system_prompt"), + maxTurns: __t.option(__t.u32()).name("max_turns"), + maxHistoryMessages: __t.option(__t.u32()).name("max_history_messages"), + maxTokens: __t.option(__t.u32()).name("max_tokens"), + retries: __t.option(__t.u32()), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/types.ts @@ -0,0 +1,137 @@ +// 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 const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/whoami_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/clear_agent_override_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/clear_agent_override_reducer.ts new file mode 100644 index 00000000000..9d9d7df0ddd --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/clear_agent_override_reducer.ts @@ -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 { + agentName: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/clear_api_key_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/clear_api_key_reducer.ts new file mode 100644 index 00000000000..ce029b88adf --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/clear_api_key_reducer.ts @@ -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 { + provider: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/clear_thread_lock_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/clear_thread_lock_reducer.ts new file mode 100644 index 00000000000..452ed4063c6 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/clear_thread_lock_reducer.ts @@ -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 { + threadId: __t.u64(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/delete_thread_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/delete_thread_reducer.ts new file mode 100644 index 00000000000..452ed4063c6 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/delete_thread_reducer.ts @@ -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 { + threadId: __t.u64(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/files/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/files/types.ts new file mode 100644 index 00000000000..a8336b9566f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/files/types.ts @@ -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"; + +export const File = __t.object("File", { + id: __t.u64(), + ownerPathKey: __t.string(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const FileBlob = __t.object("FileBlob", { + fileId: __t.u64(), + bytes: __t.byteArray(), +}); +export type FileBlob = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/generate_thread_title_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/generate_thread_title_procedure.ts new file mode 100644 index 00000000000..398618113fb --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/generate_thread_title_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + threadId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/get_agent_config_status_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/get_agent_config_status_procedure.ts new file mode 100644 index 00000000000..359fe0fafab --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/get_agent_config_status_procedure.ts @@ -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"; + +import { + AgentConfigStatus, +} from "./types"; + +export const params = { +}; +export const returnType = AgentConfigStatus \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/index.ts b/spacetime-agents-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..bd996e4cce9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,387 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 AddAgentAdminIdentityReducer from "./add_agent_admin_identity_reducer"; +import ClearAgentOverrideReducer from "./clear_agent_override_reducer"; +import ClearApiKeyReducer from "./clear_api_key_reducer"; +import ClearThreadLockReducer from "./clear_thread_lock_reducer"; +import DeleteThreadReducer from "./delete_thread_reducer"; +import RemoveAgentAdminIdentityReducer from "./remove_agent_admin_identity_reducer"; +import RequestCancelReducer from "./request_cancel_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SetAgentOverrideReducer from "./set_agent_override_reducer"; +import SetAgentSecretReducer from "./set_agent_secret_reducer"; +import SetApiKeyReducer from "./set_api_key_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; +import UpdateThreadReducer from "./update_thread_reducer"; + +// Import all procedure arg schemas +import * as GenerateThreadTitleProcedure from "./generate_thread_title_procedure"; +import * as GetAgentConfigStatusProcedure from "./get_agent_config_status_procedure"; +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as LinkConnectionProcedure from "./link_connection_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as RegenerateResponseProcedure from "./regenerate_response_procedure"; +import * as SendMessageProcedure from "./send_message_procedure"; +import * as StartThreadProcedure from "./start_thread_procedure"; + +// Import all table schema definitions +import AgentOverrideRow from "./agent_override_table"; +import MyAuthUserRow from "./my_auth_user_table"; +import MyFilesRow from "./my_files_table"; +import MyMessageEmbeddingsRow from "./my_message_embeddings_table"; +import MyMessagesRow from "./my_messages_table"; +import MyThreadLocksRow from "./my_thread_locks_table"; +import MyThreadsRow from "./my_threads_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import AgentRateLimit_RateLimitConfigRow from "./agentRateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; +import AgentRateLimit_AdminRateLimitBucketsRow from "./agentRateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; +import AgentRateLimit_AddRateLimitAdminReducer from "./agentRateLimit/add_rate_limit_admin_reducer"; +import AgentRateLimit_ResetBucketsReducer from "./agentRateLimit/reset_buckets_reducer"; +import AgentRateLimit_UpdateConfigReducer from "./agentRateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; +import * as AgentRateLimit_ConsumeProcedure from "./agentRateLimit/consume_procedure"; +import * as AgentRateLimit_RunSweepProcedure from "./agentRateLimit/run_sweep_procedure"; + +/** 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({ + agentOverride: __table({ + name: 'agent_override', + indexes: [ + { accessor: 'agentName', name: 'agent_override_agent_name_idx_btree', algorithm: 'btree', columns: [ + 'agentName', + ] }, + ], + constraints: [ + { name: 'agent_override_agent_name_key', constraint: 'unique', columns: ['agentName'] }, + ], + }, AgentOverrideRow), + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myFiles: __table({ + name: 'my_files', + indexes: [ + ], + constraints: [ + ], + }, MyFilesRow), + myMessageEmbeddings: __table({ + name: 'my_message_embeddings', + indexes: [ + ], + constraints: [ + ], + }, MyMessageEmbeddingsRow), + myMessages: __table({ + name: 'my_messages', + indexes: [ + ], + constraints: [ + ], + }, MyMessagesRow), + myThreadLocks: __table({ + name: 'my_thread_locks', + indexes: [ + ], + constraints: [ + ], + }, MyThreadLocksRow), + myThreads: __table({ + name: 'my_threads', + indexes: [ + ], + constraints: [ + ], + }, MyThreadsRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "agentRateLimit.rate_limit_config": __table({ + name: 'agentRateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AgentRateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), + "agentRateLimit.admin_rate_limit_buckets": __table({ + name: 'agentRateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AgentRateLimit_AdminRateLimitBucketsRow), +}); + +/** 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( + __reducerSchema("add_agent_admin_identity", AddAgentAdminIdentityReducer), + __reducerSchema("clear_agent_override", ClearAgentOverrideReducer), + __reducerSchema("clear_api_key", ClearApiKeyReducer), + __reducerSchema("clear_thread_lock", ClearThreadLockReducer), + __reducerSchema("delete_thread", DeleteThreadReducer), + __reducerSchema("remove_agent_admin_identity", RemoveAgentAdminIdentityReducer), + __reducerSchema("request_cancel", RequestCancelReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("set_agent_override", SetAgentOverrideReducer), + __reducerSchema("set_agent_secret", SetAgentSecretReducer), + __reducerSchema("set_api_key", SetApiKeyReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("update_thread", UpdateThreadReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), + __reducerSchema("agentRateLimit.add_rate_limit_admin", AgentRateLimit_AddRateLimitAdminReducer), + __reducerSchema("agentRateLimit.reset_buckets", AgentRateLimit_ResetBucketsReducer), + __reducerSchema("agentRateLimit.update_config", AgentRateLimit_UpdateConfigReducer), +); + +/** 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("generate_thread_title", GenerateThreadTitleProcedure.params, GenerateThreadTitleProcedure.returnType), + __procedureSchema("get_agent_config_status", GetAgentConfigStatusProcedure.params, GetAgentConfigStatusProcedure.returnType), + __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("link_connection", LinkConnectionProcedure.params, LinkConnectionProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("regenerate_response", RegenerateResponseProcedure.params, RegenerateResponseProcedure.returnType), + __procedureSchema("send_message", SendMessageProcedure.params, SendMessageProcedure.returnType), + __procedureSchema("start_thread", StartThreadProcedure.params, StartThreadProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), + __procedureSchema("agentRateLimit.consume", AgentRateLimit_ConsumeProcedure.params, AgentRateLimit_ConsumeProcedure.returnType), + __procedureSchema("agentRateLimit.run_sweep", AgentRateLimit_RunSweepProcedure.params, AgentRateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + agentOverride: __qb.agentOverride, + myAuthUser: __qb.myAuthUser, + myFiles: __qb.myFiles, + myMessageEmbeddings: __qb.myMessageEmbeddings, + myMessages: __qb.myMessages, + myThreadLocks: __qb.myThreadLocks, + myThreads: __qb.myThreads, + agentRateLimit: { + rateLimitConfig: __qb["agentRateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["agentRateLimit.admin_rate_limit_buckets"], + }, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + addAgentAdminIdentity: __reducerAccessors.addAgentAdminIdentity, + clearAgentOverride: __reducerAccessors.clearAgentOverride, + clearApiKey: __reducerAccessors.clearApiKey, + clearThreadLock: __reducerAccessors.clearThreadLock, + deleteThread: __reducerAccessors.deleteThread, + removeAgentAdminIdentity: __reducerAccessors.removeAgentAdminIdentity, + requestCancel: __reducerAccessors.requestCancel, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + setAgentOverride: __reducerAccessors.setAgentOverride, + setAgentSecret: __reducerAccessors.setAgentSecret, + setApiKey: __reducerAccessors.setApiKey, + setAuthConfig: __reducerAccessors.setAuthConfig, + unlinkConnection: __reducerAccessors.unlinkConnection, + updateProfile: __reducerAccessors.updateProfile, + updateThread: __reducerAccessors.updateThread, + agentRateLimit: { + addRateLimitAdmin: __reducerAccessors["agentRateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["agentRateLimit.resetBuckets"], + updateConfig: __reducerAccessors["agentRateLimit.updateConfig"], + }, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + generateThreadTitle: __procedureAccessors.generateThreadTitle, + getAgentConfigStatus: __procedureAccessors.getAgentConfigStatus, + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + linkConnection: __procedureAccessors.linkConnection, + listMySessions: __procedureAccessors.listMySessions, + regenerateResponse: __procedureAccessors.regenerateResponse, + sendMessage: __procedureAccessors.sendMessage, + startThread: __procedureAccessors.startThread, + agentRateLimit: { + consume: __procedureAccessors["agentRateLimit.consume"], + runSweep: __procedureAccessors["agentRateLimit.runSweep"], + }, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/link_connection_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/link_connection_procedure.ts new file mode 100644 index 00000000000..9c7f85a8337 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/link_connection_procedure.ts @@ -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"; + +import { + LinkConnectionResult, +} from "./types"; + +export const params = { + sessionToken: __t.string(), +}; +export const returnType = LinkConnectionResult \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_auth_user_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_files_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_files_table.ts new file mode 100644 index 00000000000..90659027bc4 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_files_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64(), + fileId: __t.u64().name("file_id"), + path: __t.string(), + ownerUserId: __t.string().name("owner_user_id"), + mimeType: __t.string().name("mime_type"), + size: __t.u64(), + sha256Hex: __t.string().name("sha_256_hex"), + visibility: __t.string(), + filename: __t.option(__t.string()), + messageId: __t.option(__t.u64()).name("message_id"), + threadId: __t.option(__t.u64()).name("thread_id"), + ordinal: __t.u32(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_message_embeddings_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_message_embeddings_table.ts new file mode 100644 index 00000000000..ad69d2a6150 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_message_embeddings_table.ts @@ -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({ + messageId: __t.u64().primaryKey().name("message_id"), + threadId: __t.u64().name("thread_id"), + userId: __t.string().name("user_id"), + model: __t.string(), + vector: __t.array(__t.f32()), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_messages_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_messages_table.ts new file mode 100644 index 00000000000..88c46d4549d --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_messages_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + threadId: __t.u64().name("thread_id"), + userId: __t.string().name("user_id"), + role: __t.string(), + content: __t.string(), + toolCallsJson: __t.option(__t.string()).name("tool_calls_json"), + toolCallId: __t.option(__t.string()).name("tool_call_id"), + isError: __t.bool().name("is_error"), + promptTokens: __t.option(__t.u32()).name("prompt_tokens"), + completionTokens: __t.option(__t.u32()).name("completion_tokens"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_thread_locks_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_thread_locks_table.ts new file mode 100644 index 00000000000..b4070961f81 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_thread_locks_table.ts @@ -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({ + threadId: __t.u64().primaryKey().name("thread_id"), + userId: __t.string().name("user_id"), + lockedAt: __t.timestamp().name("locked_at"), + cancelRequested: __t.bool().name("cancel_requested"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_threads_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_threads_table.ts new file mode 100644 index 00000000000..525ad19bd94 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_threads_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + userId: __t.string().name("user_id"), + agentName: __t.string().name("agent_name"), + title: __t.option(__t.string()), + systemPromptOverride: __t.option(__t.string()).name("system_prompt_override"), + modelOverride: __t.option(__t.string()).name("model_override"), + metadata: __t.option(__t.string()), + summary: __t.option(__t.string()), + summarizedThroughId: __t.option(__t.u64()).name("summarized_through_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/regenerate_response_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/regenerate_response_procedure.ts new file mode 100644 index 00000000000..398618113fb --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/regenerate_response_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + threadId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/remove_agent_admin_identity_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/remove_agent_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/remove_agent_admin_identity_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/request_cancel_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/request_cancel_reducer.ts new file mode 100644 index 00000000000..452ed4063c6 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/request_cancel_reducer.ts @@ -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 { + threadId: __t.u64(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/revoke_session_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/send_message_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/send_message_procedure.ts new file mode 100644 index 00000000000..70c7ce3d362 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/send_message_procedure.ts @@ -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 { + SendAttachment, +} from "./types"; + +export const params = { + threadId: __t.u64(), + content: __t.string(), + get attachments() { + return __t.array(SendAttachment); + }, +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/set_agent_override_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/set_agent_override_reducer.ts new file mode 100644 index 00000000000..68dbd1ce936 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/set_agent_override_reducer.ts @@ -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 { + agentName: __t.string(), + provider: __t.option(__t.string()), + model: __t.option(__t.string()), + systemPrompt: __t.option(__t.string()), + maxTurns: __t.option(__t.u32()), + maxHistoryMessages: __t.option(__t.u32()), + maxTokens: __t.option(__t.u32()), + retries: __t.option(__t.u32()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/set_agent_secret_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/set_agent_secret_reducer.ts new file mode 100644 index 00000000000..cf609ee4331 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/set_agent_secret_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + staleLockThresholdSecs: __t.option(__t.u32()), + rateLimitTokensPerWindow: __t.option(__t.u32()), + rateLimitWindowSecs: __t.option(__t.u32()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/set_api_key_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/set_api_key_reducer.ts new file mode 100644 index 00000000000..fd621323a70 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/set_api_key_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + provider: __t.string(), + key: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/set_auth_config_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/start_thread_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/start_thread_procedure.ts new file mode 100644 index 00000000000..aa89af1fa45 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/start_thread_procedure.ts @@ -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 const params = { + agentName: __t.string(), + title: __t.option(__t.string()), + systemPromptOverride: __t.option(__t.string()), + metadata: __t.option(__t.string()), +}; +export const returnType = __t.u64() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..8806a043a06 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,215 @@ +// 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 const AgentAdminIdentity = __t.object("AgentAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AgentAdminIdentity = __Infer; + +export const AgentAuthUser = __t.object("AgentAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AgentAuthUser = __Infer; + +export const AgentConfigStatus = __t.object("AgentConfigStatus", { + isConfigured: __t.bool(), + staleLockThresholdSecs: __t.u32(), + rateLimitTokensPerWindow: __t.option(__t.u32()), + rateLimitWindowSecs: __t.option(__t.u32()), + get agents() { + return __t.array(AgentInfo); + }, + configuredProviders: __t.array(__t.string()), +}); +export type AgentConfigStatus = __Infer; + +export const AgentInfo = __t.object("AgentInfo", { + name: __t.string(), + defaultProvider: __t.string(), + defaultModel: __t.string(), +}); +export type AgentInfo = __Infer; + +export const AgentOverride = __t.object("AgentOverride", { + agentName: __t.string(), + provider: __t.option(__t.string()), + model: __t.option(__t.string()), + systemPrompt: __t.option(__t.string()), + maxTurns: __t.option(__t.u32()), + maxHistoryMessages: __t.option(__t.u32()), + maxTokens: __t.option(__t.u32()), + retries: __t.option(__t.u32()), + updatedAt: __t.timestamp(), +}); +export type AgentOverride = __Infer; + +export const AgentSecret = __t.object("AgentSecret", { + singleton: __t.bool(), + staleLockThresholdSecs: __t.u32(), + rateLimitTokensPerWindow: __t.option(__t.u32()), + rateLimitWindowSecs: __t.option(__t.u32()), + updatedAt: __t.timestamp(), +}); +export type AgentSecret = __Infer; + +export const ApiKey = __t.object("ApiKey", { + provider: __t.string(), + key: __t.string(), + updatedAt: __t.timestamp(), +}); +export type ApiKey = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const File = __t.object("File", { + id: __t.u64(), + fileId: __t.u64(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + filename: __t.option(__t.string()), + messageId: __t.option(__t.u64()), + threadId: __t.option(__t.u64()), + ordinal: __t.u32(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const LinkConnectionResult = __t.object("LinkConnectionResult", { + userId: __t.string(), +}); +export type LinkConnectionResult = __Infer; + +export const Message = __t.object("Message", { + id: __t.u64(), + threadId: __t.u64(), + userId: __t.string(), + role: __t.string(), + content: __t.string(), + toolCallsJson: __t.option(__t.string()), + toolCallId: __t.option(__t.string()), + isError: __t.bool(), + promptTokens: __t.option(__t.u32()), + completionTokens: __t.option(__t.u32()), + createdAt: __t.timestamp(), +}); +export type Message = __Infer; + +export const MessageAttachment = __t.object("MessageAttachment", { + id: __t.u64(), + fileId: __t.u64(), + messageId: __t.u64(), + threadId: __t.u64(), + ownerUserId: __t.string(), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type MessageAttachment = __Infer; + +export const MessageEmbedding = __t.object("MessageEmbedding", { + messageId: __t.u64(), + threadId: __t.u64(), + userId: __t.string(), + model: __t.string(), + vector: __t.array(__t.f32()), + createdAt: __t.timestamp(), +}); +export type MessageEmbedding = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyFiles = __t.object("MyFiles", {}); +export type MyFiles = __Infer; + +export const MyMessageEmbeddings = __t.object("MyMessageEmbeddings", {}); +export type MyMessageEmbeddings = __Infer; + +export const MyMessages = __t.object("MyMessages", {}); +export type MyMessages = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const MyThreadLocks = __t.object("MyThreadLocks", {}); +export type MyThreadLocks = __Infer; + +export const MyThreads = __t.object("MyThreads", {}); +export type MyThreads = __Infer; + +export const SendAttachment = __t.object("SendAttachment", { + mimeType: __t.string(), + filename: __t.option(__t.string()), + bytes: __t.byteArray(), +}); +export type SendAttachment = __Infer; + +export const Thread = __t.object("Thread", { + id: __t.u64(), + userId: __t.string(), + agentName: __t.string(), + title: __t.option(__t.string()), + systemPromptOverride: __t.option(__t.string()), + modelOverride: __t.option(__t.string()), + metadata: __t.option(__t.string()), + summary: __t.option(__t.string()), + summarizedThroughId: __t.option(__t.u64()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Thread = __Infer; + +export const ThreadLock = __t.object("ThreadLock", { + threadId: __t.u64(), + userId: __t.string(), + lockedAt: __t.timestamp(), + cancelRequested: __t.bool(), +}); +export type ThreadLock = __Infer; + +export const ThreadLockSweeperTick = __t.object("ThreadLockSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type ThreadLockSweeperTick = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-agents-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..4c464d921f0 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,34 @@ +// 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 GenerateThreadTitleProcedure from "../generate_thread_title_procedure"; +import * as GetAgentConfigStatusProcedure from "../get_agent_config_status_procedure"; +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as LinkConnectionProcedure from "../link_connection_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as RegenerateResponseProcedure from "../regenerate_response_procedure"; +import * as SendMessageProcedure from "../send_message_procedure"; +import * as StartThreadProcedure from "../start_thread_procedure"; + +export type GenerateThreadTitleArgs = __Infer; +export type GenerateThreadTitleResult = __Infer; +export type GetAgentConfigStatusArgs = __Infer; +export type GetAgentConfigStatusResult = __Infer; +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type LinkConnectionArgs = __Infer; +export type LinkConnectionResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type RegenerateResponseArgs = __Infer; +export type RegenerateResponseResult = __Infer; +export type SendMessageArgs = __Infer; +export type SendMessageResult = __Infer; +export type StartThreadArgs = __Infer; +export type StartThreadResult = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-agents-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..d4aed5c9af0 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,42 @@ +// 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 +import AddAgentAdminIdentityReducer from "../add_agent_admin_identity_reducer"; +import ClearAgentOverrideReducer from "../clear_agent_override_reducer"; +import ClearApiKeyReducer from "../clear_api_key_reducer"; +import ClearThreadLockReducer from "../clear_thread_lock_reducer"; +import DeleteThreadReducer from "../delete_thread_reducer"; +import RemoveAgentAdminIdentityReducer from "../remove_agent_admin_identity_reducer"; +import RequestCancelReducer from "../request_cancel_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SetAgentOverrideReducer from "../set_agent_override_reducer"; +import SetAgentSecretReducer from "../set_agent_secret_reducer"; +import SetApiKeyReducer from "../set_api_key_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; +import UpdateThreadReducer from "../update_thread_reducer"; + +export type AddAgentAdminIdentityParams = __Infer; +export type ClearAgentOverrideParams = __Infer; +export type ClearApiKeyParams = __Infer; +export type ClearThreadLockParams = __Infer; +export type DeleteThreadParams = __Infer; +export type RemoveAgentAdminIdentityParams = __Infer; +export type RequestCancelParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SetAgentOverrideParams = __Infer; +export type SetAgentSecretParams = __Infer; +export type SetApiKeyParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UpdateProfileParams = __Infer; +export type UpdateThreadParams = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/unlink_connection_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/update_profile_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/update_thread_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/update_thread_reducer.ts new file mode 100644 index 00000000000..e00528bdcc9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/update_thread_reducer.ts @@ -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 { + threadId: __t.u64(), + title: __t.option(__t.string()), + systemPromptOverride: __t.option(__t.string()), + modelOverride: __t.option(__t.string()), + metadata: __t.option(__t.string()), + clearTitle: __t.bool(), + clearSystemPromptOverride: __t.bool(), + clearModelOverride: __t.bool(), + clearMetadata: __t.bool(), +}; diff --git a/spacetime-agents-ts/spacetimedb/src/index.ts b/spacetime-agents-ts/spacetimedb/src/index.ts index d4e3855096b..fb37228854d 100644 --- a/spacetime-agents-ts/spacetimedb/src/index.ts +++ b/spacetime-agents-ts/spacetimedb/src/index.ts @@ -118,7 +118,7 @@ import { } from './model'; const threadLockSweeperTick = table( - { name: 'thread_lock_sweeper_tick', scheduled: (): any => thread_lock_sweep }, + { name: 'thread_lock_sweeper_tick' }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), @@ -1008,6 +1008,7 @@ export const generate_thread_title = spacetimedb.procedure( ); export const thread_lock_sweep = spacetimedb.reducer( + { onSchedule: threadLockSweeperTick }, { arg: threadLockSweeperTick.rowType }, (ctx, _arg) => { const secret = ctx.db.agentSecret.singleton.find(true); diff --git a/spacetime-api-keys-ts/example/package.json b/spacetime-api-keys-ts/example/package.json index 998fb98d054..8c30d6ee8ff 100644 --- a/spacetime-api-keys-ts/example/package.json +++ b/spacetime-api-keys-ts/example/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "test:unit": "tsx scripts/test-model.ts && tsx scripts/test-share-key.ts", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/index.ts b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts index 510d8af2e0f..0ee634831db 100644 --- a/spacetime-api-keys-ts/example/spacetimedb/src/index.ts +++ b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts @@ -24,7 +24,6 @@ import { import { accessKeySummary, colonySweepTick, - setColonySweepReducer, spacetimedb, type HttpCtx, type ReadCtx, @@ -697,6 +696,7 @@ export const presence_leave = spacetimedb.reducer( ); export const colony_sweep = spacetimedb.reducer( + { onSchedule: colonySweepTick }, { arg: colonySweepTick.rowType }, ctx => { runPresenceSweep( @@ -708,8 +708,6 @@ export const colony_sweep = spacetimedb.reducer( } ); -setColonySweepReducer(colony_sweep); - // Reads. world, world_event, and presence_entry are public tables the // client subscribes to with a WHERE on the colony id. The grid submodule's // tables are reached through these public projection views, filtered by diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts b/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts index 716262f0622..7e838eba697 100644 --- a/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts +++ b/spacetime-api-keys-ts/example/spacetimedb/src/schema.ts @@ -80,28 +80,14 @@ export const presenceConfig = table( } ); -let colonySweepReducer: unknown; - export const colonySweepTick = table( - { - name: 'colony_sweep_tick', - scheduled: (): any => { - if (!colonySweepReducer) { - throw new Error('colony.sweep_reducer_not_registered'); - } - return colonySweepReducer; - }, - }, + { name: 'colony_sweep_tick' }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), } ); -export function setColonySweepReducer(reducer: unknown): void { - colonySweepReducer = reducer; -} - export const spacetimedb = schema({ apiKeys, grid, diff --git a/spacetime-api-keys-ts/example/src/app.ts b/spacetime-api-keys-ts/example/src/app.ts index 36290e46cf1..44be9bcad6d 100644 --- a/spacetime-api-keys-ts/example/src/app.ts +++ b/spacetime-api-keys-ts/example/src/app.ts @@ -2,7 +2,7 @@ import { DbConnection, tables, type ErrorContext, -} from './codegen/app/index.ts'; +} from './module_bindings/app/index.ts'; import { parseShareKey, shareKeyFromHash } from './share-key'; import { diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/add_admin_identity_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/add_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/add_admin_identity_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_key_usage_admin_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_key_usage_admin_table.ts new file mode 100644 index 00000000000..13ca42e538d --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_key_usage_admin_table.ts @@ -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({ + usageId: __t.u64().name("usage_id"), + keyId: __t.string().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + usedAt: __t.timestamp().name("used_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_keys_admin_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_keys_admin_table.ts new file mode 100644 index 00000000000..478edd2b013 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/api_keys_admin_table.ts @@ -0,0 +1,31 @@ +// 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 { + ApiKeyStatus, +} from "./types"; + + +export default __t.row({ + keyId: __t.string().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + name: __t.string(), + scopesJson: __t.string().name("scopes_json"), + metadataJson: __t.option(__t.string()).name("metadata_json"), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp().name("created_at"), + expiresAt: __t.option(__t.timestamp()).name("expires_at"), + lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_for_subject_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_for_subject_procedure.ts new file mode 100644 index 00000000000..b3ca01ada61 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_for_subject_procedure.ts @@ -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 { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_procedure.ts new file mode 100644 index 00000000000..2324c883938 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/create_api_key_procedure.ts @@ -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 { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/my_api_keys_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/my_api_keys_table.ts new file mode 100644 index 00000000000..478edd2b013 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/my_api_keys_table.ts @@ -0,0 +1,31 @@ +// 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 { + ApiKeyStatus, +} from "./types"; + + +export default __t.row({ + keyId: __t.string().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + name: __t.string(), + scopesJson: __t.string().name("scopes_json"), + metadataJson: __t.option(__t.string()).name("metadata_json"), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp().name("created_at"), + expiresAt: __t.option(__t.timestamp()).name("expires_at"), + lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/remove_admin_identity_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/remove_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/remove_admin_identity_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_for_subject_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_for_subject_reducer.ts new file mode 100644 index 00000000000..8088a2a4a47 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_for_subject_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + keyId: __t.string(), + ownerSubject: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_reducer.ts new file mode 100644 index 00000000000..74c389667b3 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/revoke_api_key_reducer.ts @@ -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 { + keyId: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/rotate_api_key_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/rotate_api_key_procedure.ts new file mode 100644 index 00000000000..8261e1ed9bc --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/rotate_api_key_procedure.ts @@ -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"; + +import { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + keyId: __t.string(), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/sweep_api_key_usage_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/sweep_api_key_usage_reducer.ts new file mode 100644 index 00000000000..77e9dcb6473 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/sweep_api_key_usage_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + maxAgeSeconds: __t.u32(), + maxRows: __t.u32(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/types.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/types.ts new file mode 100644 index 00000000000..0fbfafcd592 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/apiKeys/types.ts @@ -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. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const ApiKey = __t.object("ApiKey", { + keyId: __t.string(), + prefix: __t.string(), + hash: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + createdAtOrder: __t.i64(), + expiresAt: __t.option(__t.timestamp()), + lastUsedAt: __t.option(__t.timestamp()), + revokedAt: __t.option(__t.timestamp()), +}); +export type ApiKey = __Infer; + +export const ApiKeyAdminIdentity = __t.object("ApiKeyAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type ApiKeyAdminIdentity = __Infer; + +export const ApiKeyCreateResult = __t.object("ApiKeyCreateResult", { + keyId: __t.string(), + key: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), +}); +export type ApiKeyCreateResult = __Infer; + +// The tagged union or sum type for the algebraic type `ApiKeyStatus`. +export const ApiKeyStatus = __t.enum("ApiKeyStatus", { + Active: __t.unit(), + Revoked: __t.unit(), +}); +export type ApiKeyStatus = __Infer; + +export const ApiKeySummary = __t.object("ApiKeySummary", { + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), + lastUsedAt: __t.option(__t.timestamp()), + revokedAt: __t.option(__t.timestamp()), +}); +export type ApiKeySummary = __Infer; + +export const ApiKeyUsage = __t.object("ApiKeyUsage", { + usageId: __t.u64(), + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + usedAt: __t.timestamp(), + usedAtOrder: __t.i64(), +}); +export type ApiKeyUsage = __Infer; + +export const ApiKeyUsageAdmin = __t.object("ApiKeyUsageAdmin", {}); +export type ApiKeyUsageAdmin = __Infer; + +export const ApiKeyUsageSummary = __t.object("ApiKeyUsageSummary", { + usageId: __t.u64(), + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + usedAt: __t.timestamp(), +}); +export type ApiKeyUsageSummary = __Infer; + +export const ApiKeysAdmin = __t.object("ApiKeysAdmin", {}); +export type ApiKeysAdmin = __Infer; + +export const MyApiKeys = __t.object("MyApiKeys", {}); +export type MyApiKeys = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/build_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/build_reducer.ts new file mode 100644 index 00000000000..2b94f0ff71d --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/build_reducer.ts @@ -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 { + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + label: __t.option(__t.string()), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/clear_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/clear_reducer.ts new file mode 100644 index 00000000000..c061193b961 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/clear_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + x: __t.i32(), + y: __t.i32(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/clear_world_events_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/clear_world_events_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/clear_world_events_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/colony_cells_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_cells_table.ts new file mode 100644 index 00000000000..9f1a3bfc694 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_cells_table.ts @@ -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().primaryKey(), + gridId: __t.u64().name("grid_id"), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/colony_entities_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_entities_table.ts new file mode 100644 index 00000000000..1b75cfb7ea0 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_entities_table.ts @@ -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({ + id: __t.u64().primaryKey(), + gridId: __t.u64().name("grid_id"), + ownerUserId: __t.string().name("owner_user_id"), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool().name("blocks_movement"), + label: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/colony_grid_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_grid_table.ts new file mode 100644 index 00000000000..ba03c161ea6 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/colony_grid_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + ownerUserId: __t.string().name("owner_user_id"), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32().name("default_cost"), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/create_access_key_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/create_access_key_procedure.ts new file mode 100644 index 00000000000..2324c883938 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/create_access_key_procedure.ts @@ -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 { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/ensure_world_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/ensure_world_procedure.ts new file mode 100644 index 00000000000..dd16b86efa5 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/ensure_world_procedure.ts @@ -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"; + +import { + EnsureWorldResult, +} from "./types"; + +export const params = { +}; +export const returnType = EnsureWorldResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/grid/types.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/grid/types.ts new file mode 100644 index 00000000000..48f7c6524bd --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/grid/types.ts @@ -0,0 +1,69 @@ +// 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 const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const EntityPath = __t.object("EntityPath", { + entityId: __t.u64(), + gridId: __t.u64(), + get cells() { + return __t.array(PathCell); + }, + cost: __t.i32(), + computedAt: __t.timestamp(), +}); +export type EntityPath = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const PathCell = __t.object("PathCell", { + x: __t.i32(), + y: __t.i32(), +}); +export type PathCell = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/index.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..d4342f01cbe --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,324 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 BuildReducer from "./build_reducer"; +import ClearReducer from "./clear_reducer"; +import ClearWorldEventsReducer from "./clear_world_events_reducer"; +import PlantReducer from "./plant_reducer"; +import PresenceHeartbeatReducer from "./presence_heartbeat_reducer"; +import PresenceLeaveReducer from "./presence_leave_reducer"; +import ResetWorldReducer from "./reset_world_reducer"; +import RevokeAccessKeyReducer from "./revoke_access_key_reducer"; +import TerraformReducer from "./terraform_reducer"; +import UnbuildReducer from "./unbuild_reducer"; + +// Import all procedure arg schemas +import * as CreateAccessKeyProcedure from "./create_access_key_procedure"; +import * as EnsureWorldProcedure from "./ensure_world_procedure"; +import * as RotateAccessKeyProcedure from "./rotate_access_key_procedure"; + +// Import all table schema definitions +import ColonyCellsRow from "./colony_cells_table"; +import ColonyEntitiesRow from "./colony_entities_table"; +import ColonyGridRow from "./colony_grid_table"; +import MyAccessKeysRow from "./my_access_keys_table"; +import PresenceEntryRow from "./presence_entry_table"; +import WorldRow from "./world_table"; +import WorldEventRow from "./world_event_table"; + +// Import namespace table schema definitions +import ApiKeys_ApiKeyUsageAdminRow from "./apiKeys/api_key_usage_admin_table"; +import ApiKeys_ApiKeysAdminRow from "./apiKeys/api_keys_admin_table"; +import ApiKeys_MyApiKeysRow from "./apiKeys/my_api_keys_table"; + +// Import namespace reducer arg schemas +import ApiKeys_AddAdminIdentityReducer from "./apiKeys/add_admin_identity_reducer"; +import ApiKeys_RemoveAdminIdentityReducer from "./apiKeys/remove_admin_identity_reducer"; +import ApiKeys_RevokeApiKeyReducer from "./apiKeys/revoke_api_key_reducer"; +import ApiKeys_RevokeApiKeyForSubjectReducer from "./apiKeys/revoke_api_key_for_subject_reducer"; +import ApiKeys_SweepApiKeyUsageReducer from "./apiKeys/sweep_api_key_usage_reducer"; + +// Import namespace procedure arg schemas +import * as ApiKeys_CreateApiKeyProcedure from "./apiKeys/create_api_key_procedure"; +import * as ApiKeys_CreateApiKeyForSubjectProcedure from "./apiKeys/create_api_key_for_subject_procedure"; +import * as ApiKeys_RotateApiKeyProcedure from "./apiKeys/rotate_api_key_procedure"; + +/** 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({ + presenceEntry: __table({ + name: 'presence_entry', + indexes: [ + { accessor: 'expiresAt', name: 'presence_entry_expires_at_idx_btree', algorithm: 'btree', columns: [ + 'expiresAt', + ] }, + { accessor: 'joinedAt', name: 'presence_entry_joined_at_idx_btree', algorithm: 'btree', columns: [ + 'joinedAt', + ] }, + { accessor: 'key', name: 'presence_entry_key_idx_btree', algorithm: 'btree', columns: [ + 'key', + ] }, + { accessor: 'lastSeenAt', name: 'presence_entry_last_seen_at_idx_btree', algorithm: 'btree', columns: [ + 'lastSeenAt', + ] }, + { accessor: 'scope', name: 'presence_entry_scope_idx_btree', algorithm: 'btree', columns: [ + 'scope', + ] }, + { accessor: 'status', name: 'presence_entry_status_idx_btree', algorithm: 'btree', columns: [ + 'status', + ] }, + { accessor: 'subject', name: 'presence_entry_subject_idx_btree', algorithm: 'btree', columns: [ + 'subject', + ] }, + ], + constraints: [ + { name: 'presence_entry_key_key', constraint: 'unique', columns: ['key'] }, + ], + }, PresenceEntryRow), + world: __table({ + name: 'world', + indexes: [ + { accessor: 'gridId', name: 'world_grid_id_idx_btree', algorithm: 'btree', columns: [ + 'gridId', + ] }, + { accessor: 'ownerSubject', name: 'world_owner_subject_idx_btree', algorithm: 'btree', columns: [ + 'ownerSubject', + ] }, + ], + constraints: [ + { name: 'world_owner_subject_key', constraint: 'unique', columns: ['ownerSubject'] }, + ], + }, WorldRow), + worldEvent: __table({ + name: 'world_event', + indexes: [ + { accessor: 'action', name: 'world_event_action_idx_btree', algorithm: 'btree', columns: [ + 'action', + ] }, + { accessor: 'allowed', name: 'world_event_allowed_idx_btree', algorithm: 'btree', columns: [ + 'allowed', + ] }, + { accessor: 'createdAt', name: 'world_event_created_at_idx_btree', algorithm: 'btree', columns: [ + 'createdAt', + ] }, + { accessor: 'eventId', name: 'world_event_event_id_idx_btree', algorithm: 'btree', columns: [ + 'eventId', + ] }, + { accessor: 'ownerSubject', name: 'world_event_owner_subject_idx_btree', algorithm: 'btree', columns: [ + 'ownerSubject', + ] }, + ], + constraints: [ + { name: 'world_event_event_id_key', constraint: 'unique', columns: ['eventId'] }, + ], + }, WorldEventRow), + colonyCells: __table({ + name: 'colony_cells', + indexes: [ + ], + constraints: [ + ], + }, ColonyCellsRow), + colonyEntities: __table({ + name: 'colony_entities', + indexes: [ + ], + constraints: [ + ], + }, ColonyEntitiesRow), + colonyGrid: __table({ + name: 'colony_grid', + indexes: [ + ], + constraints: [ + ], + }, ColonyGridRow), + myAccessKeys: __table({ + name: 'my_access_keys', + indexes: [ + ], + constraints: [ + ], + }, MyAccessKeysRow), + "apiKeys.api_key_usage_admin": __table({ + name: 'apiKeys.api_key_usage_admin', + indexes: [ + ], + constraints: [ + ], + }, ApiKeys_ApiKeyUsageAdminRow), + "apiKeys.api_keys_admin": __table({ + name: 'apiKeys.api_keys_admin', + indexes: [ + ], + constraints: [ + ], + }, ApiKeys_ApiKeysAdminRow), + "apiKeys.my_api_keys": __table({ + name: 'apiKeys.my_api_keys', + indexes: [ + ], + constraints: [ + ], + }, ApiKeys_MyApiKeysRow), +}); + +/** 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( + __reducerSchema("build", BuildReducer), + __reducerSchema("clear", ClearReducer), + __reducerSchema("clear_world_events", ClearWorldEventsReducer), + __reducerSchema("plant", PlantReducer), + __reducerSchema("presence_heartbeat", PresenceHeartbeatReducer), + __reducerSchema("presence_leave", PresenceLeaveReducer), + __reducerSchema("reset_world", ResetWorldReducer), + __reducerSchema("revoke_access_key", RevokeAccessKeyReducer), + __reducerSchema("terraform", TerraformReducer), + __reducerSchema("unbuild", UnbuildReducer), + __reducerSchema("apiKeys.add_admin_identity", ApiKeys_AddAdminIdentityReducer), + __reducerSchema("apiKeys.remove_admin_identity", ApiKeys_RemoveAdminIdentityReducer), + __reducerSchema("apiKeys.revoke_api_key", ApiKeys_RevokeApiKeyReducer), + __reducerSchema("apiKeys.revoke_api_key_for_subject", ApiKeys_RevokeApiKeyForSubjectReducer), + __reducerSchema("apiKeys.sweep_api_key_usage", ApiKeys_SweepApiKeyUsageReducer), +); + +/** 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("create_access_key", CreateAccessKeyProcedure.params, CreateAccessKeyProcedure.returnType), + __procedureSchema("ensure_world", EnsureWorldProcedure.params, EnsureWorldProcedure.returnType), + __procedureSchema("rotate_access_key", RotateAccessKeyProcedure.params, RotateAccessKeyProcedure.returnType), + __procedureSchema("apiKeys.create_api_key", ApiKeys_CreateApiKeyProcedure.params, ApiKeys_CreateApiKeyProcedure.returnType), + __procedureSchema("apiKeys.create_api_key_for_subject", ApiKeys_CreateApiKeyForSubjectProcedure.params, ApiKeys_CreateApiKeyForSubjectProcedure.returnType), + __procedureSchema("apiKeys.rotate_api_key", ApiKeys_RotateApiKeyProcedure.params, ApiKeys_RotateApiKeyProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + presenceEntry: __qb.presenceEntry, + world: __qb.world, + worldEvent: __qb.worldEvent, + colonyCells: __qb.colonyCells, + colonyEntities: __qb.colonyEntities, + colonyGrid: __qb.colonyGrid, + myAccessKeys: __qb.myAccessKeys, + apiKeys: { + apiKeyUsageAdmin: __qb["apiKeys.api_key_usage_admin"], + apiKeysAdmin: __qb["apiKeys.api_keys_admin"], + myApiKeys: __qb["apiKeys.my_api_keys"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + build: __reducerAccessors.build, + clear: __reducerAccessors.clear, + clearWorldEvents: __reducerAccessors.clearWorldEvents, + plant: __reducerAccessors.plant, + presenceHeartbeat: __reducerAccessors.presenceHeartbeat, + presenceLeave: __reducerAccessors.presenceLeave, + resetWorld: __reducerAccessors.resetWorld, + revokeAccessKey: __reducerAccessors.revokeAccessKey, + terraform: __reducerAccessors.terraform, + unbuild: __reducerAccessors.unbuild, + apiKeys: { + addAdminIdentity: __reducerAccessors["apiKeys.addAdminIdentity"], + removeAdminIdentity: __reducerAccessors["apiKeys.removeAdminIdentity"], + revokeApiKey: __reducerAccessors["apiKeys.revokeApiKey"], + revokeApiKeyForSubject: __reducerAccessors["apiKeys.revokeApiKeyForSubject"], + sweepApiKeyUsage: __reducerAccessors["apiKeys.sweepApiKeyUsage"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + createAccessKey: __procedureAccessors.createAccessKey, + ensureWorld: __procedureAccessors.ensureWorld, + rotateAccessKey: __procedureAccessors.rotateAccessKey, + apiKeys: { + createApiKey: __procedureAccessors["apiKeys.createApiKey"], + createApiKeyForSubject: __procedureAccessors["apiKeys.createApiKeyForSubject"], + rotateApiKey: __procedureAccessors["apiKeys.rotateApiKey"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/my_access_keys_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/my_access_keys_table.ts new file mode 100644 index 00000000000..63686ec3ca6 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/my_access_keys_table.ts @@ -0,0 +1,31 @@ +// 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 { + ApiKeyStatus, +} from "./types"; + + +export default __t.row({ + keyId: __t.string().primaryKey().name("key_id"), + prefix: __t.string(), + ownerSubject: __t.string().name("owner_subject"), + name: __t.string(), + scopesJson: __t.string().name("scopes_json"), + metadataJson: __t.option(__t.string()).name("metadata_json"), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp().name("created_at"), + expiresAt: __t.option(__t.timestamp()).name("expires_at"), + lastUsedAt: __t.option(__t.timestamp()).name("last_used_at"), + revokedAt: __t.option(__t.timestamp()).name("revoked_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/plant_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/plant_reducer.ts new file mode 100644 index 00000000000..2b4c9c3b0f5 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/plant_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + x: __t.i32(), + y: __t.i32(), + kind: __t.option(__t.string()), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/presence_entry_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_entry_table.ts new file mode 100644 index 00000000000..70af5d56d15 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_entry_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()).name("payload_json"), + joinedAt: __t.timestamp().name("joined_at"), + lastSeenAt: __t.timestamp().name("last_seen_at"), + expiresAt: __t.timestamp().name("expires_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/presence_heartbeat_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_heartbeat_reducer.ts new file mode 100644 index 00000000000..46b19979f5d --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_heartbeat_reducer.ts @@ -0,0 +1,21 @@ +// 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 { + scope: __t.string(), + name: __t.string(), + role: __t.string(), + color: __t.string(), + cx: __t.f64(), + cy: __t.f64(), + onGrid: __t.bool(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/presence_leave_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_leave_reducer.ts new file mode 100644 index 00000000000..7a16fc253db --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/presence_leave_reducer.ts @@ -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 { + scope: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/reset_world_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/reset_world_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/reset_world_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/revoke_access_key_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/revoke_access_key_reducer.ts new file mode 100644 index 00000000000..74c389667b3 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/revoke_access_key_reducer.ts @@ -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 { + keyId: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/rotate_access_key_procedure.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/rotate_access_key_procedure.ts new file mode 100644 index 00000000000..8261e1ed9bc --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/rotate_access_key_procedure.ts @@ -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"; + +import { + ApiKeyCreateResult, +} from "./types"; + +export const params = { + keyId: __t.string(), + expiresInSeconds: __t.option(__t.u32()), + keyPrefix: __t.option(__t.string()), +}; +export const returnType = ApiKeyCreateResult \ No newline at end of file diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/terraform_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/terraform_reducer.ts new file mode 100644 index 00000000000..fea18233964 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/terraform_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + x: __t.i32(), + y: __t.i32(), + terrain: __t.string(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/types.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..54619126f70 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,159 @@ +// 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 const AccessKeySummary = __t.object("AccessKeySummary", { + keyId: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), + lastUsedAt: __t.option(__t.timestamp()), + revokedAt: __t.option(__t.timestamp()), +}); +export type AccessKeySummary = __Infer; + +export const ApiKeyCreateResult = __t.object("ApiKeyCreateResult", { + keyId: __t.string(), + key: __t.string(), + prefix: __t.string(), + ownerSubject: __t.string(), + name: __t.string(), + scopesJson: __t.string(), + metadataJson: __t.option(__t.string()), + get status() { + return ApiKeyStatus; + }, + createdAt: __t.timestamp(), + expiresAt: __t.option(__t.timestamp()), +}); +export type ApiKeyCreateResult = __Infer; + +// The tagged union or sum type for the algebraic type `ApiKeyStatus`. +export const ApiKeyStatus = __t.enum("ApiKeyStatus", { + Active: __t.unit(), + Revoked: __t.unit(), +}); +export type ApiKeyStatus = __Infer; + +export const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const ColonyCells = __t.object("ColonyCells", {}); +export type ColonyCells = __Infer; + +export const ColonyEntities = __t.object("ColonyEntities", {}); +export type ColonyEntities = __Infer; + +export const ColonyGrid = __t.object("ColonyGrid", {}); +export type ColonyGrid = __Infer; + +export const ColonySweepTick = __t.object("ColonySweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type ColonySweepTick = __Infer; + +export const EnsureWorldResult = __t.object("EnsureWorldResult", { + ownerSubject: __t.string(), + gridId: __t.u64(), +}); +export type EnsureWorldResult = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const MyAccessKeys = __t.object("MyAccessKeys", {}); +export type MyAccessKeys = __Infer; + +export const PresenceConfig = __t.object("PresenceConfig", { + singleton: __t.bool(), + defaultTtlSeconds: __t.u32(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type PresenceConfig = __Infer; + +export const PresenceEntry = __t.object("PresenceEntry", { + key: __t.string(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()), + joinedAt: __t.timestamp(), + lastSeenAt: __t.timestamp(), + expiresAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type PresenceEntry = __Infer; + +export const World = __t.object("World", { + ownerSubject: __t.string(), + gridId: __t.u64(), + name: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type World = __Infer; + +export const WorldEvent = __t.object("WorldEvent", { + eventId: __t.u64(), + ownerSubject: __t.string(), + keyPrefix: __t.string(), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + message: __t.string(), + createdAt: __t.timestamp(), +}); +export type WorldEvent = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..4655da2c690 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/types/procedures.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as CreateAccessKeyProcedure from "../create_access_key_procedure"; +import * as EnsureWorldProcedure from "../ensure_world_procedure"; +import * as RotateAccessKeyProcedure from "../rotate_access_key_procedure"; + +export type CreateAccessKeyArgs = __Infer; +export type CreateAccessKeyResult = __Infer; +export type EnsureWorldArgs = __Infer; +export type EnsureWorldResult = __Infer; +export type RotateAccessKeyArgs = __Infer; +export type RotateAccessKeyResult = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..b44ba89c968 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,30 @@ +// 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 +import BuildReducer from "../build_reducer"; +import ClearReducer from "../clear_reducer"; +import ClearWorldEventsReducer from "../clear_world_events_reducer"; +import PlantReducer from "../plant_reducer"; +import PresenceHeartbeatReducer from "../presence_heartbeat_reducer"; +import PresenceLeaveReducer from "../presence_leave_reducer"; +import ResetWorldReducer from "../reset_world_reducer"; +import RevokeAccessKeyReducer from "../revoke_access_key_reducer"; +import TerraformReducer from "../terraform_reducer"; +import UnbuildReducer from "../unbuild_reducer"; + +export type BuildParams = __Infer; +export type ClearParams = __Infer; +export type ClearWorldEventsParams = __Infer; +export type PlantParams = __Infer; +export type PresenceHeartbeatParams = __Infer; +export type PresenceLeaveParams = __Infer; +export type ResetWorldParams = __Infer; +export type RevokeAccessKeyParams = __Infer; +export type TerraformParams = __Infer; +export type UnbuildParams = __Infer; + diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/unbuild_reducer.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/unbuild_reducer.ts new file mode 100644 index 00000000000..c061193b961 --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/unbuild_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + x: __t.i32(), + y: __t.i32(), +}; diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/world_event_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/world_event_table.ts new file mode 100644 index 00000000000..ec04549e0ef --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/world_event_table.ts @@ -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({ + eventId: __t.u64().primaryKey().name("event_id"), + ownerSubject: __t.string().name("owner_subject"), + keyPrefix: __t.string().name("key_prefix"), + action: __t.string(), + allowed: __t.bool(), + reason: __t.string(), + message: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-api-keys-ts/example/src/module_bindings/app/world_table.ts b/spacetime-api-keys-ts/example/src/module_bindings/app/world_table.ts new file mode 100644 index 00000000000..7a4304b505b --- /dev/null +++ b/spacetime-api-keys-ts/example/src/module_bindings/app/world_table.ts @@ -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({ + ownerSubject: __t.string().primaryKey().name("owner_subject"), + gridId: __t.u64().name("grid_id"), + name: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-auth-ts/example/package.json b/spacetime-auth-ts/example/package.json index e033aa3041e..67a08439dff 100644 --- a/spacetime-auth-ts/example/package.json +++ b/spacetime-auth-ts/example/package.json @@ -4,12 +4,12 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-auth-ts/example/src/app.ts b/spacetime-auth-ts/example/src/app.ts index 3e22e1477cf..bd4e66c2d02 100644 --- a/spacetime-auth-ts/example/src/app.ts +++ b/spacetime-auth-ts/example/src/app.ts @@ -5,7 +5,7 @@ import { tables, type EventContext, type ErrorContext, -} from './codegen/app'; +} from './module_bindings/app'; interface AuthUserRow { userId: string; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/types.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/types.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/types.ts @@ -0,0 +1,137 @@ +// 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 const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/whoami_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/create_note_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/create_note_reducer.ts new file mode 100644 index 00000000000..cf5ec16d854 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/create_note_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + title: __t.string(), + body: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/delete_note_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/delete_note_reducer.ts new file mode 100644 index 00000000000..60d89c56797 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/delete_note_reducer.ts @@ -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 { + noteId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/index.ts b/spacetime-auth-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..4b21b12ba70 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,259 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 CreateNoteReducer from "./create_note_reducer"; +import DeleteNoteReducer from "./delete_note_reducer"; +import LinkConnectionReducer from "./link_connection_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UpdateNoteReducer from "./update_note_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; + +// Import all procedure arg schemas +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as WhoamiProcedure from "./whoami_procedure"; + +// Import all table schema definitions +import MyAuthUserRow from "./my_auth_user_table"; +import MyNotesRow from "./my_notes_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; + +/** 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({ + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myNotes: __table({ + name: 'my_notes', + indexes: [ + ], + constraints: [ + ], + }, MyNotesRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), +}); + +/** 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( + __reducerSchema("create_note", CreateNoteReducer), + __reducerSchema("delete_note", DeleteNoteReducer), + __reducerSchema("link_connection", LinkConnectionReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("update_note", UpdateNoteReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), +); + +/** 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("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + myAuthUser: __qb.myAuthUser, + myNotes: __qb.myNotes, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + createNote: __reducerAccessors.createNote, + deleteNote: __reducerAccessors.deleteNote, + linkConnection: __reducerAccessors.linkConnection, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + setAuthConfig: __reducerAccessors.setAuthConfig, + unlinkConnection: __reducerAccessors.unlinkConnection, + updateNote: __reducerAccessors.updateNote, + updateProfile: __reducerAccessors.updateProfile, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + listMySessions: __procedureAccessors.listMySessions, + whoami: __procedureAccessors.whoami, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/link_connection_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/my_auth_user_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/my_notes_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/my_notes_table.ts new file mode 100644 index 00000000000..52857c3cf19 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/my_notes_table.ts @@ -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({ + noteId: __t.string().primaryKey().name("note_id"), + authorId: __t.string().name("author_id"), + title: __t.string(), + body: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/revoke_session_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/set_auth_config_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/types.ts b/spacetime-auth-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..d57ecf300d0 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const ExampleAuthUser = __t.object("ExampleAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ExampleAuthUser = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyNotes = __t.object("MyNotes", {}); +export type MyNotes = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const Note = __t.object("Note", { + noteId: __t.string(), + authorId: __t.string(), + title: __t.string(), + body: __t.string(), + createdAt: __t.timestamp(), +}); +export type Note = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-auth-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..243379b58a2 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/types/procedures.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as WhoamiProcedure from "../whoami_procedure"; + +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type WhoamiArgs = __Infer; +export type WhoamiResult = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-auth-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..14ac95e9a10 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/types/reducers.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import CreateNoteReducer from "../create_note_reducer"; +import DeleteNoteReducer from "../delete_note_reducer"; +import LinkConnectionReducer from "../link_connection_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UpdateNoteReducer from "../update_note_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; + +export type CreateNoteParams = __Infer; +export type DeleteNoteParams = __Infer; +export type LinkConnectionParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UpdateNoteParams = __Infer; +export type UpdateProfileParams = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/unlink_connection_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/update_note_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/update_note_reducer.ts new file mode 100644 index 00000000000..52bd16908e4 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/update_note_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + noteId: __t.string(), + title: __t.string(), + body: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/update_profile_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/whoami_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-auth-ts/src/mounted/index.ts b/spacetime-auth-ts/src/mounted/index.ts index 60d2a7c118f..8663e5b25af 100644 --- a/spacetime-auth-ts/src/mounted/index.ts +++ b/spacetime-auth-ts/src/mounted/index.ts @@ -148,7 +148,7 @@ const authAdminIdentity = table( ); const authSweeperTick = table( - { name: 'auth_sweeper_tick', scheduled: (): any => auth_sweep }, + { name: 'auth_sweeper_tick' }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), @@ -242,6 +242,7 @@ export const revoke_my_session = spacetimedb.reducer( ); export const auth_sweep = spacetimedb.reducer( + { onSchedule: authSweeperTick }, { arg: authSweeperTick.rowType }, (ctx, _arg) => { authSweepImpl(ctx); diff --git a/spacetime-cron-ts/example/package.json b/spacetime-cron-ts/example/package.json index 262cab0f6a0..ee9313b4ac8 100644 --- a/spacetime-cron-ts/example/package.json +++ b/spacetime-cron-ts/example/package.json @@ -4,12 +4,12 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-cron-ts/example/src/app.ts b/spacetime-cron-ts/example/src/app.ts index 4f47b876c69..9f86702b19c 100644 --- a/spacetime-cron-ts/example/src/app.ts +++ b/spacetime-cron-ts/example/src/app.ts @@ -1,5 +1,5 @@ -import { DbConnection, tables, type ErrorContext } from './codegen/app'; -import type { CronSchedule } from './codegen/app/types'; +import { DbConnection, tables, type ErrorContext } from './module_bindings/app'; +import type { CronSchedule } from './module_bindings/app/types'; interface ServerConfig { stdbUri: string; diff --git a/spacetime-cron-ts/example/src/module_bindings/app/activity_log_table.ts b/spacetime-cron-ts/example/src/module_bindings/app/activity_log_table.ts new file mode 100644 index 00000000000..e6c83267786 --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/activity_log_table.ts @@ -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({ + id: __t.u64().primaryKey(), + jobName: __t.string().name("job_name"), + message: __t.string(), + at: __t.timestamp(), +}); diff --git a/spacetime-cron-ts/example/src/module_bindings/app/cleanup_fire_table.ts b/spacetime-cron-ts/example/src/module_bindings/app/cleanup_fire_table.ts new file mode 100644 index 00000000000..107696eecfe --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/cleanup_fire_table.ts @@ -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 { + CronFireRecovery, +} from "./types"; + + +export default __t.row({ + scheduledId: __t.u64().primaryKey().name("scheduled_id"), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + jobName: __t.string().name("job_name"), + generation: __t.u64(), + targetAt: __t.option(__t.timestamp()).name("target_at"), + get recovery() { + return __t.option(CronFireRecovery); + }, +}); diff --git a/spacetime-cron-ts/example/src/module_bindings/app/cron_jobs_table.ts b/spacetime-cron-ts/example/src/module_bindings/app/cron_jobs_table.ts new file mode 100644 index 00000000000..a7f49c2639a --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/cron_jobs_table.ts @@ -0,0 +1,30 @@ +// 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 { + CronSchedule, +} from "./types"; + + +export default __t.row({ + name: __t.string().primaryKey(), + get schedule() { + return CronSchedule; + }, + enabled: __t.bool(), + maxFailures: __t.u32().name("max_failures"), + consecutiveFailures: __t.u32().name("consecutive_failures"), + fireCount: __t.u64().name("fire_count"), + generation: __t.u64(), + lastRunAt: __t.option(__t.timestamp()).name("last_run_at"), + nextRunAt: __t.option(__t.timestamp()).name("next_run_at"), + disabledReason: __t.option(__t.string()).name("disabled_reason"), +}); diff --git a/spacetime-cron-ts/example/src/module_bindings/app/cron_reconcile_tick_table.ts b/spacetime-cron-ts/example/src/module_bindings/app/cron_reconcile_tick_table.ts new file mode 100644 index 00000000000..e58ad71b8ca --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/cron_reconcile_tick_table.ts @@ -0,0 +1,17 @@ +// 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({ + scheduledId: __t.u64().primaryKey().name("scheduled_id"), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + key: __t.string(), +}); diff --git a/spacetime-cron-ts/example/src/module_bindings/app/cron_run_table.ts b/spacetime-cron-ts/example/src/module_bindings/app/cron_run_table.ts new file mode 100644 index 00000000000..122a896817f --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/cron_run_table.ts @@ -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 { + CronRunStatus, +} from "./types"; + + +export default __t.row({ + invocationId: __t.string().primaryKey().name("invocation_id"), + jobName: __t.string().name("job_name"), + generation: __t.u64(), + sequence: __t.u64(), + scheduledFor: __t.timestamp().name("scheduled_for"), + completedAt: __t.timestamp().name("completed_at"), + get status() { + return CronRunStatus; + }, + error: __t.option(__t.string()), +}); diff --git a/spacetime-cron-ts/example/src/module_bindings/app/digest_fire_table.ts b/spacetime-cron-ts/example/src/module_bindings/app/digest_fire_table.ts new file mode 100644 index 00000000000..107696eecfe --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/digest_fire_table.ts @@ -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 { + CronFireRecovery, +} from "./types"; + + +export default __t.row({ + scheduledId: __t.u64().primaryKey().name("scheduled_id"), + scheduledAt: __t.scheduleAt().name("scheduled_at"), + jobName: __t.string().name("job_name"), + generation: __t.u64(), + targetAt: __t.option(__t.timestamp()).name("target_at"), + get recovery() { + return __t.option(CronFireRecovery); + }, +}); diff --git a/spacetime-cron-ts/example/src/module_bindings/app/index.ts b/spacetime-cron-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..cac97144152 --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,203 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 ScheduleCronReducer from "./schedule_cron_reducer"; +import ScheduleEveryReducer from "./schedule_every_reducer"; +import UnscheduleJobReducer from "./unschedule_job_reducer"; + +// Import all procedure arg schemas + +// Import all table schema definitions +import ActivityLogRow from "./activity_log_table"; +import CleanupFireRow from "./cleanup_fire_table"; +import CronJobsRow from "./cron_jobs_table"; +import CronReconcileTickRow from "./cron_reconcile_tick_table"; +import CronRunRow from "./cron_run_table"; +import DigestFireRow from "./digest_fire_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({ + activityLog: __table({ + name: 'activity_log', + indexes: [ + { accessor: 'at', name: 'activity_log_at_idx_btree', algorithm: 'btree', columns: [ + 'at', + ] }, + { accessor: 'id', name: 'activity_log_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + { accessor: 'jobName', name: 'activity_log_job_name_idx_btree', algorithm: 'btree', columns: [ + 'jobName', + ] }, + ], + constraints: [ + { name: 'activity_log_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, ActivityLogRow), + cleanupFire: __table({ + name: 'cleanup_fire', + indexes: [ + { accessor: 'jobName', name: 'cleanup_fire_job_name_idx_btree', algorithm: 'btree', columns: [ + 'jobName', + ] }, + { accessor: 'scheduledId', name: 'cleanup_fire_scheduled_id_idx_btree', algorithm: 'btree', columns: [ + 'scheduledId', + ] }, + ], + constraints: [ + { name: 'cleanup_fire_job_name_key', constraint: 'unique', columns: ['jobName'] }, + { name: 'cleanup_fire_scheduled_id_key', constraint: 'unique', columns: ['scheduledId'] }, + ], + }, CleanupFireRow), + cronReconcileTick: __table({ + name: 'cron_reconcile_tick', + indexes: [ + { accessor: 'key', name: 'cron_reconcile_tick_key_idx_btree', algorithm: 'btree', columns: [ + 'key', + ] }, + { accessor: 'scheduledId', name: 'cron_reconcile_tick_scheduled_id_idx_btree', algorithm: 'btree', columns: [ + 'scheduledId', + ] }, + ], + constraints: [ + { name: 'cron_reconcile_tick_key_key', constraint: 'unique', columns: ['key'] }, + { name: 'cron_reconcile_tick_scheduled_id_key', constraint: 'unique', columns: ['scheduledId'] }, + ], + }, CronReconcileTickRow), + cronRun: __table({ + name: 'cron_run', + indexes: [ + { accessor: 'invocationId', name: 'cron_run_invocation_id_idx_btree', algorithm: 'btree', columns: [ + 'invocationId', + ] }, + { accessor: 'jobName', name: 'cron_run_job_name_idx_btree', algorithm: 'btree', columns: [ + 'jobName', + ] }, + ], + constraints: [ + { name: 'cron_run_invocation_id_key', constraint: 'unique', columns: ['invocationId'] }, + ], + }, CronRunRow), + digestFire: __table({ + name: 'digest_fire', + indexes: [ + { accessor: 'jobName', name: 'digest_fire_job_name_idx_btree', algorithm: 'btree', columns: [ + 'jobName', + ] }, + { accessor: 'scheduledId', name: 'digest_fire_scheduled_id_idx_btree', algorithm: 'btree', columns: [ + 'scheduledId', + ] }, + ], + constraints: [ + { name: 'digest_fire_job_name_key', constraint: 'unique', columns: ['jobName'] }, + { name: 'digest_fire_scheduled_id_key', constraint: 'unique', columns: ['scheduledId'] }, + ], + }, DigestFireRow), + cronJobs: __table({ + name: 'cron_jobs', + indexes: [ + ], + constraints: [ + ], + }, CronJobsRow), +}); + +/** 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( + __reducerSchema("schedule_cron", ScheduleCronReducer), + __reducerSchema("schedule_every", ScheduleEveryReducer), + __reducerSchema("unschedule_job", UnscheduleJobReducer), +); + +/** 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( +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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 = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The procedures available in this remote SpacetimeDB module. */ +export const procedures = __convertToAccessorMap(proceduresSchema.procedures); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-cron-ts/example/src/module_bindings/app/schedule_cron_reducer.ts b/spacetime-cron-ts/example/src/module_bindings/app/schedule_cron_reducer.ts new file mode 100644 index 00000000000..708fe42e2e6 --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/schedule_cron_reducer.ts @@ -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 { + name: __t.string(), + expression: __t.string(), + timezone: __t.string(), + keep: __t.u32(), +}; diff --git a/spacetime-cron-ts/example/src/module_bindings/app/schedule_every_reducer.ts b/spacetime-cron-ts/example/src/module_bindings/app/schedule_every_reducer.ts new file mode 100644 index 00000000000..e054dd57fa5 --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/schedule_every_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + name: __t.string(), + seconds: __t.u32(), + keep: __t.u32(), +}; diff --git a/spacetime-cron-ts/example/src/module_bindings/app/types.ts b/spacetime-cron-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..c538888e35f --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,153 @@ +// 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 const ActivityLog = __t.object("ActivityLog", { + id: __t.u64(), + jobName: __t.string(), + message: __t.string(), + at: __t.timestamp(), +}); +export type ActivityLog = __Infer; + +export const CleanupCronArgs = __t.object("CleanupCronArgs", { + keep: __t.u32(), +}); +export type CleanupCronArgs = __Infer; + +export const CleanupFire = __t.object("CleanupFire", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), + jobName: __t.string(), + generation: __t.u64(), + targetAt: __t.option(__t.timestamp()), + get recovery() { + return __t.option(CronFireRecovery); + }, +}); +export type CleanupFire = __Infer; + +export const CronFireRecovery = __t.object("CronFireRecovery", { + sequence: __t.u64(), + scheduledFor: __t.timestamp(), + error: __t.string(), +}); +export type CronFireRecovery = __Infer; + +export const CronJob = __t.object("CronJob", { + name: __t.string(), + get schedule() { + return CronSchedule; + }, + get args() { + return CronJobArgsValue; + }, + enabled: __t.bool(), + maxFailures: __t.u32(), + consecutiveFailures: __t.u32(), + fireCount: __t.u64(), + generation: __t.u64(), + lastRunAt: __t.option(__t.timestamp()), + nextRunAt: __t.option(__t.timestamp()), + disabledReason: __t.option(__t.string()), +}); +export type CronJob = __Infer; + +// The tagged union or sum type for the algebraic type `CronJobArgsValue`. +export const CronJobArgsValue = __t.enum("CronJobArgsValue", { + get Cleanup() { + return CleanupCronArgs; + }, + Digest: __t.unit(), +}); +export type CronJobArgsValue = __Infer; + +export const CronJobView = __t.object("CronJobView", { + name: __t.string(), + get schedule() { + return CronSchedule; + }, + enabled: __t.bool(), + maxFailures: __t.u32(), + consecutiveFailures: __t.u32(), + fireCount: __t.u64(), + generation: __t.u64(), + lastRunAt: __t.option(__t.timestamp()), + nextRunAt: __t.option(__t.timestamp()), + disabledReason: __t.option(__t.string()), +}); +export type CronJobView = __Infer; + +export const CronJobs = __t.object("CronJobs", {}); +export type CronJobs = __Infer; + +export const CronReconcileTick = __t.object("CronReconcileTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), + key: __t.string(), +}); +export type CronReconcileTick = __Infer; + +export const CronRun = __t.object("CronRun", { + invocationId: __t.string(), + jobName: __t.string(), + generation: __t.u64(), + sequence: __t.u64(), + scheduledFor: __t.timestamp(), + completedAt: __t.timestamp(), + get status() { + return CronRunStatus; + }, + error: __t.option(__t.string()), +}); +export type CronRun = __Infer; + +// The tagged union or sum type for the algebraic type `CronRunStatus`. +export const CronRunStatus = __t.enum("CronRunStatus", { + Ok: __t.unit(), + Failed: __t.unit(), +}); +export type CronRunStatus = __Infer; + +// The tagged union or sum type for the algebraic type `CronSchedule`. +export const CronSchedule = __t.enum("CronSchedule", { + get Cron() { + return CronSpec; + }, + get Every() { + return EverySpec; + }, +}); +export type CronSchedule = __Infer; + +export const CronSpec = __t.object("CronSpec", { + expression: __t.string(), + timezone: __t.string(), +}); +export type CronSpec = __Infer; + +export const DigestFire = __t.object("DigestFire", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), + jobName: __t.string(), + generation: __t.u64(), + targetAt: __t.option(__t.timestamp()), + get recovery() { + return __t.option(CronFireRecovery); + }, +}); +export type DigestFire = __Infer; + +export const EverySpec = __t.object("EverySpec", { + seconds: __t.u32(), +}); +export type EverySpec = __Infer; + diff --git a/spacetime-cron-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-cron-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..d5ac825c9ab --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/types/procedures.ts @@ -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 procedure arg schemas + + diff --git a/spacetime-cron-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-cron-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..3f09c30fc78 --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,16 @@ +// 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 +import ScheduleCronReducer from "../schedule_cron_reducer"; +import ScheduleEveryReducer from "../schedule_every_reducer"; +import UnscheduleJobReducer from "../unschedule_job_reducer"; + +export type ScheduleCronParams = __Infer; +export type ScheduleEveryParams = __Infer; +export type UnscheduleJobParams = __Infer; + diff --git a/spacetime-cron-ts/example/src/module_bindings/app/unschedule_job_reducer.ts b/spacetime-cron-ts/example/src/module_bindings/app/unschedule_job_reducer.ts new file mode 100644 index 00000000000..ce493ee8574 --- /dev/null +++ b/spacetime-cron-ts/example/src/module_bindings/app/unschedule_job_reducer.ts @@ -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 { + name: __t.string(), +}; diff --git a/spacetime-files-ts/example/package.json b/spacetime-files-ts/example/package.json index 334a6a562e5..9c294c3d642 100644 --- a/spacetime-files-ts/example/package.json +++ b/spacetime-files-ts/example/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "check": "tsc --noEmit", "test:unit": "tsx scripts/test-downloads.ts && tsx scripts/test-selection.ts", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-files-ts/example/scripts/test-downloads.ts b/spacetime-files-ts/example/scripts/test-downloads.ts index c5cdabbdf2d..f6e3b9f0739 100644 --- a/spacetime-files-ts/example/scripts/test-downloads.ts +++ b/spacetime-files-ts/example/scripts/test-downloads.ts @@ -1,5 +1,5 @@ import * as assert from 'node:assert/strict'; -import type { FileSummary } from '../src/codegen/app/types'; +import type { FileSummary } from '../src/module_bindings/app/types'; import { ARCHIVE_ENTRY_COUNT_MAX, ARCHIVE_FILE_COUNT_MAX, diff --git a/spacetime-files-ts/example/src/app.ts b/spacetime-files-ts/example/src/app.ts index d26b7cad657..2d5b12a2635 100644 --- a/spacetime-files-ts/example/src/app.ts +++ b/spacetime-files-ts/example/src/app.ts @@ -1,6 +1,6 @@ // SpacetimeDB connection and file-manager UI composition. -import { DbConnection, tables, type ErrorContext } from './codegen/app'; -import type { FileSummary, Folder } from './codegen/app/types'; +import { DbConnection, tables, type ErrorContext } from './module_bindings/app'; +import type { FileSummary, Folder } from './module_bindings/app/types'; import { loadToken, saveToken, diff --git a/spacetime-files-ts/example/src/context-menu.ts b/spacetime-files-ts/example/src/context-menu.ts index aaeedf8f070..1ed4f0e95a4 100644 --- a/spacetime-files-ts/example/src/context-menu.ts +++ b/spacetime-files-ts/example/src/context-menu.ts @@ -1,4 +1,4 @@ -import type { FileSummary } from './codegen/app/types'; +import type { FileSummary } from './module_bindings/app/types'; import { icon } from './rendering'; import { escapeHtml } from './utils'; diff --git a/spacetime-files-ts/example/src/downloads.ts b/spacetime-files-ts/example/src/downloads.ts index 3e4f161dcd6..37c333153d5 100644 --- a/spacetime-files-ts/example/src/downloads.ts +++ b/spacetime-files-ts/example/src/downloads.ts @@ -1,4 +1,4 @@ -import type { FileSummary } from './codegen/app/types'; +import type { FileSummary } from './module_bindings/app/types'; import { buildZip, type ZipEntry } from './zip'; import { baseName, fileUrl, humanError, tsMs } from './utils'; diff --git a/spacetime-files-ts/example/src/list-actions.ts b/spacetime-files-ts/example/src/list-actions.ts index 09cec484005..02805ba0ce1 100644 --- a/spacetime-files-ts/example/src/list-actions.ts +++ b/spacetime-files-ts/example/src/list-actions.ts @@ -1,4 +1,4 @@ -import type { FileSummary } from './codegen/app/types'; +import type { FileSummary } from './module_bindings/app/types'; import type { ContextTarget } from './context-menu'; export interface ListActionServices { diff --git a/spacetime-files-ts/example/src/module_bindings/app/create_folder_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/create_folder_reducer.ts new file mode 100644 index 00000000000..8a2a9b08001 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/create_folder_reducer.ts @@ -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 { + path: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/delete_file_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/delete_file_reducer.ts new file mode 100644 index 00000000000..8a2a9b08001 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/delete_file_reducer.ts @@ -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 { + path: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/delete_folder_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/delete_folder_reducer.ts new file mode 100644 index 00000000000..8a2a9b08001 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/delete_folder_reducer.ts @@ -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 { + path: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/files/types.ts b/spacetime-files-ts/example/src/module_bindings/app/files/types.ts new file mode 100644 index 00000000000..a8336b9566f --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/files/types.ts @@ -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"; + +export const File = __t.object("File", { + id: __t.u64(), + ownerPathKey: __t.string(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const FileBlob = __t.object("FileBlob", { + fileId: __t.u64(), + bytes: __t.byteArray(), +}); +export type FileBlob = __Infer; + diff --git a/spacetime-files-ts/example/src/module_bindings/app/index.ts b/spacetime-files-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..9f863bb3fde --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,142 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 CreateFolderReducer from "./create_folder_reducer"; +import DeleteFileReducer from "./delete_file_reducer"; +import DeleteFolderReducer from "./delete_folder_reducer"; +import MoveFileReducer from "./move_file_reducer"; +import RenameFileReducer from "./rename_file_reducer"; +import RenameFolderReducer from "./rename_folder_reducer"; +import SetFileVisibilityReducer from "./set_file_visibility_reducer"; +import UploadFileReducer from "./upload_file_reducer"; + +// Import all procedure arg schemas +import * as ReadFileBytesProcedure from "./read_file_bytes_procedure"; + +// Import all table schema definitions +import MyFileSummariesRow from "./my_file_summaries_table"; +import MyFoldersRow from "./my_folders_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({ + myFileSummaries: __table({ + name: 'my_file_summaries', + indexes: [ + ], + constraints: [ + ], + }, MyFileSummariesRow), + myFolders: __table({ + name: 'my_folders', + indexes: [ + ], + constraints: [ + ], + }, MyFoldersRow), +}); + +/** 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( + __reducerSchema("create_folder", CreateFolderReducer), + __reducerSchema("delete_file", DeleteFileReducer), + __reducerSchema("delete_folder", DeleteFolderReducer), + __reducerSchema("move_file", MoveFileReducer), + __reducerSchema("rename_file", RenameFileReducer), + __reducerSchema("rename_folder", RenameFolderReducer), + __reducerSchema("set_file_visibility", SetFileVisibilityReducer), + __reducerSchema("upload_file", UploadFileReducer), +); + +/** 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("read_file_bytes", ReadFileBytesProcedure.params, ReadFileBytesProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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 = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The procedures available in this remote SpacetimeDB module. */ +export const procedures = __convertToAccessorMap(proceduresSchema.procedures); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-files-ts/example/src/module_bindings/app/move_file_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/move_file_reducer.ts new file mode 100644 index 00000000000..39be1981f74 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/move_file_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + oldPath: __t.string(), + targetFolderPath: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/my_file_summaries_table.ts b/spacetime-files-ts/example/src/module_bindings/app/my_file_summaries_table.ts new file mode 100644 index 00000000000..52eae105682 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/my_file_summaries_table.ts @@ -0,0 +1,21 @@ +// 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(), + path: __t.string(), + mimeType: __t.string().name("mime_type"), + size: __t.u64(), + sha256Hex: __t.string().name("sha_256_hex"), + visibility: __t.string(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-files-ts/example/src/module_bindings/app/my_folders_table.ts b/spacetime-files-ts/example/src/module_bindings/app/my_folders_table.ts new file mode 100644 index 00000000000..dc7c48b3c85 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/my_folders_table.ts @@ -0,0 +1,21 @@ +// 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().primaryKey(), + ownerUserId: __t.string().name("owner_user_id"), + path: __t.string(), + name: __t.string(), + parentPath: __t.string().name("parent_path"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-files-ts/example/src/module_bindings/app/read_file_bytes_procedure.ts b/spacetime-files-ts/example/src/module_bindings/app/read_file_bytes_procedure.ts new file mode 100644 index 00000000000..902cf595193 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/read_file_bytes_procedure.ts @@ -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"; + +import { + FileBytes, +} from "./types"; + +export const params = { + path: __t.string(), +}; +export const returnType = FileBytes \ No newline at end of file diff --git a/spacetime-files-ts/example/src/module_bindings/app/rename_file_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/rename_file_reducer.ts new file mode 100644 index 00000000000..50145dc0f1d --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/rename_file_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + oldPath: __t.string(), + newPath: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/rename_folder_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/rename_folder_reducer.ts new file mode 100644 index 00000000000..83dd1a2c22b --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/rename_folder_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + path: __t.string(), + newName: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/set_file_visibility_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/set_file_visibility_reducer.ts new file mode 100644 index 00000000000..f6ffa1c4f04 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/set_file_visibility_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + path: __t.string(), + visibility: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/types.ts b/spacetime-files-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..0d89dc6fd01 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,46 @@ +// 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 const FileBytes = __t.object("FileBytes", { + bytes: __t.byteArray(), + mimeType: __t.string(), +}); +export type FileBytes = __Infer; + +export const FileSummary = __t.object("FileSummary", { + id: __t.u64(), + path: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + updatedAt: __t.timestamp(), +}); +export type FileSummary = __Infer; + +export const Folder = __t.object("Folder", { + id: __t.u64(), + ownerUserId: __t.string(), + path: __t.string(), + name: __t.string(), + parentPath: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Folder = __Infer; + +export const MyFileSummaries = __t.object("MyFileSummaries", {}); +export type MyFileSummaries = __Infer; + +export const MyFolders = __t.object("MyFolders", {}); +export type MyFolders = __Infer; + diff --git a/spacetime-files-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-files-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..f6c24f082b1 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,13 @@ +// 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 ReadFileBytesProcedure from "../read_file_bytes_procedure"; + +export type ReadFileBytesArgs = __Infer; +export type ReadFileBytesResult = __Infer; + diff --git a/spacetime-files-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-files-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..4584b8cc134 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/types/reducers.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import CreateFolderReducer from "../create_folder_reducer"; +import DeleteFileReducer from "../delete_file_reducer"; +import DeleteFolderReducer from "../delete_folder_reducer"; +import MoveFileReducer from "../move_file_reducer"; +import RenameFileReducer from "../rename_file_reducer"; +import RenameFolderReducer from "../rename_folder_reducer"; +import SetFileVisibilityReducer from "../set_file_visibility_reducer"; +import UploadFileReducer from "../upload_file_reducer"; + +export type CreateFolderParams = __Infer; +export type DeleteFileParams = __Infer; +export type DeleteFolderParams = __Infer; +export type MoveFileParams = __Infer; +export type RenameFileParams = __Infer; +export type RenameFolderParams = __Infer; +export type SetFileVisibilityParams = __Infer; +export type UploadFileParams = __Infer; + diff --git a/spacetime-files-ts/example/src/module_bindings/app/upload_file_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/upload_file_reducer.ts new file mode 100644 index 00000000000..9d3d5519978 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/upload_file_reducer.ts @@ -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 { + path: __t.string(), + mimeType: __t.string(), + bytes: __t.byteArray(), + visibility: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/rendering.ts b/spacetime-files-ts/example/src/rendering.ts index a9d24384063..7a315b0fc3c 100644 --- a/spacetime-files-ts/example/src/rendering.ts +++ b/spacetime-files-ts/example/src/rendering.ts @@ -1,4 +1,4 @@ -import type { FileSummary, Folder } from './codegen/app/types'; +import type { FileSummary, Folder } from './module_bindings/app/types'; import { baseName, childPrefix, diff --git a/spacetime-files-ts/example/src/uploads.ts b/spacetime-files-ts/example/src/uploads.ts index a985d398a96..132650a5a62 100644 --- a/spacetime-files-ts/example/src/uploads.ts +++ b/spacetime-files-ts/example/src/uploads.ts @@ -1,5 +1,5 @@ import { FILE_BYTES_MAX } from '@spacetimedb/files/constants'; -import type { FileSummary } from './codegen/app/types'; +import type { FileSummary } from './module_bindings/app/types'; import type { DialogOptions } from './dialog'; import { errorCode, diff --git a/spacetime-files-ts/example/src/viewer.ts b/spacetime-files-ts/example/src/viewer.ts index db1d2a390d1..c1f2e6b309c 100644 --- a/spacetime-files-ts/example/src/viewer.ts +++ b/spacetime-files-ts/example/src/viewer.ts @@ -1,4 +1,4 @@ -import type { FileSummary } from './codegen/app/types'; +import type { FileSummary } from './module_bindings/app/types'; import { baseName, escapeHtml, fmtSize, humanError, tsMs } from './utils'; const element = (id: string): T => diff --git a/spacetime-grid-ts/example/package.json b/spacetime-grid-ts/example/package.json index 0ea447eb9f8..c63bba4f7b6 100644 --- a/spacetime-grid-ts/example/package.json +++ b/spacetime-grid-ts/example/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "test:unit": "node scripts/test-hex-geometry.mjs", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-grid-ts/example/src/app.ts b/spacetime-grid-ts/example/src/app.ts index cdddc2f79b2..1510b8b316f 100644 --- a/spacetime-grid-ts/example/src/app.ts +++ b/spacetime-grid-ts/example/src/app.ts @@ -14,7 +14,7 @@ import { tables, type ErrorContext, type SubscriptionHandle, -} from './codegen/app'; +} from './module_bindings/app'; interface AuthUser { userId: string; email: string; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/actor_directory_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/actor_directory_table.ts new file mode 100644 index 00000000000..a6151ec4368 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/actor_directory_table.ts @@ -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 { + ActorKind, +} from "./types"; + + +export default __t.row({ + actorId: __t.string().name("actor_id"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + get kind() { + return ActorKind; + }, +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/ai_take_turn_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/ai_take_turn_procedure.ts new file mode 100644 index 00000000000..8f540212e08 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/ai_take_turn_procedure.ts @@ -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"; + +import { + AiTakeTurnResult, +} from "./types"; + +export const params = { + matchId: __t.u64(), +}; +export const returnType = AiTakeTurnResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/attack_unit_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/attack_unit_procedure.ts new file mode 100644 index 00000000000..6416ac0c492 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/attack_unit_procedure.ts @@ -0,0 +1,17 @@ +// 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 const params = { + attackerId: __t.u64(), + targetId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/types.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/types.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/types.ts @@ -0,0 +1,137 @@ +// 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 const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/auth/whoami_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/auth/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/create_match_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/create_match_procedure.ts new file mode 100644 index 00000000000..19e91eb52ec --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/create_match_procedure.ts @@ -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"; + +import { + CreateMatchResult, +} from "./types"; + +export const params = { + vsAi: __t.bool(), +}; +export const returnType = CreateMatchResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/end_turn_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/end_turn_procedure.ts new file mode 100644 index 00000000000..c1f25720383 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/end_turn_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + matchId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/get_cells_in_range_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/get_cells_in_range_procedure.ts new file mode 100644 index 00000000000..486c44aee70 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/get_cells_in_range_procedure.ts @@ -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 { + CellsInRangeResult, +} from "./types"; + +export const params = { + gridId: __t.u64(), + originX: __t.i32(), + originY: __t.i32(), + maxCost: __t.i32(), +}; +export const returnType = CellsInRangeResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/grid/types.ts b/spacetime-grid-ts/example/src/module_bindings/app/grid/types.ts new file mode 100644 index 00000000000..48f7c6524bd --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/grid/types.ts @@ -0,0 +1,69 @@ +// 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 const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const EntityPath = __t.object("EntityPath", { + entityId: __t.u64(), + gridId: __t.u64(), + get cells() { + return __t.array(PathCell); + }, + cost: __t.i32(), + computedAt: __t.timestamp(), +}); +export type EntityPath = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const PathCell = __t.object("PathCell", { + x: __t.i32(), + y: __t.i32(), +}); +export type PathCell = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/index.ts b/spacetime-grid-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..05a46c2eea7 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,360 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 LinkConnectionReducer from "./link_connection_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; + +// Import all procedure arg schemas +import * as AiTakeTurnProcedure from "./ai_take_turn_procedure"; +import * as AttackUnitProcedure from "./attack_unit_procedure"; +import * as CreateMatchProcedure from "./create_match_procedure"; +import * as EndTurnProcedure from "./end_turn_procedure"; +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as GetCellsInRangeProcedure from "./get_cells_in_range_procedure"; +import * as JoinMatchProcedure from "./join_match_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as MoveUnitProcedure from "./move_unit_procedure"; +import * as WhoamiProcedure from "./whoami_procedure"; + +// Import all table schema definitions +import ActorDirectoryRow from "./actor_directory_table"; +import LobbyOpenMatchesRow from "./lobby_open_matches_table"; +import MyAuthUserRow from "./my_auth_user_table"; +import MyCellStatesRow from "./my_cell_states_table"; +import MyGridEntitiesRow from "./my_grid_entities_table"; +import MyGridsRow from "./my_grids_table"; +import MyMatchParticipantsRow from "./my_match_participants_table"; +import MyMatchesRow from "./my_matches_table"; +import MyPlayerUnitsRow from "./my_player_units_table"; +import NpcActorRow from "./npc_actor_table"; +import UnitTypeRow from "./unit_type_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; + +/** 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({ + npcActor: __table({ + name: 'npc_actor', + indexes: [ + { accessor: 'actorId', name: 'npc_actor_actor_id_idx_btree', algorithm: 'btree', columns: [ + 'actorId', + ] }, + ], + constraints: [ + { name: 'npc_actor_actor_id_key', constraint: 'unique', columns: ['actorId'] }, + ], + }, NpcActorRow), + unitType: __table({ + name: 'unit_type', + indexes: [ + { accessor: 'typeId', name: 'unit_type_type_id_idx_btree', algorithm: 'btree', columns: [ + 'typeId', + ] }, + ], + constraints: [ + { name: 'unit_type_type_id_key', constraint: 'unique', columns: ['typeId'] }, + ], + }, UnitTypeRow), + actorDirectory: __table({ + name: 'actor_directory', + indexes: [ + ], + constraints: [ + ], + }, ActorDirectoryRow), + lobbyOpenMatches: __table({ + name: 'lobby_open_matches', + indexes: [ + ], + constraints: [ + ], + }, LobbyOpenMatchesRow), + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myCellStates: __table({ + name: 'my_cell_states', + indexes: [ + ], + constraints: [ + ], + }, MyCellStatesRow), + myGridEntities: __table({ + name: 'my_grid_entities', + indexes: [ + ], + constraints: [ + ], + }, MyGridEntitiesRow), + myGrids: __table({ + name: 'my_grids', + indexes: [ + ], + constraints: [ + ], + }, MyGridsRow), + myMatchParticipants: __table({ + name: 'my_match_participants', + indexes: [ + ], + constraints: [ + ], + }, MyMatchParticipantsRow), + myMatches: __table({ + name: 'my_matches', + indexes: [ + ], + constraints: [ + ], + }, MyMatchesRow), + myPlayerUnits: __table({ + name: 'my_player_units', + indexes: [ + ], + constraints: [ + ], + }, MyPlayerUnitsRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), +}); + +/** 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( + __reducerSchema("link_connection", LinkConnectionReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), +); + +/** 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("ai_take_turn", AiTakeTurnProcedure.params, AiTakeTurnProcedure.returnType), + __procedureSchema("attack_unit", AttackUnitProcedure.params, AttackUnitProcedure.returnType), + __procedureSchema("create_match", CreateMatchProcedure.params, CreateMatchProcedure.returnType), + __procedureSchema("end_turn", EndTurnProcedure.params, EndTurnProcedure.returnType), + __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("get_cells_in_range", GetCellsInRangeProcedure.params, GetCellsInRangeProcedure.returnType), + __procedureSchema("join_match", JoinMatchProcedure.params, JoinMatchProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("move_unit", MoveUnitProcedure.params, MoveUnitProcedure.returnType), + __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + npcActor: __qb.npcActor, + unitType: __qb.unitType, + actorDirectory: __qb.actorDirectory, + lobbyOpenMatches: __qb.lobbyOpenMatches, + myAuthUser: __qb.myAuthUser, + myCellStates: __qb.myCellStates, + myGridEntities: __qb.myGridEntities, + myGrids: __qb.myGrids, + myMatchParticipants: __qb.myMatchParticipants, + myMatches: __qb.myMatches, + myPlayerUnits: __qb.myPlayerUnits, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + linkConnection: __reducerAccessors.linkConnection, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + setAuthConfig: __reducerAccessors.setAuthConfig, + unlinkConnection: __reducerAccessors.unlinkConnection, + updateProfile: __reducerAccessors.updateProfile, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + aiTakeTurn: __procedureAccessors.aiTakeTurn, + attackUnit: __procedureAccessors.attackUnit, + createMatch: __procedureAccessors.createMatch, + endTurn: __procedureAccessors.endTurn, + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + getCellsInRange: __procedureAccessors.getCellsInRange, + joinMatch: __procedureAccessors.joinMatch, + listMySessions: __procedureAccessors.listMySessions, + moveUnit: __procedureAccessors.moveUnit, + whoami: __procedureAccessors.whoami, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/join_match_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/join_match_procedure.ts new file mode 100644 index 00000000000..c1f25720383 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/join_match_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + matchId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/link_connection_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/lobby_open_matches_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/lobby_open_matches_table.ts new file mode 100644 index 00000000000..96bdadeae1c --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/lobby_open_matches_table.ts @@ -0,0 +1,17 @@ +// 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({ + matchId: __t.u64().name("match_id"), + hostUserId: __t.string().name("host_user_id"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/move_unit_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/move_unit_procedure.ts new file mode 100644 index 00000000000..75f2e273fb6 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/move_unit_procedure.ts @@ -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"; + +import { + MoveUnitResult, +} from "./types"; + +export const params = { + entityId: __t.u64(), + toX: __t.i32(), + toY: __t.i32(), +}; +export const returnType = MoveUnitResult \ No newline at end of file diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_auth_user_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_cell_states_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_cell_states_table.ts new file mode 100644 index 00000000000..9f1a3bfc694 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_cell_states_table.ts @@ -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().primaryKey(), + gridId: __t.u64().name("grid_id"), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_grid_entities_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_grid_entities_table.ts new file mode 100644 index 00000000000..1b75cfb7ea0 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_grid_entities_table.ts @@ -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({ + id: __t.u64().primaryKey(), + gridId: __t.u64().name("grid_id"), + ownerUserId: __t.string().name("owner_user_id"), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool().name("blocks_movement"), + label: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_grids_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_grids_table.ts new file mode 100644 index 00000000000..ba03c161ea6 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_grids_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + ownerUserId: __t.string().name("owner_user_id"), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32().name("default_cost"), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_match_participants_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_match_participants_table.ts new file mode 100644 index 00000000000..2a912a235b9 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_match_participants_table.ts @@ -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().primaryKey(), + matchId: __t.u64().name("match_id"), + userId: __t.string().name("user_id"), + seatIdx: __t.i32().name("seat_idx"), + team: __t.i32(), + joinedAt: __t.timestamp().name("joined_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_matches_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_matches_table.ts new file mode 100644 index 00000000000..ca03504bff9 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_matches_table.ts @@ -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 { + MatchStatus, +} from "./types"; + + +export default __t.row({ + matchId: __t.u64().primaryKey().name("match_id"), + get status() { + return MatchStatus; + }, + currentSeatIdx: __t.i32().name("current_seat_idx"), + turnNumber: __t.i32().name("turn_number"), + winnerUserId: __t.option(__t.string()).name("winner_user_id"), + gridId: __t.u64().name("grid_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/my_player_units_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/my_player_units_table.ts new file mode 100644 index 00000000000..fd18c44eb92 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/my_player_units_table.ts @@ -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({ + entityId: __t.u64().primaryKey().name("entity_id"), + matchId: __t.u64().name("match_id"), + ownerUserId: __t.string().name("owner_user_id"), + typeId: __t.string().name("type_id"), + currentHp: __t.i32().name("current_hp"), + hasMoved: __t.bool().name("has_moved"), + hasAttacked: __t.bool().name("has_attacked"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/npc_actor_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/npc_actor_table.ts new file mode 100644 index 00000000000..c4dd4173c26 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/npc_actor_table.ts @@ -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({ + actorId: __t.string().primaryKey().name("actor_id"), + name: __t.string(), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/revoke_session_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/set_auth_config_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/types.ts b/spacetime-grid-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..4727e25df0a --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,276 @@ +// 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 const ActorDirectory = __t.object("ActorDirectory", {}); +export type ActorDirectory = __Infer; + +export const ActorDirectoryRow = __t.object("ActorDirectoryRow", { + actorId: __t.string(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + get kind() { + return ActorKind; + }, +}); +export type ActorDirectoryRow = __Infer; + +// The tagged union or sum type for the algebraic type `ActorKind`. +export const ActorKind = __t.enum("ActorKind", { + User: __t.unit(), + Npc: __t.unit(), +}); +export type ActorKind = __Infer; + +export const AiAttackInfo = __t.object("AiAttackInfo", { + targetId: __t.u64(), + damage: __t.i32(), + killed: __t.bool(), + targetX: __t.i32(), + targetY: __t.i32(), + targetOwner: __t.string(), + targetTypeId: __t.string(), + targetPreHp: __t.i32(), +}); +export type AiAttackInfo = __Infer; + +export const AiPathStep = __t.object("AiPathStep", { + x: __t.i32(), + y: __t.i32(), +}); +export type AiPathStep = __Infer; + +export const AiTakeTurnResult = __t.object("AiTakeTurnResult", { + get events() { + return __t.array(AiTurnEvent); + }, +}); +export type AiTakeTurnResult = __Infer; + +export const AiTurnEvent = __t.object("AiTurnEvent", { + entityId: __t.u64(), + get movePath() { + return __t.option(__t.array(AiPathStep)); + }, + get attack() { + return __t.option(AiAttackInfo); + }, +}); +export type AiTurnEvent = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const CellState = __t.object("CellState", { + id: __t.u64(), + gridId: __t.u64(), + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), + terrain: __t.option(__t.string()), +}); +export type CellState = __Infer; + +export const CellsInRangeResult = __t.object("CellsInRangeResult", { + get cells() { + return __t.array(ReachableCell); + }, +}); +export type CellsInRangeResult = __Infer; + +export const CreateMatchResult = __t.object("CreateMatchResult", { + matchId: __t.u64(), + gridId: __t.u64(), +}); +export type CreateMatchResult = __Infer; + +export const Grid = __t.object("Grid", { + id: __t.u64(), + ownerUserId: __t.string(), + name: __t.string(), + kind: __t.string(), + orientation: __t.string(), + width: __t.i32(), + height: __t.i32(), + defaultCost: __t.i32(), + connectivity: __t.i32(), + mode: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Grid = __Infer; + +export const GridAuthUser = __t.object("GridAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridAuthUser = __Infer; + +export const GridEntity = __t.object("GridEntity", { + id: __t.u64(), + gridId: __t.u64(), + ownerUserId: __t.string(), + x: __t.i32(), + y: __t.i32(), + kind: __t.string(), + blocksMovement: __t.bool(), + label: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type GridEntity = __Infer; + +export const LobbyOpenMatch = __t.object("LobbyOpenMatch", { + matchId: __t.u64(), + hostUserId: __t.string(), + createdAt: __t.timestamp(), +}); +export type LobbyOpenMatch = __Infer; + +export const LobbyOpenMatches = __t.object("LobbyOpenMatches", {}); +export type LobbyOpenMatches = __Infer; + +export const Match = __t.object("Match", { + matchId: __t.u64(), + get status() { + return MatchStatus; + }, + currentSeatIdx: __t.i32(), + turnNumber: __t.i32(), + winnerUserId: __t.option(__t.string()), + gridId: __t.u64(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Match = __Infer; + +export const MatchParticipant = __t.object("MatchParticipant", { + id: __t.u64(), + matchId: __t.u64(), + userId: __t.string(), + seatIdx: __t.i32(), + team: __t.i32(), + joinedAt: __t.timestamp(), +}); +export type MatchParticipant = __Infer; + +// The tagged union or sum type for the algebraic type `MatchStatus`. +export const MatchStatus = __t.enum("MatchStatus", { + Waiting: __t.unit(), + Active: __t.unit(), + Ended: __t.unit(), +}); +export type MatchStatus = __Infer; + +export const MoveStep = __t.object("MoveStep", { + x: __t.i32(), + y: __t.i32(), +}); +export type MoveStep = __Infer; + +export const MoveUnitResult = __t.object("MoveUnitResult", { + get path() { + return __t.array(MoveStep); + }, +}); +export type MoveUnitResult = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyCellStates = __t.object("MyCellStates", {}); +export type MyCellStates = __Infer; + +export const MyGridEntities = __t.object("MyGridEntities", {}); +export type MyGridEntities = __Infer; + +export const MyGrids = __t.object("MyGrids", {}); +export type MyGrids = __Infer; + +export const MyMatchParticipants = __t.object("MyMatchParticipants", {}); +export type MyMatchParticipants = __Infer; + +export const MyMatches = __t.object("MyMatches", {}); +export type MyMatches = __Infer; + +export const MyPlayerUnits = __t.object("MyPlayerUnits", {}); +export type MyPlayerUnits = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const NpcActor = __t.object("NpcActor", { + actorId: __t.string(), + name: __t.string(), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type NpcActor = __Infer; + +export const PlayerUnit = __t.object("PlayerUnit", { + entityId: __t.u64(), + matchId: __t.u64(), + ownerUserId: __t.string(), + typeId: __t.string(), + currentHp: __t.i32(), + hasMoved: __t.bool(), + hasAttacked: __t.bool(), + createdAt: __t.timestamp(), +}); +export type PlayerUnit = __Infer; + +export const ReachableCell = __t.object("ReachableCell", { + x: __t.i32(), + y: __t.i32(), + cost: __t.i32(), +}); +export type ReachableCell = __Infer; + +export const UnitType = __t.object("UnitType", { + typeId: __t.string(), + name: __t.string(), + movement: __t.i32(), + attackRange: __t.i32(), + attackDmg: __t.i32(), + hp: __t.i32(), + glyph: __t.string(), +}); +export type UnitType = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-grid-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..ec53ed8c107 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,40 @@ +// 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 AiTakeTurnProcedure from "../ai_take_turn_procedure"; +import * as AttackUnitProcedure from "../attack_unit_procedure"; +import * as CreateMatchProcedure from "../create_match_procedure"; +import * as EndTurnProcedure from "../end_turn_procedure"; +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as GetCellsInRangeProcedure from "../get_cells_in_range_procedure"; +import * as JoinMatchProcedure from "../join_match_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as MoveUnitProcedure from "../move_unit_procedure"; +import * as WhoamiProcedure from "../whoami_procedure"; + +export type AiTakeTurnArgs = __Infer; +export type AiTakeTurnResult = __Infer; +export type AttackUnitArgs = __Infer; +export type AttackUnitResult = __Infer; +export type CreateMatchArgs = __Infer; +export type CreateMatchResult = __Infer; +export type EndTurnArgs = __Infer; +export type EndTurnResult = __Infer; +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type GetCellsInRangeArgs = __Infer; +export type GetCellsInRangeResult = __Infer; +export type JoinMatchArgs = __Infer; +export type JoinMatchResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type MoveUnitArgs = __Infer; +export type MoveUnitResult = __Infer; +export type WhoamiArgs = __Infer; +export type WhoamiResult = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-grid-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..db11ee2a71f --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/types/reducers.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import LinkConnectionReducer from "../link_connection_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; + +export type LinkConnectionParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UpdateProfileParams = __Infer; + diff --git a/spacetime-grid-ts/example/src/module_bindings/app/unit_type_table.ts b/spacetime-grid-ts/example/src/module_bindings/app/unit_type_table.ts new file mode 100644 index 00000000000..7cdd49c6677 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/unit_type_table.ts @@ -0,0 +1,21 @@ +// 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({ + typeId: __t.string().primaryKey().name("type_id"), + name: __t.string(), + movement: __t.i32(), + attackRange: __t.i32().name("attack_range"), + attackDmg: __t.i32().name("attack_dmg"), + hp: __t.i32(), + glyph: __t.string(), +}); diff --git a/spacetime-grid-ts/example/src/module_bindings/app/unlink_connection_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/update_profile_reducer.ts b/spacetime-grid-ts/example/src/module_bindings/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-grid-ts/example/src/module_bindings/app/whoami_procedure.ts b/spacetime-grid-ts/example/src/module_bindings/app/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-grid-ts/example/src/module_bindings/app/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-lobby-ts/example/package.json b/spacetime-lobby-ts/example/package.json index f3dc5f443a0..c222a8360eb 100644 --- a/spacetime-lobby-ts/example/package.json +++ b/spacetime-lobby-ts/example/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen --module-path ./spacetimedb -y", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "test:unit": "tsx scripts/test-model.ts", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-lobby-ts/example/src/app.ts b/spacetime-lobby-ts/example/src/app.ts index 7aa5072b495..fa3c90628cf 100644 --- a/spacetime-lobby-ts/example/src/app.ts +++ b/spacetime-lobby-ts/example/src/app.ts @@ -1,4 +1,4 @@ -import { DbConnection, tables, type ErrorContext } from './codegen'; +import { DbConnection, tables, type ErrorContext } from './module_bindings'; import { TOKEN_KEY_PREFIX, diff --git a/spacetime-lobby-ts/example/src/model.ts b/spacetime-lobby-ts/example/src/model.ts index bab7c966d4d..a1f935ad5b1 100644 --- a/spacetime-lobby-ts/example/src/model.ts +++ b/spacetime-lobby-ts/example/src/model.ts @@ -297,4 +297,4 @@ export function maneuverFx(move: ManeuverCatalogRow): string { .map(c => `${c.text}`) .join(''); } -import type { EventContext } from './codegen'; +import type { EventContext } from './module_bindings'; diff --git a/spacetime-lobby-ts/example/src/module_bindings/advance_duel_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/advance_duel_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/advance_duel_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/choose_maneuver_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/choose_maneuver_reducer.ts new file mode 100644 index 00000000000..68bf00e0400 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/choose_maneuver_reducer.ts @@ -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"; + +import { + ManeuverSlot, +} from "./types"; + +export default { + roomId: __t.u64(), + get slot() { + return ManeuverSlot; + }, +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/fallback_to_ai_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/fallback_to_ai_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/fallback_to_ai_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/find_duel_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/find_duel_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/find_duel_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/index.ts b/spacetime-lobby-ts/example/src/module_bindings/index.ts new file mode 100644 index 00000000000..c85f8365e3b --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/index.ts @@ -0,0 +1,422 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 AdvanceDuelReducer from "./advance_duel_reducer"; +import ChooseManeuverReducer from "./choose_maneuver_reducer"; +import FallbackToAiReducer from "./fallback_to_ai_reducer"; +import FindDuelReducer from "./find_duel_reducer"; +import JoinDuelRoomReducer from "./join_duel_room_reducer"; +import LeaveDuelReducer from "./leave_duel_reducer"; +import QueueAgainReducer from "./queue_again_reducer"; +import SelectShipReducer from "./select_ship_reducer"; +import SetDisplayNameReducer from "./set_display_name_reducer"; + +// Import all procedure arg schemas + +// Import all table schema definitions +import LobbyQueueSummaryRow from "./lobby_queue_summary_table"; +import LobbyRankedLeaderboardRow from "./lobby_ranked_leaderboard_table"; +import ManeuverCatalogRow from "./maneuver_catalog_table"; +import MyDuelCombatantsRow from "./my_duel_combatants_table"; +import MyDuelManeuversRow from "./my_duel_maneuvers_table"; +import MyDuelRoundLogsRow from "./my_duel_round_logs_table"; +import MyDuelsRow from "./my_duels_table"; +import MyLobbyRatingsRow from "./my_lobby_ratings_table"; +import MyLobbyRoomSeatsRow from "./my_lobby_room_seats_table"; +import MyLobbyRoomsRow from "./my_lobby_rooms_table"; +import MyLobbyTicketsRow from "./my_lobby_tickets_table"; +import MyProfileRow from "./my_profile_table"; +import PlayersRow from "./players_table"; +import ShipCatalogRow from "./ship_catalog_table"; + +// Import namespace table schema definitions +import Lobby_LobbyAdminMatchResultsRow from "./lobby/lobby_admin_match_results_table"; +import Lobby_LobbyAdminRoomSeatsRow from "./lobby/lobby_admin_room_seats_table"; +import Lobby_LobbyAdminRoomsRow from "./lobby/lobby_admin_rooms_table"; +import Lobby_LobbyAdminTicketsRow from "./lobby/lobby_admin_tickets_table"; +import Lobby_LobbyQueueSummaryRow from "./lobby/lobby_queue_summary_table"; +import Lobby_LobbyRankedLeaderboardRow from "./lobby/lobby_ranked_leaderboard_table"; +import Lobby_MyLobbyRatingsRow from "./lobby/my_lobby_ratings_table"; +import Lobby_MyLobbyRoomSeatsRow from "./lobby/my_lobby_room_seats_table"; +import Lobby_MyLobbyRoomsRow from "./lobby/my_lobby_rooms_table"; +import Lobby_MyLobbyTicketsRow from "./lobby/my_lobby_tickets_table"; + +// Import namespace reducer arg schemas +import Lobby_AddAdminIdentityReducer from "./lobby/add_admin_identity_reducer"; +import Lobby_CancelTicketReducer from "./lobby/cancel_ticket_reducer"; +import Lobby_CloseRoomReducer from "./lobby/close_room_reducer"; +import Lobby_ExpireTicketsReducer from "./lobby/expire_tickets_reducer"; +import Lobby_JoinQueueReducer from "./lobby/join_queue_reducer"; +import Lobby_JoinRankedQueueReducer from "./lobby/join_ranked_queue_reducer"; +import Lobby_JoinRoomReducer from "./lobby/join_room_reducer"; +import Lobby_LeaveRoomReducer from "./lobby/leave_room_reducer"; +import Lobby_RemoveAdminIdentityReducer from "./lobby/remove_admin_identity_reducer"; +import Lobby_SetRatingReducer from "./lobby/set_rating_reducer"; +import Lobby_UpdateConfigReducer from "./lobby/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Lobby_GetLobbyStatusProcedure from "./lobby/get_lobby_status_procedure"; + +/** 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({ + maneuverCatalog: __table({ + name: 'maneuver_catalog', + indexes: [ + { accessor: 'maneuverId', name: 'maneuver_catalog_maneuver_id_idx_btree', algorithm: 'btree', columns: [ + 'maneuverId', + ] }, + { accessor: 'byShipClass', name: 'maneuver_catalog_ship_class_idx_btree', algorithm: 'btree', columns: [ + 'shipClass', + ] }, + { accessor: 'bySlot', name: 'maneuver_catalog_slot_idx_btree', algorithm: 'btree', columns: [ + 'slot', + ] }, + ], + constraints: [ + { name: 'maneuver_catalog_maneuver_id_key', constraint: 'unique', columns: ['maneuverId'] }, + ], + }, ManeuverCatalogRow), + shipCatalog: __table({ + name: 'ship_catalog', + indexes: [ + { accessor: 'byShipClass', name: 'ship_catalog_ship_class_idx_btree', algorithm: 'btree', columns: [ + 'shipClass', + ] }, + { accessor: 'shipId', name: 'ship_catalog_ship_id_idx_btree', algorithm: 'btree', columns: [ + 'shipId', + ] }, + ], + constraints: [ + { name: 'ship_catalog_ship_id_key', constraint: 'unique', columns: ['shipId'] }, + ], + }, ShipCatalogRow), + lobbyQueueSummary: __table({ + name: 'lobby_queue_summary', + indexes: [ + ], + constraints: [ + ], + }, LobbyQueueSummaryRow), + lobbyRankedLeaderboard: __table({ + name: 'lobby_ranked_leaderboard', + indexes: [ + ], + constraints: [ + ], + }, LobbyRankedLeaderboardRow), + myDuelCombatants: __table({ + name: 'my_duel_combatants', + indexes: [ + ], + constraints: [ + ], + }, MyDuelCombatantsRow), + myDuelManeuvers: __table({ + name: 'my_duel_maneuvers', + indexes: [ + ], + constraints: [ + ], + }, MyDuelManeuversRow), + myDuelRoundLogs: __table({ + name: 'my_duel_round_logs', + indexes: [ + ], + constraints: [ + ], + }, MyDuelRoundLogsRow), + myDuels: __table({ + name: 'my_duels', + indexes: [ + ], + constraints: [ + ], + }, MyDuelsRow), + myLobbyRatings: __table({ + name: 'my_lobby_ratings', + indexes: [ + ], + constraints: [ + ], + }, MyLobbyRatingsRow), + myLobbyRoomSeats: __table({ + name: 'my_lobby_room_seats', + indexes: [ + ], + constraints: [ + ], + }, MyLobbyRoomSeatsRow), + myLobbyRooms: __table({ + name: 'my_lobby_rooms', + indexes: [ + ], + constraints: [ + ], + }, MyLobbyRoomsRow), + myLobbyTickets: __table({ + name: 'my_lobby_tickets', + indexes: [ + ], + constraints: [ + ], + }, MyLobbyTicketsRow), + myProfile: __table({ + name: 'my_profile', + indexes: [ + ], + constraints: [ + ], + }, MyProfileRow), + players: __table({ + name: 'players', + indexes: [ + ], + constraints: [ + ], + }, PlayersRow), + "lobby.lobby_admin_match_results": __table({ + name: 'lobby.lobby_admin_match_results', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyAdminMatchResultsRow), + "lobby.lobby_admin_room_seats": __table({ + name: 'lobby.lobby_admin_room_seats', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyAdminRoomSeatsRow), + "lobby.lobby_admin_rooms": __table({ + name: 'lobby.lobby_admin_rooms', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyAdminRoomsRow), + "lobby.lobby_admin_tickets": __table({ + name: 'lobby.lobby_admin_tickets', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyAdminTicketsRow), + "lobby.lobby_queue_summary": __table({ + name: 'lobby.lobby_queue_summary', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyQueueSummaryRow), + "lobby.lobby_ranked_leaderboard": __table({ + name: 'lobby.lobby_ranked_leaderboard', + indexes: [ + ], + constraints: [ + ], + }, Lobby_LobbyRankedLeaderboardRow), + "lobby.my_lobby_ratings": __table({ + name: 'lobby.my_lobby_ratings', + indexes: [ + ], + constraints: [ + ], + }, Lobby_MyLobbyRatingsRow), + "lobby.my_lobby_room_seats": __table({ + name: 'lobby.my_lobby_room_seats', + indexes: [ + ], + constraints: [ + ], + }, Lobby_MyLobbyRoomSeatsRow), + "lobby.my_lobby_rooms": __table({ + name: 'lobby.my_lobby_rooms', + indexes: [ + ], + constraints: [ + ], + }, Lobby_MyLobbyRoomsRow), + "lobby.my_lobby_tickets": __table({ + name: 'lobby.my_lobby_tickets', + indexes: [ + ], + constraints: [ + ], + }, Lobby_MyLobbyTicketsRow), +}); + +/** 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( + __reducerSchema("advance_duel", AdvanceDuelReducer), + __reducerSchema("choose_maneuver", ChooseManeuverReducer), + __reducerSchema("fallback_to_ai", FallbackToAiReducer), + __reducerSchema("find_duel", FindDuelReducer), + __reducerSchema("join_duel_room", JoinDuelRoomReducer), + __reducerSchema("leave_duel", LeaveDuelReducer), + __reducerSchema("queue_again", QueueAgainReducer), + __reducerSchema("select_ship", SelectShipReducer), + __reducerSchema("set_display_name", SetDisplayNameReducer), + __reducerSchema("lobby.add_admin_identity", Lobby_AddAdminIdentityReducer), + __reducerSchema("lobby.cancel_ticket", Lobby_CancelTicketReducer), + __reducerSchema("lobby.close_room", Lobby_CloseRoomReducer), + __reducerSchema("lobby.expire_tickets", Lobby_ExpireTicketsReducer), + __reducerSchema("lobby.join_queue", Lobby_JoinQueueReducer), + __reducerSchema("lobby.join_ranked_queue", Lobby_JoinRankedQueueReducer), + __reducerSchema("lobby.join_room", Lobby_JoinRoomReducer), + __reducerSchema("lobby.leave_room", Lobby_LeaveRoomReducer), + __reducerSchema("lobby.remove_admin_identity", Lobby_RemoveAdminIdentityReducer), + __reducerSchema("lobby.set_rating", Lobby_SetRatingReducer), + __reducerSchema("lobby.update_config", Lobby_UpdateConfigReducer), +); + +/** 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("lobby.get_lobby_status", Lobby_GetLobbyStatusProcedure.params, Lobby_GetLobbyStatusProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + maneuverCatalog: __qb.maneuverCatalog, + shipCatalog: __qb.shipCatalog, + lobbyQueueSummary: __qb.lobbyQueueSummary, + lobbyRankedLeaderboard: __qb.lobbyRankedLeaderboard, + myDuelCombatants: __qb.myDuelCombatants, + myDuelManeuvers: __qb.myDuelManeuvers, + myDuelRoundLogs: __qb.myDuelRoundLogs, + myDuels: __qb.myDuels, + myLobbyRatings: __qb.myLobbyRatings, + myLobbyRoomSeats: __qb.myLobbyRoomSeats, + myLobbyRooms: __qb.myLobbyRooms, + myLobbyTickets: __qb.myLobbyTickets, + myProfile: __qb.myProfile, + players: __qb.players, + lobby: { + lobbyAdminMatchResults: __qb["lobby.lobby_admin_match_results"], + lobbyAdminRoomSeats: __qb["lobby.lobby_admin_room_seats"], + lobbyAdminRooms: __qb["lobby.lobby_admin_rooms"], + lobbyAdminTickets: __qb["lobby.lobby_admin_tickets"], + lobbyQueueSummary: __qb["lobby.lobby_queue_summary"], + lobbyRankedLeaderboard: __qb["lobby.lobby_ranked_leaderboard"], + myLobbyRatings: __qb["lobby.my_lobby_ratings"], + myLobbyRoomSeats: __qb["lobby.my_lobby_room_seats"], + myLobbyRooms: __qb["lobby.my_lobby_rooms"], + myLobbyTickets: __qb["lobby.my_lobby_tickets"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + advanceDuel: __reducerAccessors.advanceDuel, + chooseManeuver: __reducerAccessors.chooseManeuver, + fallbackToAi: __reducerAccessors.fallbackToAi, + findDuel: __reducerAccessors.findDuel, + joinDuelRoom: __reducerAccessors.joinDuelRoom, + leaveDuel: __reducerAccessors.leaveDuel, + queueAgain: __reducerAccessors.queueAgain, + selectShip: __reducerAccessors.selectShip, + setDisplayName: __reducerAccessors.setDisplayName, + lobby: { + addAdminIdentity: __reducerAccessors["lobby.addAdminIdentity"], + cancelTicket: __reducerAccessors["lobby.cancelTicket"], + closeRoom: __reducerAccessors["lobby.closeRoom"], + expireTickets: __reducerAccessors["lobby.expireTickets"], + joinQueue: __reducerAccessors["lobby.joinQueue"], + joinRankedQueue: __reducerAccessors["lobby.joinRankedQueue"], + joinRoom: __reducerAccessors["lobby.joinRoom"], + leaveRoom: __reducerAccessors["lobby.leaveRoom"], + removeAdminIdentity: __reducerAccessors["lobby.removeAdminIdentity"], + setRating: __reducerAccessors["lobby.setRating"], + updateConfig: __reducerAccessors["lobby.updateConfig"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + lobby: { + getLobbyStatus: __procedureAccessors["lobby.getLobbyStatus"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-lobby-ts/example/src/module_bindings/join_duel_room_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/join_duel_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/join_duel_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/leave_duel_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/leave_duel_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/leave_duel_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/add_admin_identity_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/add_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/add_admin_identity_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/cancel_ticket_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/cancel_ticket_reducer.ts new file mode 100644 index 00000000000..2098bfc3542 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/cancel_ticket_reducer.ts @@ -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 { + ticketId: __t.string(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/close_room_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/close_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/close_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/expire_tickets_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/expire_tickets_reducer.ts new file mode 100644 index 00000000000..dc602ed6dc0 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/expire_tickets_reducer.ts @@ -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 { + limit: __t.option(__t.u32()), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/get_lobby_status_procedure.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/get_lobby_status_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/get_lobby_status_procedure.ts @@ -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 const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/join_queue_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_queue_reducer.ts new file mode 100644 index 00000000000..4490f6c1830 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_queue_reducer.ts @@ -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 { + pool: __t.string(), + matchSize: __t.u32(), + attributesJson: __t.option(__t.string()), + ttlSeconds: __t.option(__t.u32()), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/join_ranked_queue_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_ranked_queue_reducer.ts new file mode 100644 index 00000000000..33f6e61037c --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_ranked_queue_reducer.ts @@ -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 { + pool: __t.string(), + matchSize: __t.u32(), + attributesJson: __t.option(__t.string()), + ttlSeconds: __t.option(__t.u32()), + ratingPool: __t.option(__t.string()), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/join_room_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/join_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/leave_room_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/leave_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/leave_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_match_results_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_match_results_table.ts new file mode 100644 index 00000000000..8b3e65b81d5 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_match_results_table.ts @@ -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"; + +export default __t.row({ + resultId: __t.u64().primaryKey().name("result_id"), + roomId: __t.u64().name("room_id"), + pool: __t.string(), + winnerSubject: __t.option(__t.string()).name("winner_subject"), + loserSubject: __t.option(__t.string()).name("loser_subject"), + subjectA: __t.string().name("subject_a"), + subjectB: __t.string().name("subject_b"), + ratingABefore: __t.i32().name("rating_a_before"), + ratingAAfter: __t.i32().name("rating_a_after"), + ratingBBefore: __t.i32().name("rating_b_before"), + ratingBAfter: __t.i32().name("rating_b_after"), + reportedAt: __t.timestamp().name("reported_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_room_seats_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_room_seats_table.ts new file mode 100644 index 00000000000..089ec4f3f16 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_room_seats_table.ts @@ -0,0 +1,30 @@ +// 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 { + LobbySeatStatus, +} from "./types"; + + +export default __t.row({ + seatId: __t.u64().primaryKey().name("seat_id"), + roomId: __t.u64().name("room_id"), + subject: __t.string(), + ticketId: __t.option(__t.string()).name("ticket_id"), + seatIndex: __t.u32().name("seat_index"), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()).name("joined_at"), + leftAt: __t.option(__t.timestamp()).name("left_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_rooms_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_rooms_table.ts new file mode 100644 index 00000000000..1ba9b3b5490 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_rooms_table.ts @@ -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 { + LobbyRoomStatus, +} from "./types"; + + +export default __t.row({ + roomId: __t.u64().primaryKey().name("room_id"), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()).name("metadata_json"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + closedAt: __t.option(__t.timestamp()).name("closed_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_tickets_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_tickets_table.ts new file mode 100644 index 00000000000..e6e4c70d96f --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_admin_tickets_table.ts @@ -0,0 +1,34 @@ +// 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 { + LobbyTicketStatus, +} from "./types"; + + +export default __t.row({ + ticketId: __t.string().primaryKey().name("ticket_id"), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32().name("match_size"), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()).name("rating_pool"), + partyId: __t.option(__t.string()).name("party_id"), + attributesJson: __t.option(__t.string()).name("attributes_json"), + roomId: __t.option(__t.u64()).name("room_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + expiresAtMicros: __t.i64().name("expires_at_micros"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_queue_summary_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_queue_summary_table.ts new file mode 100644 index 00000000000..1218594a764 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_queue_summary_table.ts @@ -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({ + pool: __t.string(), + queuedTickets: __t.u32().name("queued_tickets"), + readyRooms: __t.u32().name("ready_rooms"), + activeRooms: __t.u32().name("active_rooms"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_ranked_leaderboard_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_ranked_leaderboard_table.ts new file mode 100644 index 00000000000..1c44ef6d4ee --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/lobby_ranked_leaderboard_table.ts @@ -0,0 +1,21 @@ +// 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({ + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_ratings_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_ratings_table.ts new file mode 100644 index 00000000000..1c44ef6d4ee --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_ratings_table.ts @@ -0,0 +1,21 @@ +// 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({ + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_room_seats_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_room_seats_table.ts new file mode 100644 index 00000000000..089ec4f3f16 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_room_seats_table.ts @@ -0,0 +1,30 @@ +// 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 { + LobbySeatStatus, +} from "./types"; + + +export default __t.row({ + seatId: __t.u64().primaryKey().name("seat_id"), + roomId: __t.u64().name("room_id"), + subject: __t.string(), + ticketId: __t.option(__t.string()).name("ticket_id"), + seatIndex: __t.u32().name("seat_index"), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()).name("joined_at"), + leftAt: __t.option(__t.timestamp()).name("left_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_rooms_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_rooms_table.ts new file mode 100644 index 00000000000..1ba9b3b5490 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_rooms_table.ts @@ -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 { + LobbyRoomStatus, +} from "./types"; + + +export default __t.row({ + roomId: __t.u64().primaryKey().name("room_id"), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()).name("metadata_json"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + closedAt: __t.option(__t.timestamp()).name("closed_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_tickets_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_tickets_table.ts new file mode 100644 index 00000000000..e6e4c70d96f --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/my_lobby_tickets_table.ts @@ -0,0 +1,34 @@ +// 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 { + LobbyTicketStatus, +} from "./types"; + + +export default __t.row({ + ticketId: __t.string().primaryKey().name("ticket_id"), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32().name("match_size"), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()).name("rating_pool"), + partyId: __t.option(__t.string()).name("party_id"), + attributesJson: __t.option(__t.string()).name("attributes_json"), + roomId: __t.option(__t.u64()).name("room_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + expiresAtMicros: __t.i64().name("expires_at_micros"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/remove_admin_identity_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/remove_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/remove_admin_identity_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/set_rating_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/set_rating_reducer.ts new file mode 100644 index 00000000000..8a53e81760f --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/set_rating_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/types.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/types.ts new file mode 100644 index 00000000000..243ba354994 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/types.ts @@ -0,0 +1,182 @@ +// 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 const LobbyAdminIdentity = __t.object("LobbyAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type LobbyAdminIdentity = __Infer; + +export const LobbyAdminMatchResults = __t.object("LobbyAdminMatchResults", {}); +export type LobbyAdminMatchResults = __Infer; + +export const LobbyAdminRoomSeats = __t.object("LobbyAdminRoomSeats", {}); +export type LobbyAdminRoomSeats = __Infer; + +export const LobbyAdminRooms = __t.object("LobbyAdminRooms", {}); +export type LobbyAdminRooms = __Infer; + +export const LobbyAdminTickets = __t.object("LobbyAdminTickets", {}); +export type LobbyAdminTickets = __Infer; + +export const LobbyConfig = __t.object("LobbyConfig", { + singleton: __t.bool(), + defaultTicketTtlSeconds: __t.u32(), + maxMatchSize: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type LobbyConfig = __Infer; + +export const LobbyMatchResult = __t.object("LobbyMatchResult", { + resultId: __t.u64(), + roomId: __t.u64(), + pool: __t.string(), + winnerSubject: __t.option(__t.string()), + loserSubject: __t.option(__t.string()), + subjectA: __t.string(), + subjectB: __t.string(), + ratingABefore: __t.i32(), + ratingAAfter: __t.i32(), + ratingBBefore: __t.i32(), + ratingBAfter: __t.i32(), + reportedAt: __t.timestamp(), +}); +export type LobbyMatchResult = __Infer; + +export const LobbyQueueSummary = __t.object("LobbyQueueSummary", {}); +export type LobbyQueueSummary = __Infer; + +export const LobbyQueueSummaryRow = __t.object("LobbyQueueSummaryRow", { + pool: __t.string(), + queuedTickets: __t.u32(), + readyRooms: __t.u32(), + activeRooms: __t.u32(), +}); +export type LobbyQueueSummaryRow = __Infer; + +export const LobbyQueueTicket = __t.object("LobbyQueueTicket", { + ticketId: __t.string(), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32(), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()), + partyId: __t.option(__t.string()), + attributesJson: __t.option(__t.string()), + roomId: __t.option(__t.u64()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + expiresAtMicros: __t.i64(), +}); +export type LobbyQueueTicket = __Infer; + +export const LobbyRankedLeaderboard = __t.object("LobbyRankedLeaderboard", {}); +export type LobbyRankedLeaderboard = __Infer; + +export const LobbyRankedRatingRow = __t.object("LobbyRankedRatingRow", { + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); +export type LobbyRankedRatingRow = __Infer; + +export const LobbyRoom = __t.object("LobbyRoom", { + roomId: __t.u64(), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + closedAt: __t.option(__t.timestamp()), +}); +export type LobbyRoom = __Infer; + +export const LobbyRoomSeat = __t.object("LobbyRoomSeat", { + seatId: __t.u64(), + roomId: __t.u64(), + subject: __t.string(), + ticketId: __t.option(__t.string()), + seatIndex: __t.u32(), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()), + leftAt: __t.option(__t.timestamp()), + updatedAt: __t.timestamp(), +}); +export type LobbyRoomSeat = __Infer; + +// The tagged union or sum type for the algebraic type `LobbyRoomStatus`. +export const LobbyRoomStatus = __t.enum("LobbyRoomStatus", { + Ready: __t.unit(), + Active: __t.unit(), + Closed: __t.unit(), + Abandoned: __t.unit(), +}); +export type LobbyRoomStatus = __Infer; + +// The tagged union or sum type for the algebraic type `LobbySeatStatus`. +export const LobbySeatStatus = __t.enum("LobbySeatStatus", { + Reserved: __t.unit(), + Joined: __t.unit(), + Left: __t.unit(), + Disconnected: __t.unit(), +}); +export type LobbySeatStatus = __Infer; + +export const LobbySubjectRating = __t.object("LobbySubjectRating", { + ratingId: __t.string(), + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + ratingOrder: __t.i64(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type LobbySubjectRating = __Infer; + +// The tagged union or sum type for the algebraic type `LobbyTicketStatus`. +export const LobbyTicketStatus = __t.enum("LobbyTicketStatus", { + Queued: __t.unit(), + Matched: __t.unit(), + Cancelled: __t.unit(), + Expired: __t.unit(), +}); +export type LobbyTicketStatus = __Infer; + +export const MyLobbyRatings = __t.object("MyLobbyRatings", {}); +export type MyLobbyRatings = __Infer; + +export const MyLobbyRoomSeats = __t.object("MyLobbyRoomSeats", {}); +export type MyLobbyRoomSeats = __Infer; + +export const MyLobbyRooms = __t.object("MyLobbyRooms", {}); +export type MyLobbyRooms = __Infer; + +export const MyLobbyTickets = __t.object("MyLobbyTickets", {}); +export type MyLobbyTickets = __Infer; + diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby/update_config_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby/update_config_reducer.ts new file mode 100644 index 00000000000..0f85a99be65 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby/update_config_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + defaultTicketTtlSeconds: __t.u32(), + maxMatchSize: __t.u32(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby_queue_summary_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby_queue_summary_table.ts new file mode 100644 index 00000000000..1218594a764 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby_queue_summary_table.ts @@ -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({ + pool: __t.string(), + queuedTickets: __t.u32().name("queued_tickets"), + readyRooms: __t.u32().name("ready_rooms"), + activeRooms: __t.u32().name("active_rooms"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/lobby_ranked_leaderboard_table.ts b/spacetime-lobby-ts/example/src/module_bindings/lobby_ranked_leaderboard_table.ts new file mode 100644 index 00000000000..1c44ef6d4ee --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/lobby_ranked_leaderboard_table.ts @@ -0,0 +1,21 @@ +// 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({ + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/maneuver_catalog_table.ts b/spacetime-lobby-ts/example/src/module_bindings/maneuver_catalog_table.ts new file mode 100644 index 00000000000..1e5b31d4193 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/maneuver_catalog_table.ts @@ -0,0 +1,34 @@ +// 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 { + ShipClass, + ManeuverSlot, +} from "./types"; + + +export default __t.row({ + maneuverId: __t.string().primaryKey().name("maneuver_id"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + get slot() { + return ManeuverSlot; + }, + name: __t.string(), + description: __t.string(), + damageBps: __t.i32().name("damage_bps"), + defenseBps: __t.i32().name("defense_bps"), + shieldRestore: __t.u32().name("shield_restore"), + selfShieldCost: __t.u32().name("self_shield_cost"), + critBonusBps: __t.i32().name("crit_bonus_bps"), + dodgeBonusBps: __t.i32().name("dodge_bonus_bps"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_duel_combatants_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_duel_combatants_table.ts new file mode 100644 index 00000000000..4ad0a77b6f1 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_duel_combatants_table.ts @@ -0,0 +1,35 @@ +// 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 { + ShipClass, +} from "./types"; + + +export default __t.row({ + combatantId: __t.string().primaryKey().name("combatant_id"), + roomId: __t.u64().name("room_id"), + subject: __t.string(), + displayName: __t.string().name("display_name"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + hull: __t.u32(), + maxHull: __t.u32().name("max_hull"), + shields: __t.u32(), + maxShields: __t.u32().name("max_shields"), + attack: __t.u32(), + defense: __t.u32(), + speed: __t.u32(), + critBps: __t.u32().name("crit_bps"), + dodgeBps: __t.u32().name("dodge_bps"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_duel_maneuvers_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_duel_maneuvers_table.ts new file mode 100644 index 00000000000..94ec937c71c --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_duel_maneuvers_table.ts @@ -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 { + ManeuverSlot, +} from "./types"; + + +export default __t.row({ + choiceId: __t.string().primaryKey().name("choice_id"), + roomId: __t.u64().name("room_id"), + round: __t.u32(), + subject: __t.string(), + get slot() { + return ManeuverSlot; + }, + maneuverId: __t.string().name("maneuver_id"), + chosenAt: __t.timestamp().name("chosen_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_duel_round_logs_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_duel_round_logs_table.ts new file mode 100644 index 00000000000..5ccc1d071be --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_duel_round_logs_table.ts @@ -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({ + logId: __t.u64().primaryKey().name("log_id"), + roomId: __t.u64().name("room_id"), + round: __t.u32(), + message: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_duels_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_duels_table.ts new file mode 100644 index 00000000000..2e5e82ff970 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_duels_table.ts @@ -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 { + DuelStatus, +} from "./types"; + + +export default __t.row({ + roomId: __t.u64().primaryKey().name("room_id"), + get status() { + return DuelStatus; + }, + round: __t.u32(), + winnerSubject: __t.option(__t.string()).name("winner_subject"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_lobby_ratings_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_ratings_table.ts new file mode 100644 index 00000000000..1c44ef6d4ee --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_ratings_table.ts @@ -0,0 +1,21 @@ +// 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({ + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_lobby_room_seats_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_room_seats_table.ts new file mode 100644 index 00000000000..089ec4f3f16 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_room_seats_table.ts @@ -0,0 +1,30 @@ +// 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 { + LobbySeatStatus, +} from "./types"; + + +export default __t.row({ + seatId: __t.u64().primaryKey().name("seat_id"), + roomId: __t.u64().name("room_id"), + subject: __t.string(), + ticketId: __t.option(__t.string()).name("ticket_id"), + seatIndex: __t.u32().name("seat_index"), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()).name("joined_at"), + leftAt: __t.option(__t.timestamp()).name("left_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_lobby_rooms_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_rooms_table.ts new file mode 100644 index 00000000000..1ba9b3b5490 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_rooms_table.ts @@ -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 { + LobbyRoomStatus, +} from "./types"; + + +export default __t.row({ + roomId: __t.u64().primaryKey().name("room_id"), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()).name("metadata_json"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + closedAt: __t.option(__t.timestamp()).name("closed_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_lobby_tickets_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_tickets_table.ts new file mode 100644 index 00000000000..e6e4c70d96f --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_lobby_tickets_table.ts @@ -0,0 +1,34 @@ +// 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 { + LobbyTicketStatus, +} from "./types"; + + +export default __t.row({ + ticketId: __t.string().primaryKey().name("ticket_id"), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32().name("match_size"), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()).name("rating_pool"), + partyId: __t.option(__t.string()).name("party_id"), + attributesJson: __t.option(__t.string()).name("attributes_json"), + roomId: __t.option(__t.u64()).name("room_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + expiresAtMicros: __t.i64().name("expires_at_micros"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/my_profile_table.ts b/spacetime-lobby-ts/example/src/module_bindings/my_profile_table.ts new file mode 100644 index 00000000000..82ca37479f5 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/my_profile_table.ts @@ -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 { + ShipClass, +} from "./types"; + + +export default __t.row({ + subject: __t.string().primaryKey(), + displayName: __t.string().name("display_name"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/players_table.ts b/spacetime-lobby-ts/example/src/module_bindings/players_table.ts new file mode 100644 index 00000000000..82ca37479f5 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/players_table.ts @@ -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 { + ShipClass, +} from "./types"; + + +export default __t.row({ + subject: __t.string().primaryKey(), + displayName: __t.string().name("display_name"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/queue_again_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/queue_again_reducer.ts new file mode 100644 index 00000000000..7f866437244 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/queue_again_reducer.ts @@ -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 { + roomId: __t.option(__t.u64()), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/select_ship_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/select_ship_reducer.ts new file mode 100644 index 00000000000..1531f694080 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/select_ship_reducer.ts @@ -0,0 +1,21 @@ +// 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 { + ShipClass, +} from "./types"; + +export default { + get shipClass() { + return ShipClass; + }, +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/set_display_name_reducer.ts b/spacetime-lobby-ts/example/src/module_bindings/set_display_name_reducer.ts new file mode 100644 index 00000000000..547493ef073 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/set_display_name_reducer.ts @@ -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 { + displayName: __t.string(), +}; diff --git a/spacetime-lobby-ts/example/src/module_bindings/ship_catalog_table.ts b/spacetime-lobby-ts/example/src/module_bindings/ship_catalog_table.ts new file mode 100644 index 00000000000..8da9e44a661 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/ship_catalog_table.ts @@ -0,0 +1,31 @@ +// 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 { + ShipClass, +} from "./types"; + + +export default __t.row({ + shipId: __t.string().primaryKey().name("ship_id"), + get shipClass() { + return ShipClass.name("ship_class"); + }, + role: __t.string(), + description: __t.string(), + hull: __t.u32(), + shields: __t.u32(), + attack: __t.u32(), + defense: __t.u32(), + speed: __t.u32(), + critBps: __t.u32().name("crit_bps"), + dodgeBps: __t.u32().name("dodge_bps"), +}); diff --git a/spacetime-lobby-ts/example/src/module_bindings/types.ts b/spacetime-lobby-ts/example/src/module_bindings/types.ts new file mode 100644 index 00000000000..5ec51715acc --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/types.ts @@ -0,0 +1,272 @@ +// 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 const Duel = __t.object("Duel", { + roomId: __t.u64(), + get status() { + return DuelStatus; + }, + round: __t.u32(), + winnerSubject: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Duel = __Infer; + +export const DuelCombatant = __t.object("DuelCombatant", { + combatantId: __t.string(), + roomId: __t.u64(), + subject: __t.string(), + displayName: __t.string(), + get shipClass() { + return ShipClass; + }, + hull: __t.u32(), + maxHull: __t.u32(), + shields: __t.u32(), + maxShields: __t.u32(), + attack: __t.u32(), + defense: __t.u32(), + speed: __t.u32(), + critBps: __t.u32(), + dodgeBps: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type DuelCombatant = __Infer; + +export const DuelManeuver = __t.object("DuelManeuver", { + choiceId: __t.string(), + roomId: __t.u64(), + round: __t.u32(), + subject: __t.string(), + get slot() { + return ManeuverSlot; + }, + maneuverId: __t.string(), + chosenAt: __t.timestamp(), +}); +export type DuelManeuver = __Infer; + +export const DuelRoundLog = __t.object("DuelRoundLog", { + logId: __t.u64(), + roomId: __t.u64(), + round: __t.u32(), + message: __t.string(), + createdAt: __t.timestamp(), +}); +export type DuelRoundLog = __Infer; + +// The tagged union or sum type for the algebraic type `DuelStatus`. +export const DuelStatus = __t.enum("DuelStatus", { + Configuring: __t.unit(), + Active: __t.unit(), + Complete: __t.unit(), + Abandoned: __t.unit(), +}); +export type DuelStatus = __Infer; + +export const ExampleLobbyQueueSummaryRow = __t.object("ExampleLobbyQueueSummaryRow", { + pool: __t.string(), + queuedTickets: __t.u32(), + readyRooms: __t.u32(), + activeRooms: __t.u32(), +}); +export type ExampleLobbyQueueSummaryRow = __Infer; + +export const ExampleLobbyRatingRow = __t.object("ExampleLobbyRatingRow", { + pool: __t.string(), + subject: __t.string(), + rating: __t.i32(), + wins: __t.u32(), + losses: __t.u32(), + draws: __t.u32(), + matches: __t.u32(), +}); +export type ExampleLobbyRatingRow = __Infer; + +export const LobbyQueueSummary = __t.object("LobbyQueueSummary", {}); +export type LobbyQueueSummary = __Infer; + +export const LobbyQueueTicket = __t.object("LobbyQueueTicket", { + ticketId: __t.string(), + pool: __t.string(), + subject: __t.string(), + get status() { + return LobbyTicketStatus; + }, + matchSize: __t.u32(), + ranked: __t.bool(), + rating: __t.option(__t.i32()), + ratingPool: __t.option(__t.string()), + partyId: __t.option(__t.string()), + attributesJson: __t.option(__t.string()), + roomId: __t.option(__t.u64()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + expiresAtMicros: __t.i64(), +}); +export type LobbyQueueTicket = __Infer; + +export const LobbyRankedLeaderboard = __t.object("LobbyRankedLeaderboard", {}); +export type LobbyRankedLeaderboard = __Infer; + +export const LobbyRoom = __t.object("LobbyRoom", { + roomId: __t.u64(), + pool: __t.string(), + get status() { + return LobbyRoomStatus; + }, + capacity: __t.u32(), + metadataJson: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + closedAt: __t.option(__t.timestamp()), +}); +export type LobbyRoom = __Infer; + +export const LobbyRoomSeat = __t.object("LobbyRoomSeat", { + seatId: __t.u64(), + roomId: __t.u64(), + subject: __t.string(), + ticketId: __t.option(__t.string()), + seatIndex: __t.u32(), + get status() { + return LobbySeatStatus; + }, + ready: __t.bool(), + joinedAt: __t.option(__t.timestamp()), + leftAt: __t.option(__t.timestamp()), + updatedAt: __t.timestamp(), +}); +export type LobbyRoomSeat = __Infer; + +// The tagged union or sum type for the algebraic type `LobbyRoomStatus`. +export const LobbyRoomStatus = __t.enum("LobbyRoomStatus", { + Ready: __t.unit(), + Active: __t.unit(), + Closed: __t.unit(), + Abandoned: __t.unit(), +}); +export type LobbyRoomStatus = __Infer; + +// The tagged union or sum type for the algebraic type `LobbySeatStatus`. +export const LobbySeatStatus = __t.enum("LobbySeatStatus", { + Reserved: __t.unit(), + Joined: __t.unit(), + Left: __t.unit(), + Disconnected: __t.unit(), +}); +export type LobbySeatStatus = __Infer; + +// The tagged union or sum type for the algebraic type `LobbyTicketStatus`. +export const LobbyTicketStatus = __t.enum("LobbyTicketStatus", { + Queued: __t.unit(), + Matched: __t.unit(), + Cancelled: __t.unit(), + Expired: __t.unit(), +}); +export type LobbyTicketStatus = __Infer; + +export const ManeuverCatalog = __t.object("ManeuverCatalog", { + maneuverId: __t.string(), + get shipClass() { + return ShipClass; + }, + get slot() { + return ManeuverSlot; + }, + name: __t.string(), + description: __t.string(), + damageBps: __t.i32(), + defenseBps: __t.i32(), + shieldRestore: __t.u32(), + selfShieldCost: __t.u32(), + critBonusBps: __t.i32(), + dodgeBonusBps: __t.i32(), +}); +export type ManeuverCatalog = __Infer; + +// The tagged union or sum type for the algebraic type `ManeuverSlot`. +export const ManeuverSlot = __t.enum("ManeuverSlot", { + Primary: __t.unit(), + Defensive: __t.unit(), + Risky: __t.unit(), +}); +export type ManeuverSlot = __Infer; + +export const MyDuelCombatants = __t.object("MyDuelCombatants", {}); +export type MyDuelCombatants = __Infer; + +export const MyDuelManeuvers = __t.object("MyDuelManeuvers", {}); +export type MyDuelManeuvers = __Infer; + +export const MyDuelRoundLogs = __t.object("MyDuelRoundLogs", {}); +export type MyDuelRoundLogs = __Infer; + +export const MyDuels = __t.object("MyDuels", {}); +export type MyDuels = __Infer; + +export const MyLobbyRatings = __t.object("MyLobbyRatings", {}); +export type MyLobbyRatings = __Infer; + +export const MyLobbyRoomSeats = __t.object("MyLobbyRoomSeats", {}); +export type MyLobbyRoomSeats = __Infer; + +export const MyLobbyRooms = __t.object("MyLobbyRooms", {}); +export type MyLobbyRooms = __Infer; + +export const MyLobbyTickets = __t.object("MyLobbyTickets", {}); +export type MyLobbyTickets = __Infer; + +export const MyProfile = __t.object("MyProfile", {}); +export type MyProfile = __Infer; + +export const Pilot = __t.object("Pilot", { + subject: __t.string(), + displayName: __t.string(), + get shipClass() { + return ShipClass; + }, + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Pilot = __Infer; + +export const Players = __t.object("Players", {}); +export type Players = __Infer; + +export const ShipCatalog = __t.object("ShipCatalog", { + shipId: __t.string(), + get shipClass() { + return ShipClass; + }, + role: __t.string(), + description: __t.string(), + hull: __t.u32(), + shields: __t.u32(), + attack: __t.u32(), + defense: __t.u32(), + speed: __t.u32(), + critBps: __t.u32(), + dodgeBps: __t.u32(), +}); +export type ShipCatalog = __Infer; + +// The tagged union or sum type for the algebraic type `ShipClass`. +export const ShipClass = __t.enum("ShipClass", { + Bulwark: __t.unit(), + Interceptor: __t.unit(), + Phantom: __t.unit(), + Artillery: __t.unit(), +}); +export type ShipClass = __Infer; + diff --git a/spacetime-lobby-ts/example/src/module_bindings/types/procedures.ts b/spacetime-lobby-ts/example/src/module_bindings/types/procedures.ts new file mode 100644 index 00000000000..d5ac825c9ab --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/types/procedures.ts @@ -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 procedure arg schemas + + diff --git a/spacetime-lobby-ts/example/src/module_bindings/types/reducers.ts b/spacetime-lobby-ts/example/src/module_bindings/types/reducers.ts new file mode 100644 index 00000000000..e99c2f4fd37 --- /dev/null +++ b/spacetime-lobby-ts/example/src/module_bindings/types/reducers.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import AdvanceDuelReducer from "../advance_duel_reducer"; +import ChooseManeuverReducer from "../choose_maneuver_reducer"; +import FallbackToAiReducer from "../fallback_to_ai_reducer"; +import FindDuelReducer from "../find_duel_reducer"; +import JoinDuelRoomReducer from "../join_duel_room_reducer"; +import LeaveDuelReducer from "../leave_duel_reducer"; +import QueueAgainReducer from "../queue_again_reducer"; +import SelectShipReducer from "../select_ship_reducer"; +import SetDisplayNameReducer from "../set_display_name_reducer"; + +export type AdvanceDuelParams = __Infer; +export type ChooseManeuverParams = __Infer; +export type FallbackToAiParams = __Infer; +export type FindDuelParams = __Infer; +export type JoinDuelRoomParams = __Infer; +export type LeaveDuelParams = __Infer; +export type QueueAgainParams = __Infer; +export type SelectShipParams = __Infer; +export type SetDisplayNameParams = __Infer; + diff --git a/spacetime-posthog-ts/example/package.json b/spacetime-posthog-ts/example/package.json index f893dc48662..cb37aced710 100644 --- a/spacetime-posthog-ts/example/package.json +++ b/spacetime-posthog-ts/example/package.json @@ -4,12 +4,12 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen --module-path ./spacetimedb -y", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts", "test:unit": "tsx scripts/test-economy.ts" }, diff --git a/spacetime-posthog-ts/example/server.ts b/spacetime-posthog-ts/example/server.ts index d3e21cf2082..efc53743d16 100644 --- a/spacetime-posthog-ts/example/server.ts +++ b/spacetime-posthog-ts/example/server.ts @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { DbConnection, tables, type ErrorContext } from './src/codegen'; +import { DbConnection, tables, type ErrorContext } from './src/module_bindings'; import { PRODUCTS, SCENARIOS } from './catalog/catalog'; import { discardStoredServerToken, diff --git a/spacetime-posthog-ts/example/src/app.ts b/spacetime-posthog-ts/example/src/app.ts index 0642234ca01..f2863c0d386 100644 --- a/spacetime-posthog-ts/example/src/app.ts +++ b/spacetime-posthog-ts/example/src/app.ts @@ -3,7 +3,7 @@ import { tables, type ErrorContext, type EventContext, -} from './codegen'; +} from './module_bindings'; import { MAX_MACHINE_LEVEL, RUSH_CYCLE_TICKS, diff --git a/spacetime-posthog-ts/example/src/module_bindings/buy_supply_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/buy_supply_reducer.ts new file mode 100644 index 00000000000..3b5f1cb92ab --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/buy_supply_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + kind: __t.string(), + units: __t.u32(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/buy_upgrade_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/buy_upgrade_reducer.ts new file mode 100644 index 00000000000..923a27af927 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/buy_upgrade_reducer.ts @@ -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 { + kind: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_analytics_summary_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_analytics_summary_table.ts new file mode 100644 index 00000000000..8eaef875426 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_analytics_summary_table.ts @@ -0,0 +1,17 @@ +// 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({ + queued: __t.u64(), + delivered: __t.u64(), + failed: __t.u64(), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_config_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_config_table.ts new file mode 100644 index 00000000000..2200717ee71 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_config_table.ts @@ -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({ + owner: __t.string().primaryKey(), + scenarioId: __t.string().name("scenario_id"), + tick: __t.u64(), + experimentKey: __t.string().name("experiment_key"), + experimentVariant: __t.option(__t.string()).name("experiment_variant"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_econ_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_econ_table.ts new file mode 100644 index 00000000000..aa684e30231 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_econ_table.ts @@ -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"; + +export default __t.row({ + owner: __t.string().primaryKey(), + cashCents: __t.u64().name("cash_cents"), + computeUnits: __t.u32().name("compute_units"), + contextUnits: __t.u32().name("context_units"), + memoryUnits: __t.u32().name("memory_units"), + suppliesSpentCents: __t.u64().name("supplies_spent_cents"), + stockouts: __t.u32(), + reputation: __t.u32(), + workers: __t.u32(), + machineLevel: __t.u32().name("machine_level"), + seats: __t.u32(), + storageLevel: __t.u32().name("storage_level"), + reneged: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_metrics_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_metrics_table.ts new file mode 100644 index 00000000000..aa776242be2 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_metrics_table.ts @@ -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({ + owner: __t.string().primaryKey(), + tick: __t.u64(), + views: __t.u64(), + carts: __t.u64(), + checkouts: __t.u64(), + purchases: __t.u64(), + abandons: __t.u64(), + revenueCents: __t.u64().name("revenue_cents"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_products_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_products_table.ts new file mode 100644 index 00000000000..ea5ad870f38 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_products_table.ts @@ -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({ + key: __t.string().primaryKey(), + owner: __t.string(), + productId: __t.string().name("product_id"), + name: __t.string(), + category: __t.string(), + description: __t.string(), + baseAppeal: __t.u32().name("base_appeal"), + active: __t.bool(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_queue_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_queue_table.ts new file mode 100644 index 00000000000..f6ffc4aabae --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_queue_table.ts @@ -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"; + +export default __t.row({ + queueId: __t.u64().primaryKey().name("queue_id"), + owner: __t.string(), + botId: __t.string().name("bot_id"), + profile: __t.string(), + scenarioId: __t.string().name("scenario_id"), + productId: __t.string().name("product_id"), + variantId: __t.string().name("variant_id"), + wants: __t.string(), + thrifty: __t.bool(), + arrivedTick: __t.u64().name("arrived_tick"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_activity_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_activity_table.ts new file mode 100644 index 00000000000..7db0b0e7eee --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_activity_table.ts @@ -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({ + activityId: __t.u64().primaryKey().name("activity_id"), + owner: __t.string(), + tick: __t.u64(), + kind: __t.string(), + message: __t.string(), + profile: __t.option(__t.string()), + productId: __t.option(__t.string()).name("product_id"), + variantId: __t.option(__t.string()).name("variant_id"), + amountCents: __t.option(__t.u32()).name("amount_cents"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_purchases_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_purchases_table.ts new file mode 100644 index 00000000000..f8686ababf5 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_purchases_table.ts @@ -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({ + purchaseId: __t.u64().primaryKey().name("purchase_id"), + owner: __t.string(), + sessionId: __t.u64().name("session_id"), + tick: __t.u64(), + botId: __t.string().name("bot_id"), + profile: __t.string(), + productId: __t.string().name("product_id"), + variantId: __t.string().name("variant_id"), + pricePaidCents: __t.u32().name("price_paid_cents"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_sessions_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_sessions_table.ts new file mode 100644 index 00000000000..67d0f14b874 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_sessions_table.ts @@ -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"; + +export default __t.row({ + sessionId: __t.u64().primaryKey().name("session_id"), + owner: __t.string(), + botId: __t.string().name("bot_id"), + tick: __t.u64(), + profile: __t.string(), + scenarioId: __t.string().name("scenario_id"), + productId: __t.option(__t.string()).name("product_id"), + variantId: __t.option(__t.string()).name("variant_id"), + stage: __t.string(), + revenueCents: __t.u32().name("revenue_cents"), + reason: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_scenarios_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_scenarios_table.ts new file mode 100644 index 00000000000..633b7ca5a2f --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_scenarios_table.ts @@ -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"; + +export default __t.row({ + scenarioId: __t.string().primaryKey().name("scenario_id"), + name: __t.string(), + description: __t.string(), + trafficPerTick: __t.u32().name("traffic_per_tick"), + priceSensitivity: __t.u32().name("price_sensitivity"), + rushBias: __t.u32().name("rush_bias"), + researchBias: __t.u32().name("research_bias"), + visualBias: __t.u32().name("visual_bias"), + memoryBias: __t.u32().name("memory_bias"), + premiumBias: __t.u32().name("premium_bias"), + volatility: __t.u32(), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_variants_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_variants_table.ts new file mode 100644 index 00000000000..d58a40be71b --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_variants_table.ts @@ -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"; + +export default __t.row({ + key: __t.string().primaryKey(), + owner: __t.string(), + variantId: __t.string().name("variant_id"), + productId: __t.string().name("product_id"), + name: __t.string(), + flavor: __t.string(), + contextTokens: __t.u32().name("context_tokens"), + reasoning: __t.u32(), + latency: __t.u32(), + priceCents: __t.u32().name("price_cents"), + baselinePriceCents: __t.u32().name("baseline_price_cents"), + discountBps: __t.u32().name("discount_bps"), + active: __t.bool(), + featured: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/flush_analytics_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/flush_analytics_procedure.ts new file mode 100644 index 00000000000..3bf3bb5c715 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/flush_analytics_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + limit: __t.u32(), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/index.ts b/spacetime-posthog-ts/example/src/module_bindings/index.ts new file mode 100644 index 00000000000..9034afa0b84 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/index.ts @@ -0,0 +1,327 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 BuySupplyReducer from "./buy_supply_reducer"; +import BuyUpgradeReducer from "./buy_upgrade_reducer"; +import InitSessionReducer from "./init_session_reducer"; +import ResetSimulationReducer from "./reset_simulation_reducer"; +import SelectScenarioReducer from "./select_scenario_reducer"; +import SetExperimentVariantReducer from "./set_experiment_variant_reducer"; +import SetFeaturedVariantReducer from "./set_featured_variant_reducer"; +import SetProductActiveReducer from "./set_product_active_reducer"; +import SetVariantActiveReducer from "./set_variant_active_reducer"; +import SetVariantDiscountReducer from "./set_variant_discount_reducer"; +import SetVariantPriceReducer from "./set_variant_price_reducer"; +import SimulateTickReducer from "./simulate_tick_reducer"; +import SyncCatalogReducer from "./sync_catalog_reducer"; + +// Import all procedure arg schemas +import * as FlushAnalyticsProcedure from "./flush_analytics_procedure"; + +// Import all table schema definitions +import CafeAnalyticsSummaryRow from "./cafe_analytics_summary_table"; +import CafeConfigRow from "./cafe_config_table"; +import CafeEconRow from "./cafe_econ_table"; +import CafeMetricsRow from "./cafe_metrics_table"; +import CafeProductsRow from "./cafe_products_table"; +import CafeQueueRow from "./cafe_queue_table"; +import CafeRecentActivityRow from "./cafe_recent_activity_table"; +import CafeRecentPurchasesRow from "./cafe_recent_purchases_table"; +import CafeRecentSessionsRow from "./cafe_recent_sessions_table"; +import CafeScenariosRow from "./cafe_scenarios_table"; +import CafeVariantsRow from "./cafe_variants_table"; +import PosthogDeliveryLogAdminRow from "./posthog_delivery_log_admin_table"; +import PosthogOutboxAdminRow from "./posthog_outbox_admin_table"; + +// Import namespace table schema definitions +import Posthog_PosthogDeliveryLogAdminRow from "./posthog/posthog_delivery_log_admin_table"; +import Posthog_PosthogOutboxAdminRow from "./posthog/posthog_outbox_admin_table"; + +// Import namespace reducer arg schemas +import Posthog_EnqueueEventReducer from "./posthog/enqueue_event_reducer"; + +// Import namespace procedure arg schemas +import * as Posthog_AddAdminIdentityProcedure from "./posthog/add_admin_identity_procedure"; +import * as Posthog_CaptureNowProcedure from "./posthog/capture_now_procedure"; +import * as Posthog_FlushOutboxProcedure from "./posthog/flush_outbox_procedure"; +import * as Posthog_GetFeatureFlagProcedure from "./posthog/get_feature_flag_procedure"; +import * as Posthog_GetPosthogConfigStatusProcedure from "./posthog/get_posthog_config_status_procedure"; +import * as Posthog_RemoveAdminIdentityProcedure from "./posthog/remove_admin_identity_procedure"; +import * as Posthog_SetPosthogConfigProcedure from "./posthog/set_posthog_config_procedure"; + +/** 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({ + cafeAnalyticsSummary: __table({ + name: 'cafe_analytics_summary', + indexes: [ + ], + constraints: [ + ], + }, CafeAnalyticsSummaryRow), + cafeConfig: __table({ + name: 'cafe_config', + indexes: [ + ], + constraints: [ + ], + }, CafeConfigRow), + cafeEcon: __table({ + name: 'cafe_econ', + indexes: [ + ], + constraints: [ + ], + }, CafeEconRow), + cafeMetrics: __table({ + name: 'cafe_metrics', + indexes: [ + ], + constraints: [ + ], + }, CafeMetricsRow), + cafeProducts: __table({ + name: 'cafe_products', + indexes: [ + ], + constraints: [ + ], + }, CafeProductsRow), + cafeQueue: __table({ + name: 'cafe_queue', + indexes: [ + ], + constraints: [ + ], + }, CafeQueueRow), + cafeRecentActivity: __table({ + name: 'cafe_recent_activity', + indexes: [ + ], + constraints: [ + ], + }, CafeRecentActivityRow), + cafeRecentPurchases: __table({ + name: 'cafe_recent_purchases', + indexes: [ + ], + constraints: [ + ], + }, CafeRecentPurchasesRow), + cafeRecentSessions: __table({ + name: 'cafe_recent_sessions', + indexes: [ + ], + constraints: [ + ], + }, CafeRecentSessionsRow), + cafeScenarios: __table({ + name: 'cafe_scenarios', + indexes: [ + ], + constraints: [ + ], + }, CafeScenariosRow), + cafeVariants: __table({ + name: 'cafe_variants', + indexes: [ + ], + constraints: [ + ], + }, CafeVariantsRow), + posthogDeliveryLogAdmin: __table({ + name: 'posthog_delivery_log_admin', + indexes: [ + ], + constraints: [ + ], + }, PosthogDeliveryLogAdminRow), + posthogOutboxAdmin: __table({ + name: 'posthog_outbox_admin', + indexes: [ + ], + constraints: [ + ], + }, PosthogOutboxAdminRow), + "posthog.posthog_delivery_log_admin": __table({ + name: 'posthog.posthog_delivery_log_admin', + indexes: [ + ], + constraints: [ + ], + }, Posthog_PosthogDeliveryLogAdminRow), + "posthog.posthog_outbox_admin": __table({ + name: 'posthog.posthog_outbox_admin', + indexes: [ + ], + constraints: [ + ], + }, Posthog_PosthogOutboxAdminRow), +}); + +/** 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( + __reducerSchema("buy_supply", BuySupplyReducer), + __reducerSchema("buy_upgrade", BuyUpgradeReducer), + __reducerSchema("init_session", InitSessionReducer), + __reducerSchema("reset_simulation", ResetSimulationReducer), + __reducerSchema("select_scenario", SelectScenarioReducer), + __reducerSchema("set_experiment_variant", SetExperimentVariantReducer), + __reducerSchema("set_featured_variant", SetFeaturedVariantReducer), + __reducerSchema("set_product_active", SetProductActiveReducer), + __reducerSchema("set_variant_active", SetVariantActiveReducer), + __reducerSchema("set_variant_discount", SetVariantDiscountReducer), + __reducerSchema("set_variant_price", SetVariantPriceReducer), + __reducerSchema("simulate_tick", SimulateTickReducer), + __reducerSchema("sync_catalog", SyncCatalogReducer), + __reducerSchema("posthog.enqueue_event", Posthog_EnqueueEventReducer), +); + +/** 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("flush_analytics", FlushAnalyticsProcedure.params, FlushAnalyticsProcedure.returnType), + __procedureSchema("posthog.add_admin_identity", Posthog_AddAdminIdentityProcedure.params, Posthog_AddAdminIdentityProcedure.returnType), + __procedureSchema("posthog.capture_now", Posthog_CaptureNowProcedure.params, Posthog_CaptureNowProcedure.returnType), + __procedureSchema("posthog.flush_outbox", Posthog_FlushOutboxProcedure.params, Posthog_FlushOutboxProcedure.returnType), + __procedureSchema("posthog.get_feature_flag", Posthog_GetFeatureFlagProcedure.params, Posthog_GetFeatureFlagProcedure.returnType), + __procedureSchema("posthog.get_posthog_config_status", Posthog_GetPosthogConfigStatusProcedure.params, Posthog_GetPosthogConfigStatusProcedure.returnType), + __procedureSchema("posthog.remove_admin_identity", Posthog_RemoveAdminIdentityProcedure.params, Posthog_RemoveAdminIdentityProcedure.returnType), + __procedureSchema("posthog.set_posthog_config", Posthog_SetPosthogConfigProcedure.params, Posthog_SetPosthogConfigProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + cafeAnalyticsSummary: __qb.cafeAnalyticsSummary, + cafeConfig: __qb.cafeConfig, + cafeEcon: __qb.cafeEcon, + cafeMetrics: __qb.cafeMetrics, + cafeProducts: __qb.cafeProducts, + cafeQueue: __qb.cafeQueue, + cafeRecentActivity: __qb.cafeRecentActivity, + cafeRecentPurchases: __qb.cafeRecentPurchases, + cafeRecentSessions: __qb.cafeRecentSessions, + cafeScenarios: __qb.cafeScenarios, + cafeVariants: __qb.cafeVariants, + posthogDeliveryLogAdmin: __qb.posthogDeliveryLogAdmin, + posthogOutboxAdmin: __qb.posthogOutboxAdmin, + posthog: { + posthogDeliveryLogAdmin: __qb["posthog.posthog_delivery_log_admin"], + posthogOutboxAdmin: __qb["posthog.posthog_outbox_admin"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + buySupply: __reducerAccessors.buySupply, + buyUpgrade: __reducerAccessors.buyUpgrade, + initSession: __reducerAccessors.initSession, + resetSimulation: __reducerAccessors.resetSimulation, + selectScenario: __reducerAccessors.selectScenario, + setExperimentVariant: __reducerAccessors.setExperimentVariant, + setFeaturedVariant: __reducerAccessors.setFeaturedVariant, + setProductActive: __reducerAccessors.setProductActive, + setVariantActive: __reducerAccessors.setVariantActive, + setVariantDiscount: __reducerAccessors.setVariantDiscount, + setVariantPrice: __reducerAccessors.setVariantPrice, + simulateTick: __reducerAccessors.simulateTick, + syncCatalog: __reducerAccessors.syncCatalog, + posthog: { + enqueueEvent: __reducerAccessors["posthog.enqueueEvent"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + flushAnalytics: __procedureAccessors.flushAnalytics, + posthog: { + addAdminIdentity: __procedureAccessors["posthog.addAdminIdentity"], + captureNow: __procedureAccessors["posthog.captureNow"], + flushOutbox: __procedureAccessors["posthog.flushOutbox"], + getFeatureFlag: __procedureAccessors["posthog.getFeatureFlag"], + getPosthogConfigStatus: __procedureAccessors["posthog.getPosthogConfigStatus"], + removeAdminIdentity: __procedureAccessors["posthog.removeAdminIdentity"], + setPosthogConfig: __procedureAccessors["posthog.setPosthogConfig"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-posthog-ts/example/src/module_bindings/init_session_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/init_session_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/init_session_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/add_admin_identity_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/add_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/add_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/capture_now_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/capture_now_procedure.ts new file mode 100644 index 00000000000..9305ab0f7de --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/capture_now_procedure.ts @@ -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 const params = { + distinctId: __t.string(), + event: __t.string(), + propertiesJson: __t.option(__t.string()), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/enqueue_event_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/enqueue_event_reducer.ts new file mode 100644 index 00000000000..0c2b30658d8 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/enqueue_event_reducer.ts @@ -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 { + distinctId: __t.string(), + event: __t.string(), + propertiesJson: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/flush_outbox_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/flush_outbox_procedure.ts new file mode 100644 index 00000000000..3bf3bb5c715 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/flush_outbox_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + limit: __t.u32(), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/get_feature_flag_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/get_feature_flag_procedure.ts new file mode 100644 index 00000000000..a1980cfd5d4 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/get_feature_flag_procedure.ts @@ -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 const params = { + key: __t.string(), + distinctId: __t.string(), + personPropertiesJson: __t.option(__t.string()), + groupsJson: __t.option(__t.string()), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/get_posthog_config_status_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/get_posthog_config_status_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/get_posthog_config_status_procedure.ts @@ -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 const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_delivery_log_admin_table.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_delivery_log_admin_table.ts new file mode 100644 index 00000000000..df3db325c77 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_delivery_log_admin_table.ts @@ -0,0 +1,30 @@ +// 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 { + PostHogDeliverySource, +} from "./types"; + + +export default __t.row({ + deliveryId: __t.u64().name("delivery_id"), + get source() { + return PostHogDeliverySource; + }, + outboxId: __t.option(__t.string()).name("outbox_id"), + distinctId: __t.string().name("distinct_id"), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16().name("status_code"), + responseBody: __t.string().name("response_body"), + errorMessage: __t.option(__t.string()).name("error_message"), + attemptedAt: __t.timestamp().name("attempted_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_outbox_admin_table.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_outbox_admin_table.ts new file mode 100644 index 00000000000..eebab071cb7 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_outbox_admin_table.ts @@ -0,0 +1,35 @@ +// 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 { + PostHogOutboxStatus, +} from "./types"; + + +export default __t.row({ + outboxId: __t.string().primaryKey().name("outbox_id"), + idempotencyKey: __t.option(__t.string()).name("idempotency_key"), + distinctId: __t.string().name("distinct_id"), + event: __t.string(), + propertiesJson: __t.option(__t.string()).name("properties_json"), + get status() { + return PostHogOutboxStatus; + }, + attempts: __t.u32(), + claimId: __t.option(__t.string()).name("claim_id"), + claimExpiresAtMicros: __t.i64().name("claim_expires_at_micros"), + nextAttemptAt: __t.timestamp().name("next_attempt_at"), + lastStatusCode: __t.option(__t.u16()).name("last_status_code"), + lastError: __t.option(__t.string()).name("last_error"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + deliveredAt: __t.option(__t.timestamp()).name("delivered_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/remove_admin_identity_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/remove_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/remove_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/set_posthog_config_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/set_posthog_config_procedure.ts new file mode 100644 index 00000000000..d75d24aca6f --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/set_posthog_config_procedure.ts @@ -0,0 +1,17 @@ +// 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 const params = { + host: __t.string(), + projectApiKey: __t.string(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/types.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/types.ts new file mode 100644 index 00000000000..a6078e2a41f --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/types.ts @@ -0,0 +1,112 @@ +// 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 const PostHogDeliveryLogRow = __t.object("PostHogDeliveryLogRow", { + deliveryId: __t.u64(), + get source() { + return PostHogDeliverySource; + }, + outboxId: __t.option(__t.string()), + distinctId: __t.string(), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16(), + responseBody: __t.string(), + errorMessage: __t.option(__t.string()), + attemptedAt: __t.timestamp(), +}); +export type PostHogDeliveryLogRow = __Infer; + +// The tagged union or sum type for the algebraic type `PostHogDeliverySource`. +export const PostHogDeliverySource = __t.enum("PostHogDeliverySource", { + Direct: __t.unit(), + Flush: __t.unit(), + FeatureFlag: __t.unit(), +}); +export type PostHogDeliverySource = __Infer; + +// The tagged union or sum type for the algebraic type `PostHogOutboxStatus`. +export const PostHogOutboxStatus = __t.enum("PostHogOutboxStatus", { + Queued: __t.unit(), + Processing: __t.unit(), + Delivered: __t.unit(), + Failed: __t.unit(), +}); +export type PostHogOutboxStatus = __Infer; + +export const PosthogAdminIdentity = __t.object("PosthogAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type PosthogAdminIdentity = __Infer; + +export const PosthogConfig = __t.object("PosthogConfig", { + singleton: __t.bool(), + host: __t.string(), + projectApiKey: __t.string(), + updatedAt: __t.timestamp(), +}); +export type PosthogConfig = __Infer; + +export const PosthogDeliveryLog = __t.object("PosthogDeliveryLog", { + deliveryId: __t.u64(), + get source() { + return PostHogDeliverySource; + }, + outboxId: __t.option(__t.string()), + distinctId: __t.string(), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16(), + responseBody: __t.string(), + errorMessage: __t.option(__t.string()), + attemptedAt: __t.timestamp(), + attemptedAtOrder: __t.i64(), +}); +export type PosthogDeliveryLog = __Infer; + +export const PosthogDeliveryLogAdmin = __t.object("PosthogDeliveryLogAdmin", {}); +export type PosthogDeliveryLogAdmin = __Infer; + +export const PosthogDeliveryStats = __t.object("PosthogDeliveryStats", { + singleton: __t.bool(), + pending: __t.u64(), + delivered: __t.u64(), + failed: __t.u64(), + updatedAt: __t.timestamp(), +}); +export type PosthogDeliveryStats = __Infer; + +export const PosthogOutbox = __t.object("PosthogOutbox", { + outboxId: __t.string(), + idempotencyKey: __t.option(__t.string()), + distinctId: __t.string(), + event: __t.string(), + propertiesJson: __t.option(__t.string()), + get status() { + return PostHogOutboxStatus; + }, + attempts: __t.u32(), + claimId: __t.option(__t.string()), + claimExpiresAtMicros: __t.i64(), + nextAttemptAt: __t.timestamp(), + lastStatusCode: __t.option(__t.u16()), + lastError: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + deliveredAt: __t.option(__t.timestamp()), +}); +export type PosthogOutbox = __Infer; + +export const PosthogOutboxAdmin = __t.object("PosthogOutboxAdmin", {}); +export type PosthogOutboxAdmin = __Infer; + diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog_delivery_log_admin_table.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog_delivery_log_admin_table.ts new file mode 100644 index 00000000000..32a8176ced6 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog_delivery_log_admin_table.ts @@ -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({ + deliveryId: __t.string().name("delivery_id"), + source: __t.string(), + distinctId: __t.string().name("distinct_id"), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16().name("status_code"), + responseBody: __t.string().name("response_body"), + attemptedAt: __t.timestamp().name("attempted_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog_outbox_admin_table.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog_outbox_admin_table.ts new file mode 100644 index 00000000000..eebab071cb7 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog_outbox_admin_table.ts @@ -0,0 +1,35 @@ +// 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 { + PostHogOutboxStatus, +} from "./types"; + + +export default __t.row({ + outboxId: __t.string().primaryKey().name("outbox_id"), + idempotencyKey: __t.option(__t.string()).name("idempotency_key"), + distinctId: __t.string().name("distinct_id"), + event: __t.string(), + propertiesJson: __t.option(__t.string()).name("properties_json"), + get status() { + return PostHogOutboxStatus; + }, + attempts: __t.u32(), + claimId: __t.option(__t.string()).name("claim_id"), + claimExpiresAtMicros: __t.i64().name("claim_expires_at_micros"), + nextAttemptAt: __t.timestamp().name("next_attempt_at"), + lastStatusCode: __t.option(__t.u16()).name("last_status_code"), + lastError: __t.option(__t.string()).name("last_error"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + deliveredAt: __t.option(__t.timestamp()).name("delivered_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/reset_simulation_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/reset_simulation_reducer.ts new file mode 100644 index 00000000000..fa2ece181cd --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/reset_simulation_reducer.ts @@ -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 { + scenarioId: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/select_scenario_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/select_scenario_reducer.ts new file mode 100644 index 00000000000..fa2ece181cd --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/select_scenario_reducer.ts @@ -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 { + scenarioId: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_experiment_variant_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_experiment_variant_reducer.ts new file mode 100644 index 00000000000..f67bc2af2c0 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_experiment_variant_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + key: __t.string(), + variant: __t.option(__t.string()), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_featured_variant_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_featured_variant_reducer.ts new file mode 100644 index 00000000000..9964a3d6901 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_featured_variant_reducer.ts @@ -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 { + variantId: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_product_active_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_product_active_reducer.ts new file mode 100644 index 00000000000..36891f8a1d3 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_product_active_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + productId: __t.string(), + active: __t.bool(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_variant_active_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_variant_active_reducer.ts new file mode 100644 index 00000000000..fa836963c3e --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_variant_active_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + variantId: __t.string(), + active: __t.bool(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_variant_discount_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_variant_discount_reducer.ts new file mode 100644 index 00000000000..e97fb1a5fbc --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_variant_discount_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + variantId: __t.string(), + discountBps: __t.u32(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_variant_price_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_variant_price_reducer.ts new file mode 100644 index 00000000000..b2b5714fb19 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_variant_price_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + variantId: __t.string(), + priceCents: __t.u32(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/simulate_tick_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/simulate_tick_reducer.ts new file mode 100644 index 00000000000..2b6e915beb8 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/simulate_tick_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + ticks: __t.u32(), + seed: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/sync_catalog_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/sync_catalog_reducer.ts new file mode 100644 index 00000000000..34d098ba890 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/sync_catalog_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + productsJson: __t.string(), + scenariosJson: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/types.ts b/spacetime-posthog-ts/example/src/module_bindings/types.ts new file mode 100644 index 00000000000..9b28e9352f0 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/types.ts @@ -0,0 +1,273 @@ +// 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 const Activity = __t.object("Activity", { + activityId: __t.u64(), + owner: __t.string(), + tick: __t.u64(), + kind: __t.string(), + message: __t.string(), + profile: __t.option(__t.string()), + productId: __t.option(__t.string()), + variantId: __t.option(__t.string()), + amountCents: __t.option(__t.u32()), + createdAt: __t.timestamp(), +}); +export type Activity = __Infer; + +export const BotSession = __t.object("BotSession", { + sessionId: __t.u64(), + owner: __t.string(), + botId: __t.string(), + tick: __t.u64(), + profile: __t.string(), + scenarioId: __t.string(), + productId: __t.option(__t.string()), + variantId: __t.option(__t.string()), + stage: __t.string(), + revenueCents: __t.u32(), + reason: __t.string(), + createdAt: __t.timestamp(), +}); +export type BotSession = __Infer; + +export const CafeAnalyticsSummary = __t.object("CafeAnalyticsSummary", {}); +export type CafeAnalyticsSummary = __Infer; + +export const CafeConfig = __t.object("CafeConfig", {}); +export type CafeConfig = __Infer; + +export const CafeEcon = __t.object("CafeEcon", {}); +export type CafeEcon = __Infer; + +export const CafeMetrics = __t.object("CafeMetrics", {}); +export type CafeMetrics = __Infer; + +export const CafeProducts = __t.object("CafeProducts", {}); +export type CafeProducts = __Infer; + +export const CafeQueue = __t.object("CafeQueue", {}); +export type CafeQueue = __Infer; + +export const CafeRecentActivity = __t.object("CafeRecentActivity", {}); +export type CafeRecentActivity = __Infer; + +export const CafeRecentPurchases = __t.object("CafeRecentPurchases", {}); +export type CafeRecentPurchases = __Infer; + +export const CafeRecentSessions = __t.object("CafeRecentSessions", {}); +export type CafeRecentSessions = __Infer; + +export const CafeScenarios = __t.object("CafeScenarios", {}); +export type CafeScenarios = __Infer; + +export const CafeVariants = __t.object("CafeVariants", {}); +export type CafeVariants = __Infer; + +export const ContextCafeAnalyticsSummaryRow = __t.object("ContextCafeAnalyticsSummaryRow", { + queued: __t.u64(), + delivered: __t.u64(), + failed: __t.u64(), +}); +export type ContextCafeAnalyticsSummaryRow = __Infer; + +export const ContextCafeDeliveryLogRow = __t.object("ContextCafeDeliveryLogRow", { + deliveryId: __t.string(), + source: __t.string(), + distinctId: __t.string(), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16(), + responseBody: __t.string(), + attemptedAt: __t.timestamp(), +}); +export type ContextCafeDeliveryLogRow = __Infer; + +export const Econ = __t.object("Econ", { + owner: __t.string(), + cashCents: __t.u64(), + computeUnits: __t.u32(), + contextUnits: __t.u32(), + memoryUnits: __t.u32(), + suppliesSpentCents: __t.u64(), + stockouts: __t.u32(), + reputation: __t.u32(), + workers: __t.u32(), + machineLevel: __t.u32(), + seats: __t.u32(), + storageLevel: __t.u32(), + reneged: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type Econ = __Infer; + +export const Metrics = __t.object("Metrics", { + owner: __t.string(), + tick: __t.u64(), + views: __t.u64(), + carts: __t.u64(), + checkouts: __t.u64(), + purchases: __t.u64(), + abandons: __t.u64(), + revenueCents: __t.u64(), + updatedAt: __t.timestamp(), +}); +export type Metrics = __Infer; + +// The tagged union or sum type for the algebraic type `PostHogOutboxStatus`. +export const PostHogOutboxStatus = __t.enum("PostHogOutboxStatus", { + Queued: __t.unit(), + Processing: __t.unit(), + Delivered: __t.unit(), + Failed: __t.unit(), +}); +export type PostHogOutboxStatus = __Infer; + +export const PosthogDeliveryLogAdmin = __t.object("PosthogDeliveryLogAdmin", {}); +export type PosthogDeliveryLogAdmin = __Infer; + +export const PosthogOutbox = __t.object("PosthogOutbox", { + outboxId: __t.string(), + idempotencyKey: __t.option(__t.string()), + distinctId: __t.string(), + event: __t.string(), + propertiesJson: __t.option(__t.string()), + get status() { + return PostHogOutboxStatus; + }, + attempts: __t.u32(), + claimId: __t.option(__t.string()), + claimExpiresAtMicros: __t.i64(), + nextAttemptAt: __t.timestamp(), + lastStatusCode: __t.option(__t.u16()), + lastError: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + deliveredAt: __t.option(__t.timestamp()), +}); +export type PosthogOutbox = __Infer; + +export const PosthogOutboxAdmin = __t.object("PosthogOutboxAdmin", {}); +export type PosthogOutboxAdmin = __Infer; + +export const Product = __t.object("Product", { + key: __t.string(), + owner: __t.string(), + productId: __t.string(), + name: __t.string(), + category: __t.string(), + description: __t.string(), + baseAppeal: __t.u32(), + active: __t.bool(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Product = __Infer; + +export const ProductTemplate = __t.object("ProductTemplate", { + productId: __t.string(), + name: __t.string(), + category: __t.string(), + description: __t.string(), + baseAppeal: __t.u32(), + active: __t.bool(), +}); +export type ProductTemplate = __Infer; + +export const Purchase = __t.object("Purchase", { + purchaseId: __t.u64(), + owner: __t.string(), + sessionId: __t.u64(), + tick: __t.u64(), + botId: __t.string(), + profile: __t.string(), + productId: __t.string(), + variantId: __t.string(), + pricePaidCents: __t.u32(), + createdAt: __t.timestamp(), +}); +export type Purchase = __Infer; + +export const Scenario = __t.object("Scenario", { + scenarioId: __t.string(), + name: __t.string(), + description: __t.string(), + trafficPerTick: __t.u32(), + priceSensitivity: __t.u32(), + rushBias: __t.u32(), + researchBias: __t.u32(), + visualBias: __t.u32(), + memoryBias: __t.u32(), + premiumBias: __t.u32(), + volatility: __t.u32(), +}); +export type Scenario = __Infer; + +export const SimConfig = __t.object("SimConfig", { + owner: __t.string(), + scenarioId: __t.string(), + tick: __t.u64(), + experimentKey: __t.string(), + experimentVariant: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type SimConfig = __Infer; + +export const Variant = __t.object("Variant", { + key: __t.string(), + owner: __t.string(), + variantId: __t.string(), + productId: __t.string(), + name: __t.string(), + flavor: __t.string(), + contextTokens: __t.u32(), + reasoning: __t.u32(), + latency: __t.u32(), + priceCents: __t.u32(), + baselinePriceCents: __t.u32(), + discountBps: __t.u32(), + active: __t.bool(), + featured: __t.bool(), + updatedAt: __t.timestamp(), +}); +export type Variant = __Infer; + +export const VariantTemplate = __t.object("VariantTemplate", { + variantId: __t.string(), + productId: __t.string(), + name: __t.string(), + flavor: __t.string(), + contextTokens: __t.u32(), + reasoning: __t.u32(), + latency: __t.u32(), + priceCents: __t.u32(), + discountBps: __t.u32(), + active: __t.bool(), + featured: __t.bool(), +}); +export type VariantTemplate = __Infer; + +export const WaitingBot = __t.object("WaitingBot", { + queueId: __t.u64(), + owner: __t.string(), + botId: __t.string(), + profile: __t.string(), + scenarioId: __t.string(), + productId: __t.string(), + variantId: __t.string(), + wants: __t.string(), + thrifty: __t.bool(), + arrivedTick: __t.u64(), + createdAt: __t.timestamp(), +}); +export type WaitingBot = __Infer; + diff --git a/spacetime-posthog-ts/example/src/module_bindings/types/procedures.ts b/spacetime-posthog-ts/example/src/module_bindings/types/procedures.ts new file mode 100644 index 00000000000..283f37b4540 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/types/procedures.ts @@ -0,0 +1,13 @@ +// 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 FlushAnalyticsProcedure from "../flush_analytics_procedure"; + +export type FlushAnalyticsArgs = __Infer; +export type FlushAnalyticsResult = __Infer; + diff --git a/spacetime-posthog-ts/example/src/module_bindings/types/reducers.ts b/spacetime-posthog-ts/example/src/module_bindings/types/reducers.ts new file mode 100644 index 00000000000..d87717d3228 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/types/reducers.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import BuySupplyReducer from "../buy_supply_reducer"; +import BuyUpgradeReducer from "../buy_upgrade_reducer"; +import InitSessionReducer from "../init_session_reducer"; +import ResetSimulationReducer from "../reset_simulation_reducer"; +import SelectScenarioReducer from "../select_scenario_reducer"; +import SetExperimentVariantReducer from "../set_experiment_variant_reducer"; +import SetFeaturedVariantReducer from "../set_featured_variant_reducer"; +import SetProductActiveReducer from "../set_product_active_reducer"; +import SetVariantActiveReducer from "../set_variant_active_reducer"; +import SetVariantDiscountReducer from "../set_variant_discount_reducer"; +import SetVariantPriceReducer from "../set_variant_price_reducer"; +import SimulateTickReducer from "../simulate_tick_reducer"; +import SyncCatalogReducer from "../sync_catalog_reducer"; + +export type BuySupplyParams = __Infer; +export type BuyUpgradeParams = __Infer; +export type InitSessionParams = __Infer; +export type ResetSimulationParams = __Infer; +export type SelectScenarioParams = __Infer; +export type SetExperimentVariantParams = __Infer; +export type SetFeaturedVariantParams = __Infer; +export type SetProductActiveParams = __Infer; +export type SetVariantActiveParams = __Infer; +export type SetVariantDiscountParams = __Infer; +export type SetVariantPriceParams = __Infer; +export type SimulateTickParams = __Infer; +export type SyncCatalogParams = __Infer; + diff --git a/spacetime-presence-ts/README.md b/spacetime-presence-ts/README.md index ef2fad12a0f..c6197c9e530 100644 --- a/spacetime-presence-ts/README.md +++ b/spacetime-presence-ts/README.md @@ -42,7 +42,7 @@ import { const presenceEntry = createPresenceEntryTable({ public: true }); const presenceConfig = createPresenceConfigTable({ public: false }); const presenceSweepTick = table( - { name: 'presence_sweep_tick', scheduled: (): any => presence_sweep }, + { name: 'presence_sweep_tick' }, presenceSweepTickRow ); @@ -76,6 +76,7 @@ export const heartbeat = spacetimedb.procedure( ); export const presence_sweep = spacetimedb.reducer( + { onSchedule: presenceSweepTick }, { arg: presenceSweepTick.rowType }, ctx => { runPresenceSweep( diff --git a/spacetime-presence-ts/example/package.json b/spacetime-presence-ts/example/package.json index 37bab181dea..9fc6cc3bcb8 100644 --- a/spacetime-presence-ts/example/package.json +++ b/spacetime-presence-ts/example/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", - "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-presence-example && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-presence-example && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-presence-example && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-presence-example && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "test:unit": "node scripts/test-ui-model.mjs", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-presence-ts/example/spacetimedb/src/index.ts b/spacetime-presence-ts/example/spacetimedb/src/index.ts index d021147098f..244fad570ff 100644 --- a/spacetime-presence-ts/example/spacetimedb/src/index.ts +++ b/spacetime-presence-ts/example/spacetimedb/src/index.ts @@ -50,12 +50,7 @@ import { typingScope, } from './chat-policy'; import { registerChatViews } from './views'; -import { - chatSweepTick, - setSweepReducer, - spacetimedb, - type DbSchema, -} from './schema'; +import { chatSweepTick, spacetimedb, type DbSchema } from './schema'; const ONE_SECOND_MICROS = 1_000_000n; const TYPING_TTL_SECONDS = 4; @@ -1052,6 +1047,7 @@ export const search_messages = spacetimedb.procedure( ); export const chat_sweep = spacetimedb.reducer( + { onSchedule: chatSweepTick }, { arg: chatSweepTick.rowType }, ctx => { runPresenceSweep( @@ -1080,8 +1076,6 @@ export const chat_sweep = spacetimedb.reducer( } ); -setSweepReducer(chat_sweep); - export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => passwordSignupHandler(ctx.as.auth, req) ); diff --git a/spacetime-presence-ts/example/spacetimedb/src/schema.ts b/spacetime-presence-ts/example/spacetimedb/src/schema.ts index fb018877f55..53dd533b127 100644 --- a/spacetime-presence-ts/example/spacetimedb/src/schema.ts +++ b/spacetime-presence-ts/example/spacetimedb/src/schema.ts @@ -28,28 +28,14 @@ export const presenceConfig = table( } ); -let sweepReducer: unknown; - export const chatSweepTick = table( - { - name: 'chat_sweep_tick', - scheduled: (): any => { - if (!sweepReducer) { - throw new Error('chat.sweep_reducer_not_registered'); - } - return sweepReducer; - }, - }, + { name: 'chat_sweep_tick' }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), } ); -export function setSweepReducer(reducer: unknown): void { - sweepReducer = reducer; -} - export const spacetimedb = schema({ auth, files, diff --git a/spacetime-presence-ts/example/src/app.ts b/spacetime-presence-ts/example/src/app.ts index 7d83dab0084..82d9008ae49 100644 --- a/spacetime-presence-ts/example/src/app.ts +++ b/spacetime-presence-ts/example/src/app.ts @@ -3,7 +3,7 @@ import { tables, type ErrorContext, type EventContext, -} from './codegen/app/index.ts'; +} from './module_bindings/app/index.ts'; import type { PresenceEntry, Server, @@ -11,7 +11,7 @@ import type { ChatRateLimitStatus, MessageThread, ThreadMessage, -} from './codegen/app/types.ts'; +} from './module_bindings/app/types.ts'; interface AttachmentInput { mimeType: string; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/types.ts @@ -0,0 +1,137 @@ +// 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 const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/auth/whoami_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/auth/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/create_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/create_room_reducer.ts new file mode 100644 index 00000000000..1382e2f73fb --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/create_room_reducer.ts @@ -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 { + serverId: __t.u64(), + name: __t.string(), + isPrivate: __t.bool(), + category: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/create_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/create_server_reducer.ts new file mode 100644 index 00000000000..ce493ee8574 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/create_server_reducer.ts @@ -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 { + name: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/delete_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/delete_message_reducer.ts new file mode 100644 index 00000000000..104809a301c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/delete_message_reducer.ts @@ -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 { + messageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/delete_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/delete_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/delete_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/delete_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/delete_server_reducer.ts new file mode 100644 index 00000000000..8cee31ec781 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/delete_server_reducer.ts @@ -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 { + serverId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/delete_thread_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/delete_thread_message_reducer.ts new file mode 100644 index 00000000000..fa89973d643 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/delete_thread_message_reducer.ts @@ -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 { + threadMessageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/edit_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/edit_message_reducer.ts new file mode 100644 index 00000000000..57eaa7d8f74 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/edit_message_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + messageId: __t.u64(), + content: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/edit_thread_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/edit_thread_message_reducer.ts new file mode 100644 index 00000000000..fe1bd145fa1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/edit_thread_message_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + threadMessageId: __t.u64(), + content: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/files/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/files/types.ts new file mode 100644 index 00000000000..a8336b9566f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/files/types.ts @@ -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"; + +export const File = __t.object("File", { + id: __t.u64(), + ownerPathKey: __t.string(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const FileBlob = __t.object("FileBlob", { + fileId: __t.u64(), + bytes: __t.byteArray(), +}); +export type FileBlob = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/get_attachment_file_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/get_attachment_file_procedure.ts new file mode 100644 index 00000000000..f365f3373db --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/get_attachment_file_procedure.ts @@ -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"; + +import { + AttachmentFileResult, +} from "./types"; + +export const params = { + fileId: __t.u64(), +}; +export const returnType = AttachmentFileResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts @@ -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"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/heartbeat_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/heartbeat_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/heartbeat_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/index.ts b/spacetime-presence-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..603a3478aac --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,488 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 CreateRoomReducer from "./create_room_reducer"; +import CreateServerReducer from "./create_server_reducer"; +import DeleteMessageReducer from "./delete_message_reducer"; +import DeleteRoomReducer from "./delete_room_reducer"; +import DeleteServerReducer from "./delete_server_reducer"; +import DeleteThreadMessageReducer from "./delete_thread_message_reducer"; +import EditMessageReducer from "./edit_message_reducer"; +import EditThreadMessageReducer from "./edit_thread_message_reducer"; +import HeartbeatReducer from "./heartbeat_reducer"; +import JoinRoomReducer from "./join_room_reducer"; +import JoinServerReducer from "./join_server_reducer"; +import LeaveRoomReducer from "./leave_room_reducer"; +import LeaveServerReducer from "./leave_server_reducer"; +import LinkConnectionReducer from "./link_connection_reducer"; +import MarkRoomReadReducer from "./mark_room_read_reducer"; +import PinMessageReducer from "./pin_message_reducer"; +import RenameRoomReducer from "./rename_room_reducer"; +import RenameServerReducer from "./rename_server_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SendMessageReducer from "./send_message_reducer"; +import SendThreadMessageReducer from "./send_thread_message_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import SetDisplayNameReducer from "./set_display_name_reducer"; +import SetRoomCategoryReducer from "./set_room_category_reducer"; +import SetRoomPrivacyReducer from "./set_room_privacy_reducer"; +import SetStatusReducer from "./set_status_reducer"; +import StartTypingReducer from "./start_typing_reducer"; +import StopTypingReducer from "./stop_typing_reducer"; +import ToggleReactionReducer from "./toggle_reaction_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UnpinMessageReducer from "./unpin_message_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; + +// Import all procedure arg schemas +import * as GetAttachmentFileProcedure from "./get_attachment_file_procedure"; +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as SearchMessagesProcedure from "./search_messages_procedure"; +import * as WhoamiProcedure from "./whoami_procedure"; + +// Import all table schema definitions +import MyAuthUserRow from "./my_auth_user_table"; +import MyChatUsersRow from "./my_chat_users_table"; +import MyMessageThreadsRow from "./my_message_threads_table"; +import MyPresenceEntriesRow from "./my_presence_entries_table"; +import MyRateLimitStatusRow from "./my_rate_limit_status_table"; +import MyRoomAttachmentsRow from "./my_room_attachments_table"; +import MyRoomMembersRow from "./my_room_members_table"; +import MyRoomMessageReactionsRow from "./my_room_message_reactions_table"; +import MyRoomMessagesRow from "./my_room_messages_table"; +import MyRoomReadCursorsRow from "./my_room_read_cursors_table"; +import MyRoomsRow from "./my_rooms_table"; +import MyServerMembersRow from "./my_server_members_table"; +import MyServersRow from "./my_servers_table"; +import MyThreadMessagesRow from "./my_thread_messages_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import RateLimit_RateLimitConfigRow from "./rateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; +import RateLimit_AdminRateLimitBucketsRow from "./rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; +import RateLimit_AddRateLimitAdminReducer from "./rateLimit/add_rate_limit_admin_reducer"; +import RateLimit_ResetBucketsReducer from "./rateLimit/reset_buckets_reducer"; +import RateLimit_UpdateConfigReducer from "./rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; +import * as RateLimit_ConsumeProcedure from "./rateLimit/consume_procedure"; +import * as RateLimit_RunSweepProcedure from "./rateLimit/run_sweep_procedure"; + +/** 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({ + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myChatUsers: __table({ + name: 'my_chat_users', + indexes: [ + ], + constraints: [ + ], + }, MyChatUsersRow), + myMessageThreads: __table({ + name: 'my_message_threads', + indexes: [ + ], + constraints: [ + ], + }, MyMessageThreadsRow), + myPresenceEntries: __table({ + name: 'my_presence_entries', + indexes: [ + ], + constraints: [ + ], + }, MyPresenceEntriesRow), + myRateLimitStatus: __table({ + name: 'my_rate_limit_status', + indexes: [ + ], + constraints: [ + ], + }, MyRateLimitStatusRow), + myRoomAttachments: __table({ + name: 'my_room_attachments', + indexes: [ + ], + constraints: [ + ], + }, MyRoomAttachmentsRow), + myRoomMembers: __table({ + name: 'my_room_members', + indexes: [ + ], + constraints: [ + ], + }, MyRoomMembersRow), + myRoomMessageReactions: __table({ + name: 'my_room_message_reactions', + indexes: [ + ], + constraints: [ + ], + }, MyRoomMessageReactionsRow), + myRoomMessages: __table({ + name: 'my_room_messages', + indexes: [ + ], + constraints: [ + ], + }, MyRoomMessagesRow), + myRoomReadCursors: __table({ + name: 'my_room_read_cursors', + indexes: [ + ], + constraints: [ + ], + }, MyRoomReadCursorsRow), + myRooms: __table({ + name: 'my_rooms', + indexes: [ + ], + constraints: [ + ], + }, MyRoomsRow), + myServerMembers: __table({ + name: 'my_server_members', + indexes: [ + ], + constraints: [ + ], + }, MyServerMembersRow), + myServers: __table({ + name: 'my_servers', + indexes: [ + ], + constraints: [ + ], + }, MyServersRow), + myThreadMessages: __table({ + name: 'my_thread_messages', + indexes: [ + ], + constraints: [ + ], + }, MyThreadMessagesRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "rateLimit.rate_limit_config": __table({ + name: 'rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, RateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), + "rateLimit.admin_rate_limit_buckets": __table({ + name: 'rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, RateLimit_AdminRateLimitBucketsRow), +}); + +/** 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( + __reducerSchema("create_room", CreateRoomReducer), + __reducerSchema("create_server", CreateServerReducer), + __reducerSchema("delete_message", DeleteMessageReducer), + __reducerSchema("delete_room", DeleteRoomReducer), + __reducerSchema("delete_server", DeleteServerReducer), + __reducerSchema("delete_thread_message", DeleteThreadMessageReducer), + __reducerSchema("edit_message", EditMessageReducer), + __reducerSchema("edit_thread_message", EditThreadMessageReducer), + __reducerSchema("heartbeat", HeartbeatReducer), + __reducerSchema("join_room", JoinRoomReducer), + __reducerSchema("join_server", JoinServerReducer), + __reducerSchema("leave_room", LeaveRoomReducer), + __reducerSchema("leave_server", LeaveServerReducer), + __reducerSchema("link_connection", LinkConnectionReducer), + __reducerSchema("mark_room_read", MarkRoomReadReducer), + __reducerSchema("pin_message", PinMessageReducer), + __reducerSchema("rename_room", RenameRoomReducer), + __reducerSchema("rename_server", RenameServerReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("send_message", SendMessageReducer), + __reducerSchema("send_thread_message", SendThreadMessageReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("set_display_name", SetDisplayNameReducer), + __reducerSchema("set_room_category", SetRoomCategoryReducer), + __reducerSchema("set_room_privacy", SetRoomPrivacyReducer), + __reducerSchema("set_status", SetStatusReducer), + __reducerSchema("start_typing", StartTypingReducer), + __reducerSchema("stop_typing", StopTypingReducer), + __reducerSchema("toggle_reaction", ToggleReactionReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("unpin_message", UnpinMessageReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), + __reducerSchema("rateLimit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer), + __reducerSchema("rateLimit.reset_buckets", RateLimit_ResetBucketsReducer), + __reducerSchema("rateLimit.update_config", RateLimit_UpdateConfigReducer), +); + +/** 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("get_attachment_file", GetAttachmentFileProcedure.params, GetAttachmentFileProcedure.returnType), + __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("search_messages", SearchMessagesProcedure.params, SearchMessagesProcedure.returnType), + __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), + __procedureSchema("rateLimit.consume", RateLimit_ConsumeProcedure.params, RateLimit_ConsumeProcedure.returnType), + __procedureSchema("rateLimit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + myAuthUser: __qb.myAuthUser, + myChatUsers: __qb.myChatUsers, + myMessageThreads: __qb.myMessageThreads, + myPresenceEntries: __qb.myPresenceEntries, + myRateLimitStatus: __qb.myRateLimitStatus, + myRoomAttachments: __qb.myRoomAttachments, + myRoomMembers: __qb.myRoomMembers, + myRoomMessageReactions: __qb.myRoomMessageReactions, + myRoomMessages: __qb.myRoomMessages, + myRoomReadCursors: __qb.myRoomReadCursors, + myRooms: __qb.myRooms, + myServerMembers: __qb.myServerMembers, + myServers: __qb.myServers, + myThreadMessages: __qb.myThreadMessages, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, + rateLimit: { + rateLimitConfig: __qb["rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["rateLimit.admin_rate_limit_buckets"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + createRoom: __reducerAccessors.createRoom, + createServer: __reducerAccessors.createServer, + deleteMessage: __reducerAccessors.deleteMessage, + deleteRoom: __reducerAccessors.deleteRoom, + deleteServer: __reducerAccessors.deleteServer, + deleteThreadMessage: __reducerAccessors.deleteThreadMessage, + editMessage: __reducerAccessors.editMessage, + editThreadMessage: __reducerAccessors.editThreadMessage, + heartbeat: __reducerAccessors.heartbeat, + joinRoom: __reducerAccessors.joinRoom, + joinServer: __reducerAccessors.joinServer, + leaveRoom: __reducerAccessors.leaveRoom, + leaveServer: __reducerAccessors.leaveServer, + linkConnection: __reducerAccessors.linkConnection, + markRoomRead: __reducerAccessors.markRoomRead, + pinMessage: __reducerAccessors.pinMessage, + renameRoom: __reducerAccessors.renameRoom, + renameServer: __reducerAccessors.renameServer, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + sendMessage: __reducerAccessors.sendMessage, + sendThreadMessage: __reducerAccessors.sendThreadMessage, + setAuthConfig: __reducerAccessors.setAuthConfig, + setDisplayName: __reducerAccessors.setDisplayName, + setRoomCategory: __reducerAccessors.setRoomCategory, + setRoomPrivacy: __reducerAccessors.setRoomPrivacy, + setStatus: __reducerAccessors.setStatus, + startTyping: __reducerAccessors.startTyping, + stopTyping: __reducerAccessors.stopTyping, + toggleReaction: __reducerAccessors.toggleReaction, + unlinkConnection: __reducerAccessors.unlinkConnection, + unpinMessage: __reducerAccessors.unpinMessage, + updateProfile: __reducerAccessors.updateProfile, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, + rateLimit: { + addRateLimitAdmin: __reducerAccessors["rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["rateLimit.updateConfig"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + getAttachmentFile: __procedureAccessors.getAttachmentFile, + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + listMySessions: __procedureAccessors.listMySessions, + searchMessages: __procedureAccessors.searchMessages, + whoami: __procedureAccessors.whoami, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, + rateLimit: { + consume: __procedureAccessors["rateLimit.consume"], + runSweep: __procedureAccessors["rateLimit.runSweep"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/join_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/join_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/join_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/join_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/join_server_reducer.ts new file mode 100644 index 00000000000..8cee31ec781 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/join_server_reducer.ts @@ -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 { + serverId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/leave_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/leave_room_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/leave_room_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/leave_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/leave_server_reducer.ts new file mode 100644 index 00000000000..8cee31ec781 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/leave_server_reducer.ts @@ -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 { + serverId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/link_connection_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/link_connection_reducer.ts @@ -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 { + sessionToken: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts @@ -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"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/mark_room_read_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/mark_room_read_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/mark_room_read_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_auth_user_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// 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({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_chat_users_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_chat_users_table.ts new file mode 100644 index 00000000000..10a1254c677 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_chat_users_table.ts @@ -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 { + ChatUserStatus, +} from "./types"; + + +export default __t.row({ + identity: __t.identity().primaryKey(), + userId: __t.string().name("user_id"), + displayName: __t.string().name("display_name"), + get status() { + return ChatUserStatus; + }, + createdAt: __t.timestamp().name("created_at"), + lastActiveAt: __t.timestamp().name("last_active_at"), + lastMessageAt: __t.timestamp().name("last_message_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_message_threads_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_message_threads_table.ts new file mode 100644 index 00000000000..5d3160c8709 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_message_threads_table.ts @@ -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().primaryKey(), + rootMessageId: __t.u64().name("root_message_id"), + roomId: __t.u64().name("room_id"), + createdBy: __t.identity().name("created_by"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_presence_entries_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_presence_entries_table.ts new file mode 100644 index 00000000000..70af5d56d15 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_presence_entries_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()).name("payload_json"), + joinedAt: __t.timestamp().name("joined_at"), + lastSeenAt: __t.timestamp().name("last_seen_at"), + expiresAt: __t.timestamp().name("expires_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_rate_limit_status_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_rate_limit_status_table.ts new file mode 100644 index 00000000000..00e9df8e608 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_rate_limit_status_table.ts @@ -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({ + scope: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.timestamp().name("reset_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_attachments_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_attachments_table.ts new file mode 100644 index 00000000000..8aa2b599cea --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_attachments_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64(), + messageId: __t.u64().name("message_id"), + fileId: __t.u64().name("file_id"), + ownerUserId: __t.string().name("owner_user_id"), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + path: __t.string(), + mimeType: __t.string().name("mime_type"), + size: __t.u64(), + sha256Hex: __t.string().name("sha_256_hex"), + visibility: __t.string(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_members_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_members_table.ts new file mode 100644 index 00000000000..5f1878a592e --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_members_table.ts @@ -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.u64().primaryKey(), + roomId: __t.u64().name("room_id"), + userId: __t.string().name("user_id"), + role: __t.string(), + joinedAt: __t.timestamp().name("joined_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_message_reactions_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_message_reactions_table.ts new file mode 100644 index 00000000000..2d86b514fed --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_message_reactions_table.ts @@ -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.u64().primaryKey(), + messageId: __t.u64().name("message_id"), + identity: __t.identity(), + emoji: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_messages_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_messages_table.ts new file mode 100644 index 00000000000..be6f0a662b9 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_messages_table.ts @@ -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({ + id: __t.u64().primaryKey(), + roomId: __t.u64().name("room_id"), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp().name("created_at"), + editedAt: __t.option(__t.timestamp()).name("edited_at"), + replyToMessageId: __t.option(__t.u64()).name("reply_to_message_id"), + pinnedAt: __t.option(__t.timestamp()).name("pinned_at"), + pinnedBy: __t.option(__t.identity()).name("pinned_by"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_room_read_cursors_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_room_read_cursors_table.ts new file mode 100644 index 00000000000..d52cd7f1251 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_room_read_cursors_table.ts @@ -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.u64().primaryKey(), + roomId: __t.u64().name("room_id"), + identity: __t.identity(), + lastReadMessageId: __t.u64().name("last_read_message_id"), + lastReadAt: __t.timestamp().name("last_read_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_rooms_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_rooms_table.ts new file mode 100644 index 00000000000..a2a98c957a1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_rooms_table.ts @@ -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({ + id: __t.u64().primaryKey(), + serverId: __t.u64().name("server_id"), + name: __t.string(), + category: __t.option(__t.string()), + createdByUserId: __t.string().name("created_by_user_id"), + createdAt: __t.timestamp().name("created_at"), + isPrivate: __t.bool().name("is_private"), + activityLabel: __t.string().name("activity_label"), + activityScore: __t.u32().name("activity_score"), + lastActivityAt: __t.option(__t.timestamp()).name("last_activity_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_server_members_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_server_members_table.ts new file mode 100644 index 00000000000..2cf02263ab3 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_server_members_table.ts @@ -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.u64().primaryKey(), + serverId: __t.u64().name("server_id"), + userId: __t.string().name("user_id"), + role: __t.string(), + joinedAt: __t.timestamp().name("joined_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_servers_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_servers_table.ts new file mode 100644 index 00000000000..bb9731a93c9 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_servers_table.ts @@ -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({ + id: __t.u64().primaryKey(), + name: __t.string(), + createdByUserId: __t.string().name("created_by_user_id"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/my_thread_messages_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/my_thread_messages_table.ts new file mode 100644 index 00000000000..07d885a44b7 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/my_thread_messages_table.ts @@ -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().primaryKey(), + threadId: __t.u64().name("thread_id"), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp().name("created_at"), + editedAt: __t.option(__t.timestamp()).name("edited_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/pin_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/pin_message_reducer.ts new file mode 100644 index 00000000000..104809a301c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/pin_message_reducer.ts @@ -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 { + messageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rename_room_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rename_room_reducer.ts new file mode 100644 index 00000000000..8d0f5f1d2de --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rename_room_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + roomId: __t.u64(), + name: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/rename_server_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/rename_server_reducer.ts new file mode 100644 index 00000000000..71d58e8cf73 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/rename_server_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + serverId: __t.u64(), + name: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/revoke_session_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/revoke_session_reducer.ts @@ -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 { + sessionId: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/search_messages_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/search_messages_procedure.ts new file mode 100644 index 00000000000..5226a56b306 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/search_messages_procedure.ts @@ -0,0 +1,21 @@ +// 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 { + Message, +} from "./types"; + +export const params = { + roomId: __t.u64(), + query: __t.string(), +}; +export const returnType = __t.array(Message) \ No newline at end of file diff --git a/spacetime-presence-ts/example/src/module_bindings/app/send_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/send_message_reducer.ts new file mode 100644 index 00000000000..37fa347fa30 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/send_message_reducer.ts @@ -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 { + AttachmentInput, +} from "./types"; + +export default { + roomId: __t.u64(), + content: __t.string(), + replyToMessageId: __t.option(__t.u64()), + get attachments() { + return __t.array(AttachmentInput); + }, +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/send_thread_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/send_thread_message_reducer.ts new file mode 100644 index 00000000000..f846302477c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/send_thread_message_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + rootMessageId: __t.u64(), + content: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_auth_config_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_auth_config_reducer.ts @@ -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 { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_display_name_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_display_name_reducer.ts new file mode 100644 index 00000000000..547493ef073 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_display_name_reducer.ts @@ -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 { + displayName: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_room_category_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_room_category_reducer.ts new file mode 100644 index 00000000000..9feeae5be80 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_room_category_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + roomId: __t.u64(), + category: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_room_privacy_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_room_privacy_reducer.ts new file mode 100644 index 00000000000..15b49b76a1e --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_room_privacy_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + roomId: __t.u64(), + isPrivate: __t.bool(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/set_status_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/set_status_reducer.ts new file mode 100644 index 00000000000..15a4e2758f8 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/set_status_reducer.ts @@ -0,0 +1,21 @@ +// 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 { + ChatUserStatus, +} from "./types"; + +export default { + get status() { + return ChatUserStatus; + }, +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/start_typing_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/start_typing_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/start_typing_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/stop_typing_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/stop_typing_reducer.ts new file mode 100644 index 00000000000..80a9f7e20dd --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/stop_typing_reducer.ts @@ -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 { + roomId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/toggle_reaction_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/toggle_reaction_reducer.ts new file mode 100644 index 00000000000..bf62b01c66f --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/toggle_reaction_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + messageId: __t.u64(), + emoji: __t.string(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/types.ts b/spacetime-presence-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..a77ec4af952 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,296 @@ +// 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 const Attachment = __t.object("Attachment", { + id: __t.u64(), + messageId: __t.u64(), + fileId: __t.u64(), + ownerUserId: __t.string(), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type Attachment = __Infer; + +export const AttachmentFileResult = __t.object("AttachmentFileResult", { + filename: __t.option(__t.string()), + mimeType: __t.string(), + bytes: __t.byteArray(), +}); +export type AttachmentFileResult = __Infer; + +export const AttachmentInput = __t.object("AttachmentInput", { + mimeType: __t.string(), + filename: __t.option(__t.string()), + bytes: __t.byteArray(), +}); +export type AttachmentInput = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const ChatAuthUser = __t.object("ChatAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ChatAuthUser = __Infer; + +export const ChatRateLimitStatus = __t.object("ChatRateLimitStatus", { + scope: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.timestamp(), +}); +export type ChatRateLimitStatus = __Infer; + +export const ChatSweepTick = __t.object("ChatSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type ChatSweepTick = __Infer; + +export const ChatUser = __t.object("ChatUser", { + identity: __t.identity(), + userId: __t.string(), + displayName: __t.string(), + get status() { + return ChatUserStatus; + }, + createdAt: __t.timestamp(), + lastActiveAt: __t.timestamp(), + lastMessageAt: __t.timestamp(), +}); +export type ChatUser = __Infer; + +// The tagged union or sum type for the algebraic type `ChatUserStatus`. +export const ChatUserStatus = __t.enum("ChatUserStatus", { + Online: __t.unit(), + Away: __t.unit(), + Dnd: __t.unit(), + Invisible: __t.unit(), +}); +export type ChatUserStatus = __Infer; + +export const Message = __t.object("Message", { + id: __t.u64(), + roomId: __t.u64(), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp(), + editedAt: __t.option(__t.timestamp()), + replyToMessageId: __t.option(__t.u64()), + pinnedAt: __t.option(__t.timestamp()), + pinnedBy: __t.option(__t.identity()), +}); +export type Message = __Infer; + +export const MessageReaction = __t.object("MessageReaction", { + id: __t.u64(), + messageId: __t.u64(), + identity: __t.identity(), + emoji: __t.string(), + createdAt: __t.timestamp(), +}); +export type MessageReaction = __Infer; + +export const MessageThread = __t.object("MessageThread", { + id: __t.u64(), + rootMessageId: __t.u64(), + roomId: __t.u64(), + createdBy: __t.identity(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type MessageThread = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyChatUsers = __t.object("MyChatUsers", {}); +export type MyChatUsers = __Infer; + +export const MyMessageThreads = __t.object("MyMessageThreads", {}); +export type MyMessageThreads = __Infer; + +export const MyPresenceEntries = __t.object("MyPresenceEntries", {}); +export type MyPresenceEntries = __Infer; + +export const MyRateLimitStatus = __t.object("MyRateLimitStatus", {}); +export type MyRateLimitStatus = __Infer; + +export const MyRoomAttachments = __t.object("MyRoomAttachments", {}); +export type MyRoomAttachments = __Infer; + +export const MyRoomMembers = __t.object("MyRoomMembers", {}); +export type MyRoomMembers = __Infer; + +export const MyRoomMessageReactions = __t.object("MyRoomMessageReactions", {}); +export type MyRoomMessageReactions = __Infer; + +export const MyRoomMessages = __t.object("MyRoomMessages", {}); +export type MyRoomMessages = __Infer; + +export const MyRoomReadCursors = __t.object("MyRoomReadCursors", {}); +export type MyRoomReadCursors = __Infer; + +export const MyRooms = __t.object("MyRooms", {}); +export type MyRooms = __Infer; + +export const MyServerMembers = __t.object("MyServerMembers", {}); +export type MyServerMembers = __Infer; + +export const MyServers = __t.object("MyServers", {}); +export type MyServers = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const MyThreadMessages = __t.object("MyThreadMessages", {}); +export type MyThreadMessages = __Infer; + +export const PresenceConfig = __t.object("PresenceConfig", { + singleton: __t.bool(), + defaultTtlSeconds: __t.u32(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type PresenceConfig = __Infer; + +export const PresenceEntry = __t.object("PresenceEntry", { + key: __t.string(), + scope: __t.string(), + subject: __t.string(), + status: __t.string(), + activity: __t.option(__t.string()), + payloadJson: __t.option(__t.string()), + joinedAt: __t.timestamp(), + lastSeenAt: __t.timestamp(), + expiresAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type PresenceEntry = __Infer; + +export const Room = __t.object("Room", { + id: __t.u64(), + serverId: __t.u64(), + name: __t.string(), + category: __t.option(__t.string()), + createdByUserId: __t.string(), + createdAt: __t.timestamp(), + isPrivate: __t.bool(), + activityLabel: __t.string(), + activityScore: __t.u32(), + lastActivityAt: __t.option(__t.timestamp()), +}); +export type Room = __Infer; + +export const RoomActivityEvent = __t.object("RoomActivityEvent", { + id: __t.u64(), + roomId: __t.u64(), + createdAt: __t.timestamp(), +}); +export type RoomActivityEvent = __Infer; + +export const RoomAttachment = __t.object("RoomAttachment", { + id: __t.u64(), + messageId: __t.u64(), + fileId: __t.u64(), + ownerUserId: __t.string(), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + path: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type RoomAttachment = __Infer; + +export const RoomMember = __t.object("RoomMember", { + id: __t.u64(), + roomId: __t.u64(), + userId: __t.string(), + role: __t.string(), + joinedAt: __t.timestamp(), +}); +export type RoomMember = __Infer; + +export const RoomReadCursor = __t.object("RoomReadCursor", { + id: __t.u64(), + roomId: __t.u64(), + identity: __t.identity(), + lastReadMessageId: __t.u64(), + lastReadAt: __t.timestamp(), +}); +export type RoomReadCursor = __Infer; + +export const Server = __t.object("Server", { + id: __t.u64(), + name: __t.string(), + createdByUserId: __t.string(), + createdAt: __t.timestamp(), +}); +export type Server = __Infer; + +export const ServerMember = __t.object("ServerMember", { + id: __t.u64(), + serverId: __t.u64(), + userId: __t.string(), + role: __t.string(), + joinedAt: __t.timestamp(), +}); +export type ServerMember = __Infer; + +export const ThreadMessage = __t.object("ThreadMessage", { + id: __t.u64(), + threadId: __t.u64(), + author: __t.identity(), + content: __t.string(), + createdAt: __t.timestamp(), + editedAt: __t.option(__t.timestamp()), +}); +export type ThreadMessage = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), + userDisplayName: __t.option(__t.string()), + userStatus: __t.option(__t.string()), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-presence-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..f70528b40b1 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/types/procedures.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as GetAttachmentFileProcedure from "../get_attachment_file_procedure"; +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as SearchMessagesProcedure from "../search_messages_procedure"; +import * as WhoamiProcedure from "../whoami_procedure"; + +export type GetAttachmentFileArgs = __Infer; +export type GetAttachmentFileResult = __Infer; +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type SearchMessagesArgs = __Infer; +export type SearchMessagesResult = __Infer; +export type WhoamiArgs = __Infer; +export type WhoamiResult = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-presence-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..16775962995 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,76 @@ +// 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 +import CreateRoomReducer from "../create_room_reducer"; +import CreateServerReducer from "../create_server_reducer"; +import DeleteMessageReducer from "../delete_message_reducer"; +import DeleteRoomReducer from "../delete_room_reducer"; +import DeleteServerReducer from "../delete_server_reducer"; +import DeleteThreadMessageReducer from "../delete_thread_message_reducer"; +import EditMessageReducer from "../edit_message_reducer"; +import EditThreadMessageReducer from "../edit_thread_message_reducer"; +import HeartbeatReducer from "../heartbeat_reducer"; +import JoinRoomReducer from "../join_room_reducer"; +import JoinServerReducer from "../join_server_reducer"; +import LeaveRoomReducer from "../leave_room_reducer"; +import LeaveServerReducer from "../leave_server_reducer"; +import LinkConnectionReducer from "../link_connection_reducer"; +import MarkRoomReadReducer from "../mark_room_read_reducer"; +import PinMessageReducer from "../pin_message_reducer"; +import RenameRoomReducer from "../rename_room_reducer"; +import RenameServerReducer from "../rename_server_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SendMessageReducer from "../send_message_reducer"; +import SendThreadMessageReducer from "../send_thread_message_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import SetDisplayNameReducer from "../set_display_name_reducer"; +import SetRoomCategoryReducer from "../set_room_category_reducer"; +import SetRoomPrivacyReducer from "../set_room_privacy_reducer"; +import SetStatusReducer from "../set_status_reducer"; +import StartTypingReducer from "../start_typing_reducer"; +import StopTypingReducer from "../stop_typing_reducer"; +import ToggleReactionReducer from "../toggle_reaction_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UnpinMessageReducer from "../unpin_message_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; + +export type CreateRoomParams = __Infer; +export type CreateServerParams = __Infer; +export type DeleteMessageParams = __Infer; +export type DeleteRoomParams = __Infer; +export type DeleteServerParams = __Infer; +export type DeleteThreadMessageParams = __Infer; +export type EditMessageParams = __Infer; +export type EditThreadMessageParams = __Infer; +export type HeartbeatParams = __Infer; +export type JoinRoomParams = __Infer; +export type JoinServerParams = __Infer; +export type LeaveRoomParams = __Infer; +export type LeaveServerParams = __Infer; +export type LinkConnectionParams = __Infer; +export type MarkRoomReadParams = __Infer; +export type PinMessageParams = __Infer; +export type RenameRoomParams = __Infer; +export type RenameServerParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SendMessageParams = __Infer; +export type SendThreadMessageParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type SetDisplayNameParams = __Infer; +export type SetRoomCategoryParams = __Infer; +export type SetRoomPrivacyParams = __Infer; +export type SetStatusParams = __Infer; +export type StartTypingParams = __Infer; +export type StopTypingParams = __Infer; +export type ToggleReactionParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UnpinMessageParams = __Infer; +export type UpdateProfileParams = __Infer; + diff --git a/spacetime-presence-ts/example/src/module_bindings/app/unlink_connection_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/unpin_message_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/unpin_message_reducer.ts new file mode 100644 index 00000000000..104809a301c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/unpin_message_reducer.ts @@ -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 { + messageId: __t.u64(), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/update_profile_reducer.ts b/spacetime-presence-ts/example/src/module_bindings/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-presence-ts/example/src/module_bindings/app/whoami_procedure.ts b/spacetime-presence-ts/example/src/module_bindings/app/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-presence-ts/example/src/module_bindings/app/whoami_procedure.ts @@ -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"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-presence-ts/src/mounted/index.ts b/spacetime-presence-ts/src/mounted/index.ts index 090c9ef5e85..39bb7b0d8d1 100644 --- a/spacetime-presence-ts/src/mounted/index.ts +++ b/spacetime-presence-ts/src/mounted/index.ts @@ -56,7 +56,7 @@ const presenceAdminIdentity = table( ); const presenceSweepTick = table( - { name: 'presence_sweep_tick', scheduled: (): any => presence_sweep }, + { name: 'presence_sweep_tick' }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), @@ -244,6 +244,7 @@ export const presenceEntriesAdmin = spacetimedb.view( ); export const presence_sweep = spacetimedb.reducer( + { onSchedule: presenceSweepTick }, { arg: presenceSweepTick.rowType }, (ctx, _args) => { runPresenceSweep( diff --git a/spacetime-rate-limit-ts/example/package.json b/spacetime-rate-limit-ts/example/package.json index 28329b27278..e2c369b567d 100644 --- a/spacetime-rate-limit-ts/example/package.json +++ b/spacetime-rate-limit-ts/example/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "test:unit": "tsx scripts/test-reactor-rules.ts", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts b/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts index 7af9a61c281..06e1d241db6 100644 --- a/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts +++ b/spacetime-rate-limit-ts/example/spacetimedb/src/index.ts @@ -46,7 +46,6 @@ import { reactorEvent, reactorRoomState, rateLimitDemoSweepTick, - setSweepReducer, spacetimedb, type Schema, type Tx, @@ -1118,6 +1117,7 @@ export const updateConfig = spacetimedb.reducer( ); export const rate_limit_demo_sweep = spacetimedb.reducer( + { onSchedule: rateLimitDemoSweepTick }, { arg: rateLimitDemoSweepTick.rowType }, (ctx, _args) => { const demo = ctx.db.rateLimitDemoConfig.singleton.find(true); @@ -1128,5 +1128,3 @@ export const rate_limit_demo_sweep = spacetimedb.reducer( pruneRateLimitEvents(ctx, retainEvents, pruneBatch); } ); - -setSweepReducer(rate_limit_demo_sweep); diff --git a/spacetime-rate-limit-ts/example/spacetimedb/src/schema.ts b/spacetime-rate-limit-ts/example/spacetimedb/src/schema.ts index b69e98fee96..0e8d8e1e93a 100644 --- a/spacetime-rate-limit-ts/example/spacetimedb/src/schema.ts +++ b/spacetime-rate-limit-ts/example/spacetimedb/src/schema.ts @@ -30,28 +30,14 @@ export const rateLimitDemoConfig = table( } ); -let sweepReducer: unknown; - export const rateLimitDemoSweepTick = table( - { - name: 'rate_limit_demo_sweep_tick', - scheduled: (): any => { - if (!sweepReducer) { - throw new Error('rate_limit_demo.sweep_reducer_not_registered'); - } - return sweepReducer; - }, - }, + { name: 'rate_limit_demo_sweep_tick' }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), } ); -export function setSweepReducer(reducer: unknown): void { - sweepReducer = reducer; -} - export const spacetimedb = schema({ rateLimit, rateLimitEvent, diff --git a/spacetime-rate-limit-ts/example/src/app.ts b/spacetime-rate-limit-ts/example/src/app.ts index c9dac2a2ded..0ac5933f905 100644 --- a/spacetime-rate-limit-ts/example/src/app.ts +++ b/spacetime-rate-limit-ts/example/src/app.ts @@ -3,7 +3,7 @@ import { tables, type ErrorContext, type EventContext, -} from './codegen/app/index.ts'; +} from './module_bindings/app/index.ts'; type TimestampLike = { microsSinceUnixEpoch: bigint }; diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/buy_upgrade_procedure.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/buy_upgrade_procedure.ts new file mode 100644 index 00000000000..709e3a65208 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/buy_upgrade_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { + upgradeId: __t.string(), +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/index.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..e7b95496647 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,257 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 ResetDemoReducer from "./reset_demo_reducer"; +import SetPlayerColorReducer from "./set_player_color_reducer"; +import UpdateConfigReducer from "./update_config_reducer"; + +// Import all procedure arg schemas +import * as BuyUpgradeProcedure from "./buy_upgrade_procedure"; +import * as OverchargeProcedure from "./overcharge_procedure"; +import * as RepairReactorProcedure from "./repair_reactor_procedure"; +import * as RunSweepProcedure from "./run_sweep_procedure"; +import * as StartReactorProcedure from "./start_reactor_procedure"; +import * as TapReactorProcedure from "./tap_reactor_procedure"; + +// Import all table schema definitions +import RateLimitDemoConfigRow from "./rate_limit_demo_config_table"; +import RateLimitEventsAdminRow from "./rate_limit_events_admin_table"; +import ReactorEventsRow from "./reactor_events_table"; +import ReactorLimitStatusRow from "./reactor_limit_status_table"; +import ReactorPlayersRow from "./reactor_players_table"; +import ReactorShopRow from "./reactor_shop_table"; +import ReactorStateRow from "./reactor_state_table"; + +// Import namespace table schema definitions +import RateLimit_RateLimitConfigRow from "./rateLimit/rate_limit_config_table"; +import RateLimit_AdminRateLimitBucketsRow from "./rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import RateLimit_AddRateLimitAdminReducer from "./rateLimit/add_rate_limit_admin_reducer"; +import RateLimit_ResetBucketsReducer from "./rateLimit/reset_buckets_reducer"; +import RateLimit_UpdateConfigReducer from "./rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as RateLimit_ConsumeProcedure from "./rateLimit/consume_procedure"; +import * as RateLimit_RunSweepProcedure from "./rateLimit/run_sweep_procedure"; + +/** 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({ + rateLimitDemoConfig: __table({ + name: 'rate_limit_demo_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_demo_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_demo_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, RateLimitDemoConfigRow), + rateLimitEventsAdmin: __table({ + name: 'rate_limit_events_admin', + indexes: [ + ], + constraints: [ + ], + }, RateLimitEventsAdminRow), + reactorEvents: __table({ + name: 'reactor_events', + indexes: [ + ], + constraints: [ + ], + }, ReactorEventsRow), + reactorLimitStatus: __table({ + name: 'reactor_limit_status', + indexes: [ + ], + constraints: [ + ], + }, ReactorLimitStatusRow), + reactorPlayers: __table({ + name: 'reactor_players', + indexes: [ + ], + constraints: [ + ], + }, ReactorPlayersRow), + reactorShop: __table({ + name: 'reactor_shop', + indexes: [ + ], + constraints: [ + ], + }, ReactorShopRow), + reactorState: __table({ + name: 'reactor_state', + indexes: [ + ], + constraints: [ + ], + }, ReactorStateRow), + "rateLimit.rate_limit_config": __table({ + name: 'rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, RateLimit_RateLimitConfigRow), + "rateLimit.admin_rate_limit_buckets": __table({ + name: 'rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, RateLimit_AdminRateLimitBucketsRow), +}); + +/** 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( + __reducerSchema("reset_demo", ResetDemoReducer), + __reducerSchema("set_player_color", SetPlayerColorReducer), + __reducerSchema("update_config", UpdateConfigReducer), + __reducerSchema("rateLimit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer), + __reducerSchema("rateLimit.reset_buckets", RateLimit_ResetBucketsReducer), + __reducerSchema("rateLimit.update_config", RateLimit_UpdateConfigReducer), +); + +/** 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("buy_upgrade", BuyUpgradeProcedure.params, BuyUpgradeProcedure.returnType), + __procedureSchema("overcharge", OverchargeProcedure.params, OverchargeProcedure.returnType), + __procedureSchema("repair_reactor", RepairReactorProcedure.params, RepairReactorProcedure.returnType), + __procedureSchema("run_sweep", RunSweepProcedure.params, RunSweepProcedure.returnType), + __procedureSchema("start_reactor", StartReactorProcedure.params, StartReactorProcedure.returnType), + __procedureSchema("tap_reactor", TapReactorProcedure.params, TapReactorProcedure.returnType), + __procedureSchema("rateLimit.consume", RateLimit_ConsumeProcedure.params, RateLimit_ConsumeProcedure.returnType), + __procedureSchema("rateLimit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + rateLimitDemoConfig: __qb.rateLimitDemoConfig, + rateLimitEventsAdmin: __qb.rateLimitEventsAdmin, + reactorEvents: __qb.reactorEvents, + reactorLimitStatus: __qb.reactorLimitStatus, + reactorPlayers: __qb.reactorPlayers, + reactorShop: __qb.reactorShop, + reactorState: __qb.reactorState, + rateLimit: { + rateLimitConfig: __qb["rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["rateLimit.admin_rate_limit_buckets"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + resetDemo: __reducerAccessors.resetDemo, + setPlayerColor: __reducerAccessors.setPlayerColor, + updateConfig: __reducerAccessors.updateConfig, + rateLimit: { + addRateLimitAdmin: __reducerAccessors["rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["rateLimit.updateConfig"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + buyUpgrade: __procedureAccessors.buyUpgrade, + overcharge: __procedureAccessors.overcharge, + repairReactor: __procedureAccessors.repairReactor, + runSweep: __procedureAccessors.runSweep, + startReactor: __procedureAccessors.startReactor, + tapReactor: __procedureAccessors.tapReactor, + rateLimit: { + consume: __procedureAccessors["rateLimit.consume"], + runSweep: __procedureAccessors["rateLimit.runSweep"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/overcharge_procedure.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/overcharge_procedure.ts new file mode 100644 index 00000000000..d53ce96cf57 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/overcharge_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/types.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rate_limit_demo_config_table.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rate_limit_demo_config_table.ts new file mode 100644 index 00000000000..bcb7aa20309 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rate_limit_demo_config_table.ts @@ -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({ + singleton: __t.bool().primaryKey(), + retainEvents: __t.u32().name("retain_events"), + eventPruneBatch: __t.u32().name("event_prune_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/rate_limit_events_admin_table.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/rate_limit_events_admin_table.ts new file mode 100644 index 00000000000..d6d861cbb6f --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/rate_limit_events_admin_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + scope: __t.string(), + key: __t.string(), + allowed: __t.bool(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32().name("retry_after_seconds"), + windowSeconds: __t.u32().name("window_seconds"), + cost: __t.u32(), + resetAt: __t.timestamp().name("reset_at"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_events_table.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_events_table.ts new file mode 100644 index 00000000000..207ec768f97 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_events_table.ts @@ -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"; + +export default __t.row({ + id: __t.u64().primaryKey(), + identity: __t.identity(), + actorName: __t.string().name("actor_name"), + actorColor: __t.string().name("actor_color"), + kind: __t.string(), + scope: __t.string(), + message: __t.string(), + allowed: __t.bool(), + energyDelta: __t.i64().name("energy_delta"), + retryAfterSeconds: __t.u32().name("retry_after_seconds"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_limit_status_table.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_limit_status_table.ts new file mode 100644 index 00000000000..fc9f0392d1e --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_limit_status_table.ts @@ -0,0 +1,21 @@ +// 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({ + scope: __t.string(), + label: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32().name("window_seconds"), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.option(__t.timestamp()).name("reset_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_players_table.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_players_table.ts new file mode 100644 index 00000000000..e9c658484b7 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_players_table.ts @@ -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({ + identity: __t.identity(), + displayName: __t.string().name("display_name"), + color: __t.string(), + contributedEnergy: __t.u64().name("contributed_energy"), + taps: __t.u32(), + surges: __t.u32(), + coolantUses: __t.u32().name("coolant_uses"), + upgradesBought: __t.u32().name("upgrades_bought"), + joinedAt: __t.timestamp().name("joined_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_shop_table.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_shop_table.ts new file mode 100644 index 00000000000..5a0217589e6 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_shop_table.ts @@ -0,0 +1,21 @@ +// 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({ + slot: __t.u32(), + id: __t.string(), + name: __t.string(), + description: __t.string(), + effect: __t.string(), + cost: __t.u64(), + available: __t.bool(), +}); diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_state_table.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_state_table.ts new file mode 100644 index 00000000000..422838dd710 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/reactor_state_table.ts @@ -0,0 +1,31 @@ +// 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({ + singleton: __t.bool().primaryKey(), + energy: __t.u64(), + reactorLevel: __t.u32().name("reactor_level"), + upgradeCount: __t.u32().name("upgrade_count"), + powerUpgradeCount: __t.u32().name("power_upgrade_count"), + coolingUpgradeCount: __t.u32().name("cooling_upgrade_count"), + capacityUpgradeCount: __t.u32().name("capacity_upgrade_count"), + chargeUpgradeCount: __t.u32().name("charge_upgrade_count"), + bayUpgradeCount: __t.u32().name("bay_upgrade_count"), + combo: __t.u32(), + bestCombo: __t.u32().name("best_combo"), + heat: __t.u32(), + heatCapacity: __t.u32().name("heat_capacity"), + coolingPerSecond: __t.u32().name("cooling_per_second"), + tapHeatGain: __t.u32().name("tap_heat_gain"), + overheated: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/repair_reactor_procedure.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/repair_reactor_procedure.ts new file mode 100644 index 00000000000..d53ce96cf57 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/repair_reactor_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/reset_demo_reducer.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/reset_demo_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/reset_demo_reducer.ts @@ -0,0 +1,13 @@ +// 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 {}; diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/run_sweep_procedure.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/set_player_color_reducer.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/set_player_color_reducer.ts new file mode 100644 index 00000000000..42ec3238c75 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/set_player_color_reducer.ts @@ -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 { + color: __t.string(), +}; diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/start_reactor_procedure.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/start_reactor_procedure.ts new file mode 100644 index 00000000000..d53ce96cf57 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/start_reactor_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/tap_reactor_procedure.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/tap_reactor_procedure.ts new file mode 100644 index 00000000000..d53ce96cf57 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/tap_reactor_procedure.ts @@ -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"; + +import { + ReactorActionResult, +} from "./types"; + +export const params = { +}; +export const returnType = ReactorActionResult \ No newline at end of file diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/types.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..fd9c231c367 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,157 @@ +// 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 const RateLimitDemoConfig = __t.object("RateLimitDemoConfig", { + singleton: __t.bool(), + retainEvents: __t.u32(), + eventPruneBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitDemoConfig = __Infer; + +export const RateLimitDemoSweepTick = __t.object("RateLimitDemoSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitDemoSweepTick = __Infer; + +export const RateLimitEvent = __t.object("RateLimitEvent", { + id: __t.u64(), + scope: __t.string(), + key: __t.string(), + allowed: __t.bool(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.u32(), + resetAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type RateLimitEvent = __Infer; + +export const RateLimitEventsAdmin = __t.object("RateLimitEventsAdmin", {}); +export type RateLimitEventsAdmin = __Infer; + +export const ReactorActionResult = __t.object("ReactorActionResult", { + allowed: __t.bool(), + action: __t.string(), + message: __t.string(), + energy: __t.u64(), + energyDelta: __t.i64(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type ReactorActionResult = __Infer; + +export const ReactorEvent = __t.object("ReactorEvent", { + id: __t.u64(), + identity: __t.identity(), + actorName: __t.string(), + actorColor: __t.string(), + kind: __t.string(), + scope: __t.string(), + message: __t.string(), + allowed: __t.bool(), + energyDelta: __t.i64(), + retryAfterSeconds: __t.u32(), + createdAt: __t.timestamp(), +}); +export type ReactorEvent = __Infer; + +export const ReactorEvents = __t.object("ReactorEvents", {}); +export type ReactorEvents = __Infer; + +export const ReactorLimitStatus = __t.object("ReactorLimitStatus", {}); +export type ReactorLimitStatus = __Infer; + +export const ReactorLimitStatusRow = __t.object("ReactorLimitStatusRow", { + scope: __t.string(), + label: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + resetAt: __t.option(__t.timestamp()), +}); +export type ReactorLimitStatusRow = __Infer; + +export const ReactorPlayerRow = __t.object("ReactorPlayerRow", { + identity: __t.identity(), + displayName: __t.string(), + color: __t.string(), + contributedEnergy: __t.u64(), + taps: __t.u32(), + surges: __t.u32(), + coolantUses: __t.u32(), + upgradesBought: __t.u32(), + joinedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ReactorPlayerRow = __Infer; + +export const ReactorPlayerState = __t.object("ReactorPlayerState", { + identity: __t.identity(), + displayName: __t.string(), + color: __t.string(), + contributedEnergy: __t.u64(), + taps: __t.u32(), + surges: __t.u32(), + coolantUses: __t.u32(), + upgradesBought: __t.u32(), + joinedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ReactorPlayerState = __Infer; + +export const ReactorPlayers = __t.object("ReactorPlayers", {}); +export type ReactorPlayers = __Infer; + +export const ReactorRoomState = __t.object("ReactorRoomState", { + singleton: __t.bool(), + energy: __t.u64(), + reactorLevel: __t.u32(), + upgradeCount: __t.u32(), + powerUpgradeCount: __t.u32(), + coolingUpgradeCount: __t.u32(), + capacityUpgradeCount: __t.u32(), + chargeUpgradeCount: __t.u32(), + bayUpgradeCount: __t.u32(), + combo: __t.u32(), + bestCombo: __t.u32(), + heat: __t.u32(), + heatCapacity: __t.u32(), + coolingPerSecond: __t.u32(), + tapHeatGain: __t.u32(), + overheated: __t.bool(), + updatedAt: __t.timestamp(), +}); +export type ReactorRoomState = __Infer; + +export const ReactorShop = __t.object("ReactorShop", {}); +export type ReactorShop = __Infer; + +export const ReactorShopItemRow = __t.object("ReactorShopItemRow", { + slot: __t.u32(), + id: __t.string(), + name: __t.string(), + description: __t.string(), + effect: __t.string(), + cost: __t.u64(), + available: __t.bool(), +}); +export type ReactorShopItemRow = __Infer; + +export const ReactorState = __t.object("ReactorState", {}); +export type ReactorState = __Infer; + diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..44dde79c5fd --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/types/procedures.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as BuyUpgradeProcedure from "../buy_upgrade_procedure"; +import * as OverchargeProcedure from "../overcharge_procedure"; +import * as RepairReactorProcedure from "../repair_reactor_procedure"; +import * as RunSweepProcedure from "../run_sweep_procedure"; +import * as StartReactorProcedure from "../start_reactor_procedure"; +import * as TapReactorProcedure from "../tap_reactor_procedure"; + +export type BuyUpgradeArgs = __Infer; +export type BuyUpgradeResult = __Infer; +export type OverchargeArgs = __Infer; +export type OverchargeResult = __Infer; +export type RepairReactorArgs = __Infer; +export type RepairReactorResult = __Infer; +export type RunSweepArgs = __Infer; +export type RunSweepResult = __Infer; +export type StartReactorArgs = __Infer; +export type StartReactorResult = __Infer; +export type TapReactorArgs = __Infer; +export type TapReactorResult = __Infer; + diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..3bed08e992d --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,16 @@ +// 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 +import ResetDemoReducer from "../reset_demo_reducer"; +import SetPlayerColorReducer from "../set_player_color_reducer"; +import UpdateConfigReducer from "../update_config_reducer"; + +export type ResetDemoParams = __Infer; +export type SetPlayerColorParams = __Infer; +export type UpdateConfigParams = __Infer; + diff --git a/spacetime-rate-limit-ts/example/src/module_bindings/app/update_config_reducer.ts b/spacetime-rate-limit-ts/example/src/module_bindings/app/update_config_reducer.ts new file mode 100644 index 00000000000..d7ddcf900e6 --- /dev/null +++ b/spacetime-rate-limit-ts/example/src/module_bindings/app/update_config_reducer.ts @@ -0,0 +1,17 @@ +// 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 { + sweepBatch: __t.option(__t.u32()), + retainEvents: __t.option(__t.u32()), + eventPruneBatch: __t.option(__t.u32()), +}; diff --git a/spacetime-rate-limit-ts/src/submodule/operations.ts b/spacetime-rate-limit-ts/src/submodule/operations.ts index bff17d1a223..6d2e0f52163 100644 --- a/spacetime-rate-limit-ts/src/submodule/operations.ts +++ b/spacetime-rate-limit-ts/src/submodule/operations.ts @@ -9,7 +9,6 @@ import { import { rateLimitBucket, rateLimitSweepTick, - setRateLimitSweepReducer, spacetimedb, t, type ReducerModuleCtx, @@ -181,6 +180,7 @@ export const adminRateLimitBuckets = spacetimedb.view( ); export const rate_limit_sweep = spacetimedb.reducer( + { onSchedule: rateLimitSweepTick }, { arg: rateLimitSweepTick.rowType }, (ctx, _args) => { runRateLimitSweep( @@ -191,5 +191,3 @@ export const rate_limit_sweep = spacetimedb.reducer( ); } ); - -setRateLimitSweepReducer(rate_limit_sweep); diff --git a/spacetime-rate-limit-ts/src/submodule/schema.ts b/spacetime-rate-limit-ts/src/submodule/schema.ts index 46484ca523e..3d546331a75 100644 --- a/spacetime-rate-limit-ts/src/submodule/schema.ts +++ b/spacetime-rate-limit-ts/src/submodule/schema.ts @@ -38,22 +38,8 @@ export const rateLimitConfig = table( } ); -let rateLimitSweepReducer: unknown; - -export function setRateLimitSweepReducer(reducer: unknown): void { - rateLimitSweepReducer = reducer; -} - export const rateLimitSweepTick = table( - { - name: 'rate_limit_sweep_tick', - scheduled: (): any => { - if (!rateLimitSweepReducer) { - throw new Error('rate_limit.sweep_reducer_not_registered'); - } - return rateLimitSweepReducer; - }, - }, + { name: 'rate_limit_sweep_tick' }, { scheduledId: t.u64().primaryKey().autoInc(), scheduledAt: t.scheduleAt(), diff --git a/spacetime-resend-ts/example/package.json b/spacetime-resend-ts/example/package.json index bc3ec4a6bb4..634b317db32 100644 --- a/spacetime-resend-ts/example/package.json +++ b/spacetime-resend-ts/example/package.json @@ -4,13 +4,13 @@ "private": true, "type": "module", "scripts": { - "build:codegen": "spacetime generate --lang typescript --out-dir src/codegen --module-path ./spacetimedb -y", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "test:unit": "tsx scripts/test-message.ts", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-resend-ts/example/server.ts b/spacetime-resend-ts/example/server.ts index aa905bef0ae..158f8973450 100644 --- a/spacetime-resend-ts/example/server.ts +++ b/spacetime-resend-ts/example/server.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { DbConnection, type ErrorContext } from './src/codegen'; +import { DbConnection, type ErrorContext } from './src/module_bindings'; import { discardStoredServerToken, grantServerIdentity, diff --git a/spacetime-resend-ts/example/src/app.ts b/spacetime-resend-ts/example/src/app.ts index bde7b97bf58..8e5ad1f14f2 100644 --- a/spacetime-resend-ts/example/src/app.ts +++ b/spacetime-resend-ts/example/src/app.ts @@ -4,7 +4,7 @@ import { type ErrorContext, type EventContext, type SubscriptionEventContext, -} from './codegen'; +} from './module_bindings'; import type { Timestamp } from 'spacetimedb'; import { messageHtml } from '../spacetimedb/src/message'; diff --git a/spacetime-resend-ts/example/src/module_bindings/clear_dispatches_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/clear_dispatches_procedure.ts new file mode 100644 index 00000000000..6621a844b9c --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/clear_dispatches_procedure.ts @@ -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"; + +import { + DispatchDeleteResult, +} from "./types"; + +export const params = { +}; +export const returnType = DispatchDeleteResult \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/delete_dispatch_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/delete_dispatch_procedure.ts new file mode 100644 index 00000000000..9e246ad1174 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/delete_dispatch_procedure.ts @@ -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"; + +import { + DispatchDeleteResult, +} from "./types"; + +export const params = { + resendId: __t.string(), +}; +export const returnType = DispatchDeleteResult \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/index.ts b/spacetime-resend-ts/example/src/module_bindings/index.ts new file mode 100644 index 00000000000..b0aafebcd54 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/index.ts @@ -0,0 +1,239 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 ClearDispatchesProcedure from "./clear_dispatches_procedure"; +import * as DeleteDispatchProcedure from "./delete_dispatch_procedure"; +import * as SendDispatchProcedure from "./send_dispatch_procedure"; +import * as SetDispatchPolicyProcedure from "./set_dispatch_policy_procedure"; + +// Import all table schema definitions +import MyDispatchDeliveryEventsRow from "./my_dispatch_delivery_events_table"; +import MyDispatchEmailsRow from "./my_dispatch_emails_table"; + +// Import namespace table schema definitions +import RateLimit_RateLimitConfigRow from "./rateLimit/rate_limit_config_table"; +import RateLimit_AdminRateLimitBucketsRow from "./rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Resend_IngestResendWebhookReducer from "./resend/ingest_resend_webhook_reducer"; +import Resend_ReplayWebhookEventReducer from "./resend/replay_webhook_event_reducer"; +import RateLimit_AddRateLimitAdminReducer from "./rateLimit/add_rate_limit_admin_reducer"; +import RateLimit_ResetBucketsReducer from "./rateLimit/reset_buckets_reducer"; +import RateLimit_UpdateConfigReducer from "./rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Resend_AddAdminIdentityProcedure from "./resend/add_admin_identity_procedure"; +import * as Resend_CancelEmailProcedure from "./resend/cancel_email_procedure"; +import * as Resend_GetEmailProcedure from "./resend/get_email_procedure"; +import * as Resend_GetResendConfigStatusProcedure from "./resend/get_resend_config_status_procedure"; +import * as Resend_ListDeliveryEventsForEmailProcedure from "./resend/list_delivery_events_for_email_procedure"; +import * as Resend_ListEmailsByOrgIdProcedure from "./resend/list_emails_by_org_id_procedure"; +import * as Resend_ListEmailsByStatusProcedure from "./resend/list_emails_by_status_procedure"; +import * as Resend_ListEmailsByUserIdProcedure from "./resend/list_emails_by_user_id_procedure"; +import * as Resend_RemoveAdminIdentityProcedure from "./resend/remove_admin_identity_procedure"; +import * as Resend_ResendApiRequestProcedure from "./resend/resend_api_request_procedure"; +import * as Resend_SendEmailProcedure from "./resend/send_email_procedure"; +import * as Resend_SetResendConfigProcedure from "./resend/set_resend_config_procedure"; +import * as RateLimit_ConsumeProcedure from "./rateLimit/consume_procedure"; +import * as RateLimit_RunSweepProcedure from "./rateLimit/run_sweep_procedure"; + +/** 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({ + myDispatchDeliveryEvents: __table({ + name: 'my_dispatch_delivery_events', + indexes: [ + ], + constraints: [ + ], + }, MyDispatchDeliveryEventsRow), + myDispatchEmails: __table({ + name: 'my_dispatch_emails', + indexes: [ + ], + constraints: [ + ], + }, MyDispatchEmailsRow), + "rateLimit.rate_limit_config": __table({ + name: 'rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, RateLimit_RateLimitConfigRow), + "rateLimit.admin_rate_limit_buckets": __table({ + name: 'rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, RateLimit_AdminRateLimitBucketsRow), +}); + +/** 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( + __reducerSchema("resend.ingest_resend_webhook", Resend_IngestResendWebhookReducer), + __reducerSchema("resend.replay_webhook_event", Resend_ReplayWebhookEventReducer), + __reducerSchema("rateLimit.add_rate_limit_admin", RateLimit_AddRateLimitAdminReducer), + __reducerSchema("rateLimit.reset_buckets", RateLimit_ResetBucketsReducer), + __reducerSchema("rateLimit.update_config", RateLimit_UpdateConfigReducer), +); + +/** 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_dispatches", ClearDispatchesProcedure.params, ClearDispatchesProcedure.returnType), + __procedureSchema("delete_dispatch", DeleteDispatchProcedure.params, DeleteDispatchProcedure.returnType), + __procedureSchema("send_dispatch", SendDispatchProcedure.params, SendDispatchProcedure.returnType), + __procedureSchema("set_dispatch_policy", SetDispatchPolicyProcedure.params, SetDispatchPolicyProcedure.returnType), + __procedureSchema("resend.add_admin_identity", Resend_AddAdminIdentityProcedure.params, Resend_AddAdminIdentityProcedure.returnType), + __procedureSchema("resend.cancel_email", Resend_CancelEmailProcedure.params, Resend_CancelEmailProcedure.returnType), + __procedureSchema("resend.get_email", Resend_GetEmailProcedure.params, Resend_GetEmailProcedure.returnType), + __procedureSchema("resend.get_resend_config_status", Resend_GetResendConfigStatusProcedure.params, Resend_GetResendConfigStatusProcedure.returnType), + __procedureSchema("resend.list_delivery_events_for_email", Resend_ListDeliveryEventsForEmailProcedure.params, Resend_ListDeliveryEventsForEmailProcedure.returnType), + __procedureSchema("resend.list_emails_by_org_id", Resend_ListEmailsByOrgIdProcedure.params, Resend_ListEmailsByOrgIdProcedure.returnType), + __procedureSchema("resend.list_emails_by_status", Resend_ListEmailsByStatusProcedure.params, Resend_ListEmailsByStatusProcedure.returnType), + __procedureSchema("resend.list_emails_by_user_id", Resend_ListEmailsByUserIdProcedure.params, Resend_ListEmailsByUserIdProcedure.returnType), + __procedureSchema("resend.remove_admin_identity", Resend_RemoveAdminIdentityProcedure.params, Resend_RemoveAdminIdentityProcedure.returnType), + __procedureSchema("resend.resend_api_request", Resend_ResendApiRequestProcedure.params, Resend_ResendApiRequestProcedure.returnType), + __procedureSchema("resend.send_email", Resend_SendEmailProcedure.params, Resend_SendEmailProcedure.returnType), + __procedureSchema("resend.set_resend_config", Resend_SetResendConfigProcedure.params, Resend_SetResendConfigProcedure.returnType), + __procedureSchema("rateLimit.consume", RateLimit_ConsumeProcedure.params, RateLimit_ConsumeProcedure.returnType), + __procedureSchema("rateLimit.run_sweep", RateLimit_RunSweepProcedure.params, RateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + myDispatchDeliveryEvents: __qb.myDispatchDeliveryEvents, + myDispatchEmails: __qb.myDispatchEmails, + rateLimit: { + rateLimitConfig: __qb["rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["rateLimit.admin_rate_limit_buckets"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + rateLimit: { + addRateLimitAdmin: __reducerAccessors["rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["rateLimit.updateConfig"], + }, + resend: { + ingestResendWebhook: __reducerAccessors["resend.ingestResendWebhook"], + replayWebhookEvent: __reducerAccessors["resend.replayWebhookEvent"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + clearDispatches: __procedureAccessors.clearDispatches, + deleteDispatch: __procedureAccessors.deleteDispatch, + sendDispatch: __procedureAccessors.sendDispatch, + setDispatchPolicy: __procedureAccessors.setDispatchPolicy, + rateLimit: { + consume: __procedureAccessors["rateLimit.consume"], + runSweep: __procedureAccessors["rateLimit.runSweep"], + }, + resend: { + addAdminIdentity: __procedureAccessors["resend.addAdminIdentity"], + cancelEmail: __procedureAccessors["resend.cancelEmail"], + getEmail: __procedureAccessors["resend.getEmail"], + getResendConfigStatus: __procedureAccessors["resend.getResendConfigStatus"], + listDeliveryEventsForEmail: __procedureAccessors["resend.listDeliveryEventsForEmail"], + listEmailsByOrgId: __procedureAccessors["resend.listEmailsByOrgId"], + listEmailsByStatus: __procedureAccessors["resend.listEmailsByStatus"], + listEmailsByUserId: __procedureAccessors["resend.listEmailsByUserId"], + removeAdminIdentity: __procedureAccessors["resend.removeAdminIdentity"], + resendApiRequest: __procedureAccessors["resend.resendApiRequest"], + sendEmail: __procedureAccessors["resend.sendEmail"], + setResendConfig: __procedureAccessors["resend.setResendConfig"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-resend-ts/example/src/module_bindings/my_dispatch_delivery_events_table.ts b/spacetime-resend-ts/example/src/module_bindings/my_dispatch_delivery_events_table.ts new file mode 100644 index 00000000000..bcd9654503f --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/my_dispatch_delivery_events_table.ts @@ -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({ + eventId: __t.string().primaryKey().name("event_id"), + resendId: __t.string().name("resend_id"), + eventType: __t.string().name("event_type"), + createdAtIso: __t.string().name("created_at_iso"), + detailJson: __t.option(__t.string()).name("detail_json"), + insertedAt: __t.timestamp().name("inserted_at"), +}); diff --git a/spacetime-resend-ts/example/src/module_bindings/my_dispatch_emails_table.ts b/spacetime-resend-ts/example/src/module_bindings/my_dispatch_emails_table.ts new file mode 100644 index 00000000000..4e11a588405 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/my_dispatch_emails_table.ts @@ -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. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + EmailStatus, +} from "./types"; + + +export default __t.row({ + resendId: __t.string().primaryKey().name("resend_id"), + fromAddress: __t.string().name("from_address"), + toAddressesJson: __t.string().name("to_addresses_json"), + subject: __t.option(__t.string()), + get status() { + return EmailStatus; + }, + lastError: __t.option(__t.string()).name("last_error"), + bouncedAt: __t.option(__t.timestamp()).name("bounced_at"), + bounceJson: __t.option(__t.string()).name("bounce_json"), + failedAt: __t.option(__t.timestamp()).name("failed_at"), + failureReason: __t.option(__t.string()).name("failure_reason"), + complained: __t.bool(), + complainedAt: __t.option(__t.timestamp()).name("complained_at"), + opened: __t.bool(), + openedAt: __t.option(__t.timestamp()).name("opened_at"), + clicked: __t.bool(), + clickedAt: __t.option(__t.timestamp()).name("clicked_at"), + deliveredAt: __t.option(__t.timestamp()).name("delivered_at"), + sentAt: __t.option(__t.timestamp()).name("sent_at"), + html: __t.option(__t.string()), + text: __t.option(__t.string()), + tagsJson: __t.option(__t.string()).name("tags_json"), + userId: __t.option(__t.string()).name("user_id"), + orgId: __t.option(__t.string()).name("org_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/add_rate_limit_admin_reducer.ts @@ -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 { + identity: __t.identity(), +}; diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/admin_rate_limit_buckets_table.ts @@ -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({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/consume_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/consume_procedure.ts @@ -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 { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/rate_limit_config_table.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// 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({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/reset_buckets_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/reset_buckets_reducer.ts @@ -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 { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/run_sweep_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/types.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/types.ts @@ -0,0 +1,56 @@ +// 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 const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-resend-ts/example/src/module_bindings/rateLimit/update_config_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/rateLimit/update_config_reducer.ts @@ -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 { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/add_admin_identity_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/add_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/add_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/cancel_email_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/cancel_email_procedure.ts new file mode 100644 index 00000000000..ec778b46cd0 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/cancel_email_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + resendId: __t.string(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/get_email_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/get_email_procedure.ts new file mode 100644 index 00000000000..0744ed77aab --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/get_email_procedure.ts @@ -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"; + +import { + ResendEmail, +} from "./types"; + +export const params = { + resendId: __t.string(), +}; +export const returnType = __t.option(ResendEmail) \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/get_resend_config_status_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/get_resend_config_status_procedure.ts new file mode 100644 index 00000000000..badd9ab9cc8 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/get_resend_config_status_procedure.ts @@ -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"; + +import { + ResendConfigStatus, +} from "./types"; + +export const params = { +}; +export const returnType = ResendConfigStatus \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/ingest_resend_webhook_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/resend/ingest_resend_webhook_reducer.ts new file mode 100644 index 00000000000..1e7afcc3703 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/ingest_resend_webhook_reducer.ts @@ -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 { + eventId: __t.string(), + eventType: __t.string(), + payloadJson: __t.string(), + signatureHeader: __t.option(__t.string()), + timestampHeader: __t.option(__t.string()), +}; diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/list_delivery_events_for_email_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/list_delivery_events_for_email_procedure.ts new file mode 100644 index 00000000000..27a3a808053 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/list_delivery_events_for_email_procedure.ts @@ -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"; + +import { + ResendDeliveryEvent, +} from "./types"; + +export const params = { + resendId: __t.string(), +}; +export const returnType = __t.array(ResendDeliveryEvent) \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_org_id_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_org_id_procedure.ts new file mode 100644 index 00000000000..cd1063291da --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_org_id_procedure.ts @@ -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"; + +import { + ResendEmail, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(ResendEmail) \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_status_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_status_procedure.ts new file mode 100644 index 00000000000..c24fc71c444 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_status_procedure.ts @@ -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 { + ResendEmail, + EmailStatus, +} from "./types"; + +export const params = { + get status() { + return EmailStatus; + }, +}; +export const returnType = __t.array(ResendEmail) \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_user_id_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_user_id_procedure.ts new file mode 100644 index 00000000000..f712b807b32 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/list_emails_by_user_id_procedure.ts @@ -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"; + +import { + ResendEmail, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(ResendEmail) \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/remove_admin_identity_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/remove_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/remove_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/replay_webhook_event_reducer.ts b/spacetime-resend-ts/example/src/module_bindings/resend/replay_webhook_event_reducer.ts new file mode 100644 index 00000000000..590a453de82 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/replay_webhook_event_reducer.ts @@ -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 { + eventId: __t.string(), +}; diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/resend_api_request_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/resend_api_request_procedure.ts new file mode 100644 index 00000000000..aff60351ebf --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/resend_api_request_procedure.ts @@ -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 { + ResendApiRequestResult, +} from "./types"; + +export const params = { + method: __t.string(), + path: __t.string(), + jsonBody: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = ResendApiRequestResult \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/send_email_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/send_email_procedure.ts new file mode 100644 index 00000000000..67babe6715f --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/send_email_procedure.ts @@ -0,0 +1,31 @@ +// 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 { + SendEmailResult, +} from "./types"; + +export const params = { + from: __t.option(__t.string()), + to: __t.array(__t.string()), + subject: __t.string(), + html: __t.option(__t.string()), + text: __t.option(__t.string()), + cc: __t.option(__t.array(__t.string())), + bcc: __t.option(__t.array(__t.string())), + replyTo: __t.option(__t.array(__t.string())), + tagsJson: __t.option(__t.string()), + headersJson: __t.option(__t.string()), + scheduledAt: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = SendEmailResult \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/set_resend_config_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/resend/set_resend_config_procedure.ts new file mode 100644 index 00000000000..645c952bd4d --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/set_resend_config_procedure.ts @@ -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 const params = { + apiKey: __t.string(), + webhookSigningSecret: __t.option(__t.string()), + defaultFrom: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/resend/types.ts b/spacetime-resend-ts/example/src/module_bindings/resend/types.ts new file mode 100644 index 00000000000..206a44b1f74 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/resend/types.ts @@ -0,0 +1,123 @@ +// 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 `EmailStatus`. +export const EmailStatus = __t.enum("EmailStatus", { + Queued: __t.unit(), + Sent: __t.unit(), + Delivered: __t.unit(), + DeliveryDelayed: __t.unit(), + Bounced: __t.unit(), + Failed: __t.unit(), + Cancelled: __t.unit(), +}); +export type EmailStatus = __Infer; + +export const ResendAdminIdentity = __t.object("ResendAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type ResendAdminIdentity = __Infer; + +export const ResendApiRequestResult = __t.object("ResendApiRequestResult", { + status: __t.u16(), + body: __t.string(), +}); +export type ResendApiRequestResult = __Infer; + +export const ResendConfig = __t.object("ResendConfig", { + singleton: __t.bool(), + apiKey: __t.string(), + webhookSigningSecret: __t.option(__t.string()), + defaultFrom: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type ResendConfig = __Infer; + +export const ResendConfigStatus = __t.object("ResendConfigStatus", { + isConfigured: __t.bool(), + hasWebhookSecret: __t.bool(), + defaultFrom: __t.option(__t.string()), + apiKeyLength: __t.u16(), +}); +export type ResendConfigStatus = __Infer; + +export const ResendDeliveryEvent = __t.object("ResendDeliveryEvent", { + eventId: __t.string(), + resendId: __t.string(), + eventType: __t.string(), + createdAtIso: __t.string(), + detailJson: __t.option(__t.string()), + insertedAt: __t.timestamp(), +}); +export type ResendDeliveryEvent = __Infer; + +export const ResendEmail = __t.object("ResendEmail", { + resendId: __t.string(), + fromAddress: __t.string(), + toAddressesJson: __t.string(), + subject: __t.option(__t.string()), + get status() { + return EmailStatus; + }, + lastError: __t.option(__t.string()), + bouncedAt: __t.option(__t.timestamp()), + bounceJson: __t.option(__t.string()), + failedAt: __t.option(__t.timestamp()), + failureReason: __t.option(__t.string()), + complained: __t.bool(), + complainedAt: __t.option(__t.timestamp()), + opened: __t.bool(), + openedAt: __t.option(__t.timestamp()), + clicked: __t.bool(), + clickedAt: __t.option(__t.timestamp()), + deliveredAt: __t.option(__t.timestamp()), + sentAt: __t.option(__t.timestamp()), + html: __t.option(__t.string()), + text: __t.option(__t.string()), + tagsJson: __t.option(__t.string()), + userId: __t.option(__t.string()), + orgId: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ResendEmail = __Infer; + +export const ResendWebhookEvent = __t.object("ResendWebhookEvent", { + eventId: __t.string(), + eventType: __t.string(), + payloadJson: __t.string(), + signatureHeader: __t.option(__t.string()), + timestampHeader: __t.option(__t.string()), + get status() { + return WebhookEventStatus; + }, + errorMessage: __t.option(__t.string()), + receivedAt: __t.timestamp(), + processedAt: __t.option(__t.timestamp()), +}); +export type ResendWebhookEvent = __Infer; + +export const SendEmailResult = __t.object("SendEmailResult", { + resendId: __t.string(), +}); +export type SendEmailResult = __Infer; + +// The tagged union or sum type for the algebraic type `WebhookEventStatus`. +export const WebhookEventStatus = __t.enum("WebhookEventStatus", { + Received: __t.unit(), + Processed: __t.unit(), + Ignored: __t.unit(), + Failed: __t.unit(), +}); +export type WebhookEventStatus = __Infer; + diff --git a/spacetime-resend-ts/example/src/module_bindings/send_dispatch_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/send_dispatch_procedure.ts new file mode 100644 index 00000000000..5a200f044d9 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/send_dispatch_procedure.ts @@ -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"; + +import { + DispatchSendResult, +} from "./types"; + +export const params = { + to: __t.string(), + subject: __t.string(), + message: __t.string(), +}; +export const returnType = DispatchSendResult \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/set_dispatch_policy_procedure.ts b/spacetime-resend-ts/example/src/module_bindings/set_dispatch_policy_procedure.ts new file mode 100644 index 00000000000..d27751fdf5d --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/set_dispatch_policy_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + allowedRecipientsJson: __t.string(), +}; +export const returnType = __t.bool() \ No newline at end of file diff --git a/spacetime-resend-ts/example/src/module_bindings/types.ts b/spacetime-resend-ts/example/src/module_bindings/types.ts new file mode 100644 index 00000000000..5275af6375d --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/types.ts @@ -0,0 +1,91 @@ +// 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 const DispatchDeleteResult = __t.object("DispatchDeleteResult", { + ok: __t.bool(), + removed: __t.u32(), +}); +export type DispatchDeleteResult = __Infer; + +export const DispatchPolicy = __t.object("DispatchPolicy", { + singleton: __t.bool(), + allowedRecipientsJson: __t.string(), + updatedAt: __t.timestamp(), +}); +export type DispatchPolicy = __Infer; + +export const DispatchSendResult = __t.object("DispatchSendResult", { + ok: __t.bool(), + resendId: __t.option(__t.string()), + message: __t.string(), +}); +export type DispatchSendResult = __Infer; + +// The tagged union or sum type for the algebraic type `EmailStatus`. +export const EmailStatus = __t.enum("EmailStatus", { + Queued: __t.unit(), + Sent: __t.unit(), + Delivered: __t.unit(), + DeliveryDelayed: __t.unit(), + Bounced: __t.unit(), + Failed: __t.unit(), + Cancelled: __t.unit(), +}); +export type EmailStatus = __Infer; + +export const MyDispatchDeliveryEvents = __t.object("MyDispatchDeliveryEvents", {}); +export type MyDispatchDeliveryEvents = __Infer; + +export const MyDispatchEmails = __t.object("MyDispatchEmails", {}); +export type MyDispatchEmails = __Infer; + +export const ResendDeliveryEvent = __t.object("ResendDeliveryEvent", { + eventId: __t.string(), + resendId: __t.string(), + eventType: __t.string(), + createdAtIso: __t.string(), + detailJson: __t.option(__t.string()), + insertedAt: __t.timestamp(), +}); +export type ResendDeliveryEvent = __Infer; + +export const ResendEmail = __t.object("ResendEmail", { + resendId: __t.string(), + fromAddress: __t.string(), + toAddressesJson: __t.string(), + subject: __t.option(__t.string()), + get status() { + return EmailStatus; + }, + lastError: __t.option(__t.string()), + bouncedAt: __t.option(__t.timestamp()), + bounceJson: __t.option(__t.string()), + failedAt: __t.option(__t.timestamp()), + failureReason: __t.option(__t.string()), + complained: __t.bool(), + complainedAt: __t.option(__t.timestamp()), + opened: __t.bool(), + openedAt: __t.option(__t.timestamp()), + clicked: __t.bool(), + clickedAt: __t.option(__t.timestamp()), + deliveredAt: __t.option(__t.timestamp()), + sentAt: __t.option(__t.timestamp()), + html: __t.option(__t.string()), + text: __t.option(__t.string()), + tagsJson: __t.option(__t.string()), + userId: __t.option(__t.string()), + orgId: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ResendEmail = __Infer; + diff --git a/spacetime-resend-ts/example/src/module_bindings/types/procedures.ts b/spacetime-resend-ts/example/src/module_bindings/types/procedures.ts new file mode 100644 index 00000000000..13622fae9a4 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/types/procedures.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as ClearDispatchesProcedure from "../clear_dispatches_procedure"; +import * as DeleteDispatchProcedure from "../delete_dispatch_procedure"; +import * as SendDispatchProcedure from "../send_dispatch_procedure"; +import * as SetDispatchPolicyProcedure from "../set_dispatch_policy_procedure"; + +export type ClearDispatchesArgs = __Infer; +export type ClearDispatchesResult = __Infer; +export type DeleteDispatchArgs = __Infer; +export type DeleteDispatchResult = __Infer; +export type SendDispatchArgs = __Infer; +export type SendDispatchResult = __Infer; +export type SetDispatchPolicyArgs = __Infer; +export type SetDispatchPolicyResult = __Infer; + diff --git a/spacetime-resend-ts/example/src/module_bindings/types/reducers.ts b/spacetime-resend-ts/example/src/module_bindings/types/reducers.ts new file mode 100644 index 00000000000..c701027cbf1 --- /dev/null +++ b/spacetime-resend-ts/example/src/module_bindings/types/reducers.ts @@ -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 + + diff --git a/spacetime-retry-ts/README.md b/spacetime-retry-ts/README.md index d2f6823df24..f988b7c4d28 100644 --- a/spacetime-retry-ts/README.md +++ b/spacetime-retry-ts/README.md @@ -55,10 +55,10 @@ const retry = createRetrySubmodule( const db = schema({ ...retry.tables }); const retryFire = db.reducer( + { onSchedule: retry.tables.retryTask }, { arg: retry.tables.retryTask.rowType }, retry.reducers.retryFire ); -retry.setRetryFireReducer(retryFire); export const submitRetryTask = db.reducer( retry.reducers.submitRetryTask.params, @@ -78,9 +78,7 @@ is scheduled immediately; subsequent delays are `backoffSecs * 2^attempt`. handler. The handler returns `retryOk()` or `retryFailed(error)`. - `makeRetryDispatch(handlers)` creates a typed tagged-union dispatcher. - `createRetrySubmodule(deps, handlers, auth?)` returns tables, enum helpers, - reducers, admin views, installation, and scheduled-reducer wiring. -- `setRetryFireReducer(reducer)` completes the scheduled-table forward - reference and must be called during module definition. + reducers, admin views, and installation. - `installRetry(ctx)` seeds the publishing identity as the initial admin. The generated client can submit a task when the host exports diff --git a/spacetime-retry-ts/spacetimedb/src/index.ts b/spacetime-retry-ts/spacetimedb/src/index.ts index 87f7bd94b70..cc2dcd32a02 100644 --- a/spacetime-retry-ts/spacetimedb/src/index.ts +++ b/spacetime-retry-ts/spacetimedb/src/index.ts @@ -108,10 +108,10 @@ export const init = spacetimedb.init(ctx => { }); export const retry_fire = spacetimedb.reducer( + { onSchedule: retryTask }, { arg: retryTask.rowType }, retry.reducers.retryFire ); -retry.setRetryFireReducer(retry_fire); export const submit_retry_task = spacetimedb.reducer( retry.reducers.submitRetryTask.params, diff --git a/spacetime-retry-ts/src/submodule.ts b/spacetime-retry-ts/src/submodule.ts index 5e5e4ee2203..8c3e79f7baa 100644 --- a/spacetime-retry-ts/src/submodule.ts +++ b/spacetime-retry-ts/src/submodule.ts @@ -30,17 +30,10 @@ export function createRetrySubmodule( const { table, t, SenderError, ScheduleAt } = deps; const retryArgs = t.enum('RetryArgs', handlers as unknown as VariantsObj); - let retryFireReducer: unknown; const retryTask = table( { name: 'retry_task', public: false, - scheduled: (): any => { - if (!retryFireReducer) { - throw new Error('retry.fire_reducer_not_registered'); - } - return retryFireReducer; - }, }, { scheduledId: t.u64().primaryKey().autoInc(), @@ -122,10 +115,6 @@ export function createRetrySubmodule( const dispatchRetry = makeRetryDispatch(handlers); - function setRetryFireReducer(reducer: unknown): void { - retryFireReducer = reducer; - } - function installRetry(ctx: unknown): void { const retryCtx = retryContext(ctx); if (retryCtx.db.retryAdminIdentity.identity.find(retryCtx.sender) == null) { @@ -315,7 +304,6 @@ export function createRetrySubmodule( retryArgs, retryHistoryStatus, RetryHistoryStatus, - setRetryFireReducer, installRetry, requireAdmin, views: { diff --git a/spacetime-stripe-ts/example/package.json b/spacetime-stripe-ts/example/package.json index c4fcc5bd399..2bf90708860 100644 --- a/spacetime-stripe-ts/example/package.json +++ b/spacetime-stripe-ts/example/package.json @@ -4,13 +4,12 @@ "private": true, "type": "module", "scripts": { - "build:codegen:app": "spacetime generate --lang typescript --out-dir src/codegen/app --module-path ./spacetimedb -y", - "build:codegen": "pnpm run build:codegen:app", - "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run build:codegen:app && pnpm run build:app", - "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run build:codegen:app && pnpm run build:app", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", - "build": "pnpm run build:codegen && pnpm run build:app", + "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, "dependencies": { diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts index 5659c26a959..0e01c46c393 100644 --- a/spacetime-stripe-ts/example/server.ts +++ b/spacetime-stripe-ts/example/server.ts @@ -4,7 +4,11 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { DbConnection, tables, type ErrorContext } from './src/codegen/app'; +import { + DbConnection, + tables, + type ErrorContext, +} from './src/module_bindings/app'; import { discardStoredServerToken, grantServerIdentity, diff --git a/spacetime-stripe-ts/example/src/app.ts b/spacetime-stripe-ts/example/src/app.ts index d7532d1e096..c82a08b1ad6 100644 --- a/spacetime-stripe-ts/example/src/app.ts +++ b/spacetime-stripe-ts/example/src/app.ts @@ -3,7 +3,7 @@ import { tables, type EventContext, type ErrorContext, -} from './codegen/app'; +} from './module_bindings/app'; declare global { interface Window { diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts new file mode 100644 index 00000000000..d564c4111fd --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts @@ -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 { + productId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts new file mode 100644 index 00000000000..e9fc16b27f5 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts @@ -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 const params = { + secretKey: __t.string(), + stripeVersion: __t.option(__t.string()), + webhookSigningSecret: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts new file mode 100644 index 00000000000..8ea8e71b13d --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts @@ -0,0 +1,30 @@ +// 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 { + StoreCheckoutLineItem, + StoreCheckoutSessionResult, +} from "./types"; + +export const params = { + get items() { + return __t.array(StoreCheckoutLineItem); + }, + customerId: __t.option(__t.string()), + mode: __t.string(), + successUrl: __t.string(), + cancelUrl: __t.string(), + metadataJson: __t.option(__t.string()), + subscriptionMetadataJson: __t.option(__t.string()), + paymentIntentMetadataJson: __t.option(__t.string()), +}; +export const returnType = StoreCheckoutSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts new file mode 100644 index 00000000000..70e89b7767d --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts @@ -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"; + +import { + StoreGetOrCreateCustomerResult, +} from "./types"; + +export const params = { + userId: __t.string(), + email: __t.option(__t.string()), + name: __t.option(__t.string()), +}; +export const returnType = StoreGetOrCreateCustomerResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts new file mode 100644 index 00000000000..c6724c84a1b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts @@ -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 const params = { +}; +export const returnType = __t.i64() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/index.ts b/spacetime-stripe-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..391e636eb1c --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,313 @@ +// 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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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 ClearStoreProductPriceReducer from "./clear_store_product_price_reducer"; +import SeedDefaultStoreProductsReducer from "./seed_default_store_products_reducer"; +import SetStoreProductPriceReducer from "./set_store_product_price_reducer"; +import UpsertStoreProductReducer from "./upsert_store_product_reducer"; + +// Import all procedure arg schemas +import * as AddAdminIdentityProcedure from "./add_admin_identity_procedure"; +import * as ConfigureStripeProcedure from "./configure_stripe_procedure"; +import * as CreateStoreCheckoutSessionProcedure from "./create_store_checkout_session_procedure"; +import * as GetOrCreateStoreCustomerProcedure from "./get_or_create_store_customer_procedure"; +import * as GetStoreWebhookEventCountProcedure from "./get_store_webhook_event_count_procedure"; +import * as ListStoreProductsJsonProcedure from "./list_store_products_json_procedure"; +import * as RemoveAdminIdentityProcedure from "./remove_admin_identity_procedure"; +import * as StoreStripeApiRequestProcedure from "./store_stripe_api_request_procedure"; +import * as SyncStoreProductsWithStripeProcedure from "./sync_store_products_with_stripe_procedure"; +import * as ValidateStoreStripePriceProcedure from "./validate_store_stripe_price_procedure"; + +// Import all table schema definitions +import StoreProductRow from "./store_product_table"; + +// Import namespace reducer arg schemas +import Stripe_IngestStripeWebhookReducer from "./stripe/ingest_stripe_webhook_reducer"; +import Stripe_ReplayWebhookEventReducer from "./stripe/replay_webhook_event_reducer"; +import Stripe_UpdatePaymentCustomerReducer from "./stripe/update_payment_customer_reducer"; +import Stripe_UpdateSubscriptionQuantityInternalReducer from "./stripe/update_subscription_quantity_internal_reducer"; +import Stripe_UpsertCustomerReducer from "./stripe/upsert_customer_reducer"; +import Stripe_UpsertSubscriptionReducer from "./stripe/upsert_subscription_reducer"; + +// Import namespace procedure arg schemas +import * as Stripe_AddAdminIdentityProcedure from "./stripe/add_admin_identity_procedure"; +import * as Stripe_CancelSubscriptionProcedure from "./stripe/cancel_subscription_procedure"; +import * as Stripe_CreateCheckoutSessionProcedure from "./stripe/create_checkout_session_procedure"; +import * as Stripe_CreateCustomerProcedure from "./stripe/create_customer_procedure"; +import * as Stripe_CreateCustomerPortalSessionProcedure from "./stripe/create_customer_portal_session_procedure"; +import * as Stripe_CreateOrUpdateCustomerProcedure from "./stripe/create_or_update_customer_procedure"; +import * as Stripe_GetCheckoutSessionProcedure from "./stripe/get_checkout_session_procedure"; +import * as Stripe_GetCustomerProcedure from "./stripe/get_customer_procedure"; +import * as Stripe_GetCustomerByEmailProcedure from "./stripe/get_customer_by_email_procedure"; +import * as Stripe_GetCustomerByUserIdProcedure from "./stripe/get_customer_by_user_id_procedure"; +import * as Stripe_GetOrCreateCustomerProcedure from "./stripe/get_or_create_customer_procedure"; +import * as Stripe_GetPaymentProcedure from "./stripe/get_payment_procedure"; +import * as Stripe_GetRemoteCheckoutSessionProcedure from "./stripe/get_remote_checkout_session_procedure"; +import * as Stripe_GetStripeConfigStatusProcedure from "./stripe/get_stripe_config_status_procedure"; +import * as Stripe_GetSubscriptionProcedure from "./stripe/get_subscription_procedure"; +import * as Stripe_GetSubscriptionByOrgIdProcedure from "./stripe/get_subscription_by_org_id_procedure"; +import * as Stripe_GetWebhookEventCountProcedure from "./stripe/get_webhook_event_count_procedure"; +import * as Stripe_ListCheckoutSessionsProcedure from "./stripe/list_checkout_sessions_procedure"; +import * as Stripe_ListInvoicesProcedure from "./stripe/list_invoices_procedure"; +import * as Stripe_ListInvoicesByOrgIdProcedure from "./stripe/list_invoices_by_org_id_procedure"; +import * as Stripe_ListInvoicesByUserIdProcedure from "./stripe/list_invoices_by_user_id_procedure"; +import * as Stripe_ListPaymentsProcedure from "./stripe/list_payments_procedure"; +import * as Stripe_ListPaymentsByOrgIdProcedure from "./stripe/list_payments_by_org_id_procedure"; +import * as Stripe_ListPaymentsByUserIdProcedure from "./stripe/list_payments_by_user_id_procedure"; +import * as Stripe_ListSubscriptionsProcedure from "./stripe/list_subscriptions_procedure"; +import * as Stripe_ListSubscriptionsByOrgIdProcedure from "./stripe/list_subscriptions_by_org_id_procedure"; +import * as Stripe_ListSubscriptionsByUserIdProcedure from "./stripe/list_subscriptions_by_user_id_procedure"; +import * as Stripe_ListSubscriptionsWithCreationTimeProcedure from "./stripe/list_subscriptions_with_creation_time_procedure"; +import * as Stripe_ReactivateSubscriptionProcedure from "./stripe/reactivate_subscription_procedure"; +import * as Stripe_RemoveAdminIdentityProcedure from "./stripe/remove_admin_identity_procedure"; +import * as Stripe_SetStripeConfigProcedure from "./stripe/set_stripe_config_procedure"; +import * as Stripe_SetStripeWebhookSigningSecretProcedure from "./stripe/set_stripe_webhook_signing_secret_procedure"; +import * as Stripe_StripeApiRequestProcedure from "./stripe/stripe_api_request_procedure"; +import * as Stripe_UpdateSubscriptionMetadataProcedure from "./stripe/update_subscription_metadata_procedure"; +import * as Stripe_UpdateSubscriptionQuantityProcedure from "./stripe/update_subscription_quantity_procedure"; +import * as Stripe_ValidateStripePriceProcedure from "./stripe/validate_stripe_price_procedure"; + +/** 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({ + storeProduct: __table({ + name: 'store_product', + indexes: [ + { accessor: 'byActiveSort', name: 'store_product_active_sort_order_product_id_idx_btree', algorithm: 'btree', columns: [ + 'active', + 'sortOrder', + 'productId', + ] }, + { accessor: 'byModeSort', name: 'store_product_mode_sort_order_product_id_idx_btree', algorithm: 'btree', columns: [ + 'mode', + 'sortOrder', + 'productId', + ] }, + { accessor: 'productId', name: 'store_product_product_id_idx_btree', algorithm: 'btree', columns: [ + 'productId', + ] }, + { accessor: 'byStripePriceId', name: 'store_product_stripe_price_id_idx_btree', algorithm: 'btree', columns: [ + 'stripePriceId', + ] }, + ], + constraints: [ + { name: 'store_product_product_id_key', constraint: 'unique', columns: ['productId'] }, + ], + }, StoreProductRow), +}); + +/** 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( + __reducerSchema("clear_store_product_price", ClearStoreProductPriceReducer), + __reducerSchema("seed_default_store_products", SeedDefaultStoreProductsReducer), + __reducerSchema("set_store_product_price", SetStoreProductPriceReducer), + __reducerSchema("upsert_store_product", UpsertStoreProductReducer), + __reducerSchema("stripe.ingest_stripe_webhook", Stripe_IngestStripeWebhookReducer), + __reducerSchema("stripe.replay_webhook_event", Stripe_ReplayWebhookEventReducer), + __reducerSchema("stripe.update_payment_customer", Stripe_UpdatePaymentCustomerReducer), + __reducerSchema("stripe.update_subscription_quantity_internal", Stripe_UpdateSubscriptionQuantityInternalReducer), + __reducerSchema("stripe.upsert_customer", Stripe_UpsertCustomerReducer), + __reducerSchema("stripe.upsert_subscription", Stripe_UpsertSubscriptionReducer), +); + +/** 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("add_admin_identity", AddAdminIdentityProcedure.params, AddAdminIdentityProcedure.returnType), + __procedureSchema("configure_stripe", ConfigureStripeProcedure.params, ConfigureStripeProcedure.returnType), + __procedureSchema("create_store_checkout_session", CreateStoreCheckoutSessionProcedure.params, CreateStoreCheckoutSessionProcedure.returnType), + __procedureSchema("get_or_create_store_customer", GetOrCreateStoreCustomerProcedure.params, GetOrCreateStoreCustomerProcedure.returnType), + __procedureSchema("get_store_webhook_event_count", GetStoreWebhookEventCountProcedure.params, GetStoreWebhookEventCountProcedure.returnType), + __procedureSchema("list_store_products_json", ListStoreProductsJsonProcedure.params, ListStoreProductsJsonProcedure.returnType), + __procedureSchema("remove_admin_identity", RemoveAdminIdentityProcedure.params, RemoveAdminIdentityProcedure.returnType), + __procedureSchema("store_stripe_api_request", StoreStripeApiRequestProcedure.params, StoreStripeApiRequestProcedure.returnType), + __procedureSchema("sync_store_products_with_stripe", SyncStoreProductsWithStripeProcedure.params, SyncStoreProductsWithStripeProcedure.returnType), + __procedureSchema("validate_store_stripe_price", ValidateStoreStripePriceProcedure.params, ValidateStoreStripePriceProcedure.returnType), + __procedureSchema("stripe.add_admin_identity", Stripe_AddAdminIdentityProcedure.params, Stripe_AddAdminIdentityProcedure.returnType), + __procedureSchema("stripe.cancel_subscription", Stripe_CancelSubscriptionProcedure.params, Stripe_CancelSubscriptionProcedure.returnType), + __procedureSchema("stripe.create_checkout_session", Stripe_CreateCheckoutSessionProcedure.params, Stripe_CreateCheckoutSessionProcedure.returnType), + __procedureSchema("stripe.create_customer", Stripe_CreateCustomerProcedure.params, Stripe_CreateCustomerProcedure.returnType), + __procedureSchema("stripe.create_customer_portal_session", Stripe_CreateCustomerPortalSessionProcedure.params, Stripe_CreateCustomerPortalSessionProcedure.returnType), + __procedureSchema("stripe.create_or_update_customer", Stripe_CreateOrUpdateCustomerProcedure.params, Stripe_CreateOrUpdateCustomerProcedure.returnType), + __procedureSchema("stripe.get_checkout_session", Stripe_GetCheckoutSessionProcedure.params, Stripe_GetCheckoutSessionProcedure.returnType), + __procedureSchema("stripe.get_customer", Stripe_GetCustomerProcedure.params, Stripe_GetCustomerProcedure.returnType), + __procedureSchema("stripe.get_customer_by_email", Stripe_GetCustomerByEmailProcedure.params, Stripe_GetCustomerByEmailProcedure.returnType), + __procedureSchema("stripe.get_customer_by_user_id", Stripe_GetCustomerByUserIdProcedure.params, Stripe_GetCustomerByUserIdProcedure.returnType), + __procedureSchema("stripe.get_or_create_customer", Stripe_GetOrCreateCustomerProcedure.params, Stripe_GetOrCreateCustomerProcedure.returnType), + __procedureSchema("stripe.get_payment", Stripe_GetPaymentProcedure.params, Stripe_GetPaymentProcedure.returnType), + __procedureSchema("stripe.get_remote_checkout_session", Stripe_GetRemoteCheckoutSessionProcedure.params, Stripe_GetRemoteCheckoutSessionProcedure.returnType), + __procedureSchema("stripe.get_stripe_config_status", Stripe_GetStripeConfigStatusProcedure.params, Stripe_GetStripeConfigStatusProcedure.returnType), + __procedureSchema("stripe.get_subscription", Stripe_GetSubscriptionProcedure.params, Stripe_GetSubscriptionProcedure.returnType), + __procedureSchema("stripe.get_subscription_by_org_id", Stripe_GetSubscriptionByOrgIdProcedure.params, Stripe_GetSubscriptionByOrgIdProcedure.returnType), + __procedureSchema("stripe.get_webhook_event_count", Stripe_GetWebhookEventCountProcedure.params, Stripe_GetWebhookEventCountProcedure.returnType), + __procedureSchema("stripe.list_checkout_sessions", Stripe_ListCheckoutSessionsProcedure.params, Stripe_ListCheckoutSessionsProcedure.returnType), + __procedureSchema("stripe.list_invoices", Stripe_ListInvoicesProcedure.params, Stripe_ListInvoicesProcedure.returnType), + __procedureSchema("stripe.list_invoices_by_org_id", Stripe_ListInvoicesByOrgIdProcedure.params, Stripe_ListInvoicesByOrgIdProcedure.returnType), + __procedureSchema("stripe.list_invoices_by_user_id", Stripe_ListInvoicesByUserIdProcedure.params, Stripe_ListInvoicesByUserIdProcedure.returnType), + __procedureSchema("stripe.list_payments", Stripe_ListPaymentsProcedure.params, Stripe_ListPaymentsProcedure.returnType), + __procedureSchema("stripe.list_payments_by_org_id", Stripe_ListPaymentsByOrgIdProcedure.params, Stripe_ListPaymentsByOrgIdProcedure.returnType), + __procedureSchema("stripe.list_payments_by_user_id", Stripe_ListPaymentsByUserIdProcedure.params, Stripe_ListPaymentsByUserIdProcedure.returnType), + __procedureSchema("stripe.list_subscriptions", Stripe_ListSubscriptionsProcedure.params, Stripe_ListSubscriptionsProcedure.returnType), + __procedureSchema("stripe.list_subscriptions_by_org_id", Stripe_ListSubscriptionsByOrgIdProcedure.params, Stripe_ListSubscriptionsByOrgIdProcedure.returnType), + __procedureSchema("stripe.list_subscriptions_by_user_id", Stripe_ListSubscriptionsByUserIdProcedure.params, Stripe_ListSubscriptionsByUserIdProcedure.returnType), + __procedureSchema("stripe.list_subscriptions_with_creation_time", Stripe_ListSubscriptionsWithCreationTimeProcedure.params, Stripe_ListSubscriptionsWithCreationTimeProcedure.returnType), + __procedureSchema("stripe.reactivate_subscription", Stripe_ReactivateSubscriptionProcedure.params, Stripe_ReactivateSubscriptionProcedure.returnType), + __procedureSchema("stripe.remove_admin_identity", Stripe_RemoveAdminIdentityProcedure.params, Stripe_RemoveAdminIdentityProcedure.returnType), + __procedureSchema("stripe.set_stripe_config", Stripe_SetStripeConfigProcedure.params, Stripe_SetStripeConfigProcedure.returnType), + __procedureSchema("stripe.set_stripe_webhook_signing_secret", Stripe_SetStripeWebhookSigningSecretProcedure.params, Stripe_SetStripeWebhookSigningSecretProcedure.returnType), + __procedureSchema("stripe.stripe_api_request", Stripe_StripeApiRequestProcedure.params, Stripe_StripeApiRequestProcedure.returnType), + __procedureSchema("stripe.update_subscription_metadata", Stripe_UpdateSubscriptionMetadataProcedure.params, Stripe_UpdateSubscriptionMetadataProcedure.returnType), + __procedureSchema("stripe.update_subscription_quantity", Stripe_UpdateSubscriptionQuantityProcedure.params, Stripe_UpdateSubscriptionQuantityProcedure.returnType), + __procedureSchema("stripe.validate_stripe_price", Stripe_ValidateStripePriceProcedure.params, Stripe_ValidateStripePriceProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" 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 = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + clearStoreProductPrice: __reducerAccessors.clearStoreProductPrice, + seedDefaultStoreProducts: __reducerAccessors.seedDefaultStoreProducts, + setStoreProductPrice: __reducerAccessors.setStoreProductPrice, + upsertStoreProduct: __reducerAccessors.upsertStoreProduct, + stripe: { + ingestStripeWebhook: __reducerAccessors["stripe.ingestStripeWebhook"], + replayWebhookEvent: __reducerAccessors["stripe.replayWebhookEvent"], + updatePaymentCustomer: __reducerAccessors["stripe.updatePaymentCustomer"], + updateSubscriptionQuantityInternal: __reducerAccessors["stripe.updateSubscriptionQuantityInternal"], + upsertCustomer: __reducerAccessors["stripe.upsertCustomer"], + upsertSubscription: __reducerAccessors["stripe.upsertSubscription"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + addAdminIdentity: __procedureAccessors.addAdminIdentity, + configureStripe: __procedureAccessors.configureStripe, + createStoreCheckoutSession: __procedureAccessors.createStoreCheckoutSession, + getOrCreateStoreCustomer: __procedureAccessors.getOrCreateStoreCustomer, + getStoreWebhookEventCount: __procedureAccessors.getStoreWebhookEventCount, + listStoreProductsJson: __procedureAccessors.listStoreProductsJson, + removeAdminIdentity: __procedureAccessors.removeAdminIdentity, + storeStripeApiRequest: __procedureAccessors.storeStripeApiRequest, + syncStoreProductsWithStripe: __procedureAccessors.syncStoreProductsWithStripe, + validateStoreStripePrice: __procedureAccessors.validateStoreStripePrice, + stripe: { + addAdminIdentity: __procedureAccessors["stripe.addAdminIdentity"], + cancelSubscription: __procedureAccessors["stripe.cancelSubscription"], + createCheckoutSession: __procedureAccessors["stripe.createCheckoutSession"], + createCustomer: __procedureAccessors["stripe.createCustomer"], + createCustomerPortalSession: __procedureAccessors["stripe.createCustomerPortalSession"], + createOrUpdateCustomer: __procedureAccessors["stripe.createOrUpdateCustomer"], + getCheckoutSession: __procedureAccessors["stripe.getCheckoutSession"], + getCustomer: __procedureAccessors["stripe.getCustomer"], + getCustomerByEmail: __procedureAccessors["stripe.getCustomerByEmail"], + getCustomerByUserId: __procedureAccessors["stripe.getCustomerByUserId"], + getOrCreateCustomer: __procedureAccessors["stripe.getOrCreateCustomer"], + getPayment: __procedureAccessors["stripe.getPayment"], + getRemoteCheckoutSession: __procedureAccessors["stripe.getRemoteCheckoutSession"], + getStripeConfigStatus: __procedureAccessors["stripe.getStripeConfigStatus"], + getSubscription: __procedureAccessors["stripe.getSubscription"], + getSubscriptionByOrgId: __procedureAccessors["stripe.getSubscriptionByOrgId"], + getWebhookEventCount: __procedureAccessors["stripe.getWebhookEventCount"], + listCheckoutSessions: __procedureAccessors["stripe.listCheckoutSessions"], + listInvoices: __procedureAccessors["stripe.listInvoices"], + listInvoicesByOrgId: __procedureAccessors["stripe.listInvoicesByOrgId"], + listInvoicesByUserId: __procedureAccessors["stripe.listInvoicesByUserId"], + listPayments: __procedureAccessors["stripe.listPayments"], + listPaymentsByOrgId: __procedureAccessors["stripe.listPaymentsByOrgId"], + listPaymentsByUserId: __procedureAccessors["stripe.listPaymentsByUserId"], + listSubscriptions: __procedureAccessors["stripe.listSubscriptions"], + listSubscriptionsByOrgId: __procedureAccessors["stripe.listSubscriptionsByOrgId"], + listSubscriptionsByUserId: __procedureAccessors["stripe.listSubscriptionsByUserId"], + listSubscriptionsWithCreationTime: __procedureAccessors["stripe.listSubscriptionsWithCreationTime"], + reactivateSubscription: __procedureAccessors["stripe.reactivateSubscription"], + removeAdminIdentity: __procedureAccessors["stripe.removeAdminIdentity"], + setStripeConfig: __procedureAccessors["stripe.setStripeConfig"], + setStripeWebhookSigningSecret: __procedureAccessors["stripe.setStripeWebhookSigningSecret"], + stripeApiRequest: __procedureAccessors["stripe.stripeApiRequest"], + updateSubscriptionMetadata: __procedureAccessors["stripe.updateSubscriptionMetadata"], + updateSubscriptionQuantity: __procedureAccessors["stripe.updateSubscriptionQuantity"], + validateStripePrice: __procedureAccessors["stripe.validateStripePrice"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** 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 { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts @@ -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 const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts new file mode 100644 index 00000000000..c338c0c1984 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts @@ -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 { + force: __t.option(__t.bool()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts new file mode 100644 index 00000000000..ed82e1f1eb8 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + productId: __t.string(), + stripePriceId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts b/spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts new file mode 100644 index 00000000000..a566792da32 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts @@ -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"; + +export default __t.row({ + productId: __t.string().primaryKey().name("product_id"), + name: __t.string(), + description: __t.string(), + mode: __t.string(), + priceLabel: __t.string().name("price_label"), + stripePriceId: __t.option(__t.string()).name("stripe_price_id"), + perksJson: __t.option(__t.string()).name("perks_json"), + active: __t.bool(), + sortOrder: __t.i64().name("sort_order"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts new file mode 100644 index 00000000000..403004799f9 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts @@ -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 { + StoreStripeHttpResponse, +} from "./types"; + +export const params = { + method: __t.string(), + path: __t.string(), + formBody: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = StoreStripeHttpResponse \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts new file mode 100644 index 00000000000..d59e794b433 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts @@ -0,0 +1,17 @@ +// 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 const params = { + stripeSubscriptionId: __t.string(), + cancelAtPeriodEnd: __t.option(__t.bool()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts new file mode 100644 index 00000000000..b3e52b631f8 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts @@ -0,0 +1,30 @@ +// 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 { + CheckoutLineItem, + CheckoutSessionResult, +} from "./types"; + +export const params = { + get items() { + return __t.array(CheckoutLineItem); + }, + customerId: __t.option(__t.string()), + mode: __t.string(), + successUrl: __t.string(), + cancelUrl: __t.string(), + metadataJson: __t.option(__t.string()), + subscriptionMetadataJson: __t.option(__t.string()), + paymentIntentMetadataJson: __t.option(__t.string()), +}; +export const returnType = CheckoutSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts new file mode 100644 index 00000000000..0fc0f271832 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts @@ -0,0 +1,21 @@ +// 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 { + PortalSessionResult, +} from "./types"; + +export const params = { + customerId: __t.string(), + returnUrl: __t.string(), +}; +export const returnType = PortalSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts new file mode 100644 index 00000000000..ce18664af55 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts @@ -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 { + CreateCustomerResult, +} from "./types"; + +export const params = { + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = CreateCustomerResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts new file mode 100644 index 00000000000..6f40113aa3c --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts @@ -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 const params = { + stripeCustomerId: __t.string(), + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts new file mode 100644 index 00000000000..7cfe49f0fef --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts @@ -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"; + +import { + StripeCheckoutSession, +} from "./types"; + +export const params = { + stripeCheckoutSessionId: __t.string(), +}; +export const returnType = __t.option(StripeCheckoutSession) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts new file mode 100644 index 00000000000..c385e289a71 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts @@ -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"; + +import { + StripeCustomer, +} from "./types"; + +export const params = { + email: __t.string(), +}; +export const returnType = __t.option(StripeCustomer) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts new file mode 100644 index 00000000000..11e4cd18c67 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts @@ -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"; + +import { + StripeCustomer, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.option(StripeCustomer) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts new file mode 100644 index 00000000000..74f769984fa --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts @@ -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"; + +import { + StripeCustomer, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.option(StripeCustomer) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts new file mode 100644 index 00000000000..1f6ece8f9e7 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts @@ -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"; + +import { + GetOrCreateCustomerResult, +} from "./types"; + +export const params = { + userId: __t.string(), + email: __t.option(__t.string()), + name: __t.option(__t.string()), +}; +export const returnType = GetOrCreateCustomerResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts new file mode 100644 index 00000000000..4468f783e36 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts @@ -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"; + +import { + StripePayment, +} from "./types"; + +export const params = { + stripePaymentIntentId: __t.string(), +}; +export const returnType = __t.option(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts new file mode 100644 index 00000000000..8b01ada5924 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts @@ -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"; + +import { + RemoteCheckoutSessionResult, +} from "./types"; + +export const params = { + sessionId: __t.string(), +}; +export const returnType = RemoteCheckoutSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts new file mode 100644 index 00000000000..38405892656 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts @@ -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"; + +import { + StripeConfigStatus, +} from "./types"; + +export const params = { +}; +export const returnType = StripeConfigStatus \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts new file mode 100644 index 00000000000..ed0cbdf5244 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts @@ -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"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.option(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts new file mode 100644 index 00000000000..fe90f17b760 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts @@ -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"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + stripeSubscriptionId: __t.string(), +}; +export const returnType = __t.option(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts new file mode 100644 index 00000000000..c6724c84a1b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts @@ -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 const params = { +}; +export const returnType = __t.i64() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts new file mode 100644 index 00000000000..e27fe18a79e --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts @@ -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 { + eventId: __t.string(), + eventType: __t.string(), + livemode: __t.bool(), + payloadJson: __t.string(), + signatureHeader: __t.option(__t.string()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts new file mode 100644 index 00000000000..a378145917a --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts @@ -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"; + +import { + StripeCheckoutSession, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripeCheckoutSession) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts new file mode 100644 index 00000000000..e4c68004285 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts @@ -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"; + +import { + StripeInvoice, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(StripeInvoice) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts new file mode 100644 index 00000000000..2c263236bc2 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts @@ -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"; + +import { + StripeInvoice, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(StripeInvoice) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts new file mode 100644 index 00000000000..76a084f415f --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts @@ -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"; + +import { + StripeInvoice, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripeInvoice) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts new file mode 100644 index 00000000000..67186316774 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts @@ -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"; + +import { + StripePayment, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts new file mode 100644 index 00000000000..e1f5a7244f9 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts @@ -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"; + +import { + StripePayment, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts new file mode 100644 index 00000000000..aa6f5dde97b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts @@ -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"; + +import { + StripePayment, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts new file mode 100644 index 00000000000..0a31e4dc4a0 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts @@ -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"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts new file mode 100644 index 00000000000..5c71c9018aa --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts @@ -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"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts new file mode 100644 index 00000000000..cd3f6d99e0a --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts @@ -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"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts new file mode 100644 index 00000000000..01b17ab972b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts @@ -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"; + +import { + SubscriptionWithCreationTime, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(SubscriptionWithCreationTime) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts new file mode 100644 index 00000000000..c4f6413f649 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + stripeSubscriptionId: __t.string(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts new file mode 100644 index 00000000000..590a453de82 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts @@ -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 { + eventId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts new file mode 100644 index 00000000000..e9fc16b27f5 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts @@ -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 const params = { + secretKey: __t.string(), + stripeVersion: __t.option(__t.string()), + webhookSigningSecret: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts new file mode 100644 index 00000000000..1ea293a0540 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts @@ -0,0 +1,16 @@ +// 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 const params = { + webhookSigningSecret: __t.string(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts new file mode 100644 index 00000000000..84d6bbac6f4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts @@ -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 { + StripeHttpResponse, +} from "./types"; + +export const params = { + method: __t.string(), + path: __t.string(), + formBody: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = StripeHttpResponse \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts new file mode 100644 index 00000000000..8ba127bc1c1 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts @@ -0,0 +1,202 @@ +// 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 const CheckoutLineItem = __t.object("CheckoutLineItem", { + priceId: __t.string(), + quantity: __t.i64(), +}); +export type CheckoutLineItem = __Infer; + +export const CheckoutSessionResult = __t.object("CheckoutSessionResult", { + sessionId: __t.string(), + url: __t.option(__t.string()), +}); +export type CheckoutSessionResult = __Infer; + +export const CreateCustomerResult = __t.object("CreateCustomerResult", { + customerId: __t.string(), +}); +export type CreateCustomerResult = __Infer; + +export const GetOrCreateCustomerResult = __t.object("GetOrCreateCustomerResult", { + customerId: __t.string(), + isNew: __t.bool(), +}); +export type GetOrCreateCustomerResult = __Infer; + +export const PortalSessionResult = __t.object("PortalSessionResult", { + url: __t.string(), +}); +export type PortalSessionResult = __Infer; + +export const RemoteCheckoutSessionResult = __t.object("RemoteCheckoutSessionResult", { + ok: __t.bool(), + status: __t.u16(), + sessionId: __t.option(__t.string()), + paymentStatus: __t.option(__t.string()), + sessionStatus: __t.option(__t.string()), + mode: __t.option(__t.string()), + amountTotal: __t.option(__t.i64()), + currency: __t.option(__t.string()), + customerId: __t.option(__t.string()), + paymentIntentId: __t.option(__t.string()), + message: __t.option(__t.string()), + code: __t.option(__t.string()), + errorType: __t.option(__t.string()), +}); +export type RemoteCheckoutSessionResult = __Infer; + +export const StripeAdminIdentity = __t.object("StripeAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type StripeAdminIdentity = __Infer; + +export const StripeCheckoutSession = __t.object("StripeCheckoutSession", { + stripeCheckoutSessionId: __t.string(), + stripeCustomerId: __t.option(__t.string()), + status: __t.string(), + mode: __t.string(), + metadataJson: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeCheckoutSession = __Infer; + +export const StripeConfig = __t.object("StripeConfig", { + singleton: __t.bool(), + secretKey: __t.string(), + stripeVersion: __t.option(__t.string()), + webhookSigningSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type StripeConfig = __Infer; + +export const StripeConfigStatus = __t.object("StripeConfigStatus", { + isConfigured: __t.bool(), + hasWebhookSecret: __t.bool(), + stripeVersion: __t.option(__t.string()), + secretKeyLength: __t.u16(), +}); +export type StripeConfigStatus = __Infer; + +export const StripeCustomer = __t.object("StripeCustomer", { + stripeCustomerId: __t.string(), + appUserId: __t.option(__t.string()), + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + userId: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeCustomer = __Infer; + +export const StripeHttpResponse = __t.object("StripeHttpResponse", { + status: __t.u16(), + body: __t.string(), +}); +export type StripeHttpResponse = __Infer; + +export const StripeInvoice = __t.object("StripeInvoice", { + stripeInvoiceId: __t.string(), + stripeCustomerId: __t.string(), + stripeSubscriptionId: __t.option(__t.string()), + status: __t.string(), + amountDue: __t.i64(), + amountPaid: __t.i64(), + createdUnix: __t.i64(), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeInvoice = __Infer; + +export const StripePayment = __t.object("StripePayment", { + stripePaymentIntentId: __t.string(), + stripeCustomerId: __t.option(__t.string()), + amount: __t.i64(), + currency: __t.string(), + status: __t.string(), + createdUnix: __t.i64(), + metadataJson: __t.option(__t.string()), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripePayment = __Infer; + +export const StripeSubscription = __t.object("StripeSubscription", { + stripeSubscriptionId: __t.string(), + stripeCustomerId: __t.string(), + status: __t.string(), + currentPeriodEndUnix: __t.i64(), + cancelAtPeriodEnd: __t.bool(), + cancelAtUnix: __t.option(__t.i64()), + quantity: __t.option(__t.i64()), + priceId: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeSubscription = __Infer; + +export const StripeWebhookEvent = __t.object("StripeWebhookEvent", { + eventId: __t.string(), + eventType: __t.string(), + livemode: __t.bool(), + signatureHeader: __t.option(__t.string()), + payloadJson: __t.string(), + get status() { + return WebhookEventStatus; + }, + errorMessage: __t.option(__t.string()), + receivedAt: __t.timestamp(), + processedAt: __t.option(__t.timestamp()), +}); +export type StripeWebhookEvent = __Infer; + +export const SubscriptionWithCreationTime = __t.object("SubscriptionWithCreationTime", { + insertedAtMicros: __t.i64(), + stripeSubscriptionId: __t.string(), + stripeCustomerId: __t.string(), + status: __t.string(), +}); +export type SubscriptionWithCreationTime = __Infer; + +export const ValidateStripePriceResult = __t.object("ValidateStripePriceResult", { + valid: __t.bool(), + status: __t.u16(), + active: __t.option(__t.bool()), + currency: __t.option(__t.string()), + unitAmount: __t.option(__t.i64()), + livemode: __t.option(__t.bool()), + type: __t.option(__t.string()), + message: __t.option(__t.string()), + code: __t.option(__t.string()), + errorType: __t.option(__t.string()), +}); +export type ValidateStripePriceResult = __Infer; + +// The tagged union or sum type for the algebraic type `WebhookEventStatus`. +export const WebhookEventStatus = __t.enum("WebhookEventStatus", { + Received: __t.unit(), + Processed: __t.unit(), + Ignored: __t.unit(), + Failed: __t.unit(), +}); +export type WebhookEventStatus = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts new file mode 100644 index 00000000000..474aa9ddead --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + stripePaymentIntentId: __t.string(), + stripeCustomerId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts new file mode 100644 index 00000000000..519234571e9 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts @@ -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 const params = { + stripeSubscriptionId: __t.string(), + metadataJson: __t.string(), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts new file mode 100644 index 00000000000..88e2ca53849 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts @@ -0,0 +1,16 @@ +// 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 { + stripeSubscriptionId: __t.string(), + quantity: __t.i64(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts new file mode 100644 index 00000000000..3db95ceaa68 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts @@ -0,0 +1,17 @@ +// 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 const params = { + stripeSubscriptionId: __t.string(), + quantity: __t.i64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts new file mode 100644 index 00000000000..d572ac2d9ac --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts @@ -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 { + stripeCustomerId: __t.string(), + appUserId: __t.option(__t.string()), + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + userId: __t.option(__t.string()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts new file mode 100644 index 00000000000..bf4b5c3741b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts @@ -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"; + +export default { + stripeSubscriptionId: __t.string(), + stripeCustomerId: __t.string(), + status: __t.string(), + currentPeriodEndUnix: __t.i64(), + cancelAtPeriodEnd: __t.bool(), + cancelAtUnix: __t.option(__t.i64()), + quantity: __t.option(__t.i64()), + priceId: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts new file mode 100644 index 00000000000..c6e5ab54119 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts @@ -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"; + +import { + ValidateStripePriceResult, +} from "./types"; + +export const params = { + priceId: __t.string(), +}; +export const returnType = ValidateStripePriceResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts @@ -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 const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/types.ts b/spacetime-stripe-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..c3985719fc6 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,71 @@ +// 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 const StoreAdminIdentity = __t.object("StoreAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type StoreAdminIdentity = __Infer; + +export const StoreCheckoutLineItem = __t.object("StoreCheckoutLineItem", { + priceId: __t.string(), + quantity: __t.i64(), +}); +export type StoreCheckoutLineItem = __Infer; + +export const StoreCheckoutSessionResult = __t.object("StoreCheckoutSessionResult", { + sessionId: __t.string(), + url: __t.option(__t.string()), +}); +export type StoreCheckoutSessionResult = __Infer; + +export const StoreGetOrCreateCustomerResult = __t.object("StoreGetOrCreateCustomerResult", { + customerId: __t.string(), + isNew: __t.bool(), +}); +export type StoreGetOrCreateCustomerResult = __Infer; + +export const StoreProduct = __t.object("StoreProduct", { + productId: __t.string(), + name: __t.string(), + description: __t.string(), + mode: __t.string(), + priceLabel: __t.string(), + stripePriceId: __t.option(__t.string()), + perksJson: __t.option(__t.string()), + active: __t.bool(), + sortOrder: __t.i64(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StoreProduct = __Infer; + +export const StoreStripeHttpResponse = __t.object("StoreStripeHttpResponse", { + status: __t.u16(), + body: __t.string(), +}); +export type StoreStripeHttpResponse = __Infer; + +export const StoreValidateStripePriceResult = __t.object("StoreValidateStripePriceResult", { + valid: __t.bool(), + status: __t.u16(), + active: __t.option(__t.bool()), + currency: __t.option(__t.string()), + unitAmount: __t.option(__t.i64()), + livemode: __t.option(__t.bool()), + type: __t.option(__t.string()), + message: __t.option(__t.string()), + code: __t.option(__t.string()), + errorType: __t.option(__t.string()), +}); +export type StoreValidateStripePriceResult = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..d734e416b32 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,40 @@ +// 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 AddAdminIdentityProcedure from "../add_admin_identity_procedure"; +import * as ConfigureStripeProcedure from "../configure_stripe_procedure"; +import * as CreateStoreCheckoutSessionProcedure from "../create_store_checkout_session_procedure"; +import * as GetOrCreateStoreCustomerProcedure from "../get_or_create_store_customer_procedure"; +import * as GetStoreWebhookEventCountProcedure from "../get_store_webhook_event_count_procedure"; +import * as ListStoreProductsJsonProcedure from "../list_store_products_json_procedure"; +import * as RemoveAdminIdentityProcedure from "../remove_admin_identity_procedure"; +import * as StoreStripeApiRequestProcedure from "../store_stripe_api_request_procedure"; +import * as SyncStoreProductsWithStripeProcedure from "../sync_store_products_with_stripe_procedure"; +import * as ValidateStoreStripePriceProcedure from "../validate_store_stripe_price_procedure"; + +export type AddAdminIdentityArgs = __Infer; +export type AddAdminIdentityResult = __Infer; +export type ConfigureStripeArgs = __Infer; +export type ConfigureStripeResult = __Infer; +export type CreateStoreCheckoutSessionArgs = __Infer; +export type CreateStoreCheckoutSessionResult = __Infer; +export type GetOrCreateStoreCustomerArgs = __Infer; +export type GetOrCreateStoreCustomerResult = __Infer; +export type GetStoreWebhookEventCountArgs = __Infer; +export type GetStoreWebhookEventCountResult = __Infer; +export type ListStoreProductsJsonArgs = __Infer; +export type ListStoreProductsJsonResult = __Infer; +export type RemoveAdminIdentityArgs = __Infer; +export type RemoveAdminIdentityResult = __Infer; +export type StoreStripeApiRequestArgs = __Infer; +export type StoreStripeApiRequestResult = __Infer; +export type SyncStoreProductsWithStripeArgs = __Infer; +export type SyncStoreProductsWithStripeResult = __Infer; +export type ValidateStoreStripePriceArgs = __Infer; +export type ValidateStoreStripePriceResult = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..3e4ef971003 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts @@ -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 { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import ClearStoreProductPriceReducer from "../clear_store_product_price_reducer"; +import SeedDefaultStoreProductsReducer from "../seed_default_store_products_reducer"; +import SetStoreProductPriceReducer from "../set_store_product_price_reducer"; +import UpsertStoreProductReducer from "../upsert_store_product_reducer"; + +export type ClearStoreProductPriceParams = __Infer; +export type SeedDefaultStoreProductsParams = __Infer; +export type SetStoreProductPriceParams = __Infer; +export type UpsertStoreProductParams = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts new file mode 100644 index 00000000000..fb8d352441e --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts @@ -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 { + productId: __t.string(), + name: __t.string(), + description: __t.string(), + mode: __t.string(), + priceLabel: __t.string(), + stripePriceId: __t.option(__t.string()), + perksJson: __t.option(__t.string()), + active: __t.option(__t.bool()), + sortOrder: __t.option(__t.i64()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts new file mode 100644 index 00000000000..fe8916221d8 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts @@ -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"; + +import { + StoreValidateStripePriceResult, +} from "./types"; + +export const params = { + priceId: __t.string(), +}; +export const returnType = StoreValidateStripePriceResult \ No newline at end of file diff --git a/tools/run-example-smokes.mjs b/tools/run-example-smokes.mjs index 5f6c0d73c51..e5f4d19a01f 100644 --- a/tools/run-example-smokes.mjs +++ b/tools/run-example-smokes.mjs @@ -608,7 +608,7 @@ async function smoke(example, browser) { ], { inherit: true } ); - run(pnpmCommand, ['--dir', example.dir, 'run', 'build:codegen'], { + run(pnpmCommand, ['--dir', example.dir, 'run', 'spacetime:generate'], { inherit: true, }); run(pnpmCommand, ['--dir', example.dir, 'run', 'build:app'], { From 9fc04d5697783faa9762863694403ad78f843176 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 20:46:58 -0400 Subject: [PATCH 14/33] Polish submodules for repository review --- .github/workflows/ci.yml | 3 +- pnpm-lock.yaml | 405 ++++++++---------- spacetime-agents-ts/example/.env.example | 2 +- spacetime-agents-ts/example/README.md | 2 +- spacetime-agents-ts/example/package.json | 6 +- spacetime-agents-ts/example/server.ts | 14 +- .../example/spacetimedb/package.json | 2 +- spacetime-agents-ts/package.json | 2 +- spacetime-agents-ts/spacetimedb/package.json | 2 +- spacetime-api-keys-ts/example/.env.example | 2 +- spacetime-api-keys-ts/example/README.md | 14 +- spacetime-api-keys-ts/example/package.json | 8 +- .../example/public/assets/brand.svg | 17 - spacetime-api-keys-ts/example/server.ts | 13 +- .../example/spacetimedb/package.json | 2 +- spacetime-api-keys-ts/package.json | 9 +- spacetime-auth-ts/README.md | 12 +- spacetime-auth-ts/example/.env.example | 4 +- spacetime-auth-ts/example/README.md | 4 +- spacetime-auth-ts/example/package.json | 6 +- spacetime-auth-ts/example/server.ts | 12 +- .../example/spacetimedb/package.json | 3 +- spacetime-auth-ts/package.json | 2 +- spacetime-auth-ts/spacetimedb/package.json | 2 +- spacetime-auth-ts/src/mounted/index.ts | 113 +---- spacetime-cron-ts/example/.env.example | 2 +- spacetime-cron-ts/example/README.md | 14 +- spacetime-cron-ts/example/package.json | 7 +- spacetime-cron-ts/example/server.ts | 8 +- .../example/spacetimedb/package.json | 2 +- spacetime-cron-ts/package.json | 2 +- spacetime-cron-ts/spacetimedb/package.json | 2 +- spacetime-crypto-ts/package.json | 2 +- spacetime-files-ts/example/.env.example | 2 +- spacetime-files-ts/example/README.md | 16 +- spacetime-files-ts/example/package.json | 4 +- .../example/public/assets/logo.svg | 5 - spacetime-files-ts/example/server.ts | 10 +- .../example/spacetimedb/package.json | 2 +- spacetime-files-ts/package.json | 2 +- spacetime-grid-ts/example/.env.example | 4 +- spacetime-grid-ts/example/README.md | 2 +- spacetime-grid-ts/example/package.json | 7 +- spacetime-grid-ts/example/server.ts | 12 +- .../example/spacetimedb/package.json | 2 +- spacetime-grid-ts/package.json | 4 +- spacetime-lobby-ts/example/.env.example | 2 +- spacetime-lobby-ts/example/README.md | 12 +- spacetime-lobby-ts/example/package.json | 4 +- spacetime-lobby-ts/example/server.ts | 8 +- .../example/spacetimedb/package.json | 2 +- spacetime-lobby-ts/package.json | 4 +- spacetime-lobby-ts/src/index.ts | 2 - spacetime-posthog-ts/example/.env.example | 2 +- spacetime-posthog-ts/example/README.md | 2 +- spacetime-posthog-ts/example/package.json | 4 +- spacetime-posthog-ts/example/server.ts | 16 +- .../example/spacetimedb/package.json | 2 +- spacetime-posthog-ts/package.json | 4 +- spacetime-posthog-ts/src/index.ts | 2 - spacetime-presence-ts/example/.env.example | 2 +- spacetime-presence-ts/example/README.md | 2 +- spacetime-presence-ts/example/package.json | 4 +- .../example/public/assets/brand.svg | 17 - spacetime-presence-ts/example/server.ts | 13 +- .../example/spacetimedb/package.json | 2 +- spacetime-presence-ts/package.json | 2 +- .../spacetimedb/package.json | 2 +- spacetime-presence-ts/src/mounted/index.ts | 30 +- spacetime-rate-limit-ts/example/.env.example | 2 +- spacetime-rate-limit-ts/example/README.md | 12 +- spacetime-rate-limit-ts/example/package.json | 4 +- spacetime-rate-limit-ts/example/server.ts | 10 +- .../example/spacetimedb/package.json | 2 +- spacetime-rate-limit-ts/package.json | 2 +- .../spacetimedb/package.json | 2 +- .../src/submodule/operations.ts | 2 +- spacetime-resend-ts/example/.env.example | 2 +- spacetime-resend-ts/example/README.md | 2 +- spacetime-resend-ts/example/package.json | 6 +- spacetime-resend-ts/example/server.ts | 16 +- .../example/spacetimedb/package.json | 4 +- spacetime-resend-ts/package.json | 4 +- spacetime-resend-ts/src/index.ts | 2 - spacetime-retry-ts/package.json | 2 +- spacetime-retry-ts/spacetimedb/package.json | 2 +- spacetime-stripe-ts/example/.env.example | 2 +- spacetime-stripe-ts/example/README.md | 2 +- spacetime-stripe-ts/example/package.json | 6 +- .../example/public/assets/logo.svg | 5 - spacetime-stripe-ts/example/public/ui.js | 43 +- spacetime-stripe-ts/example/server.ts | 16 +- .../example/spacetimedb/package.json | 2 +- spacetime-stripe-ts/package.json | 4 +- tools/release-check.mjs | 21 + tools/run-example-smokes.mjs | 3 +- 96 files changed, 457 insertions(+), 635 deletions(-) delete mode 100644 spacetime-api-keys-ts/example/public/assets/brand.svg delete mode 100644 spacetime-files-ts/example/public/assets/logo.svg delete mode 100644 spacetime-presence-ts/example/public/assets/brand.svg delete mode 100644 spacetime-stripe-ts/example/public/assets/logo.svg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 934e04f96b1..f79a044ba54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1709,8 +1709,7 @@ jobs: run: | curl -sSf https://install.spacetimedb.com | sh -s -- --root-dir "$RUNNER_TEMP/spacetime" --yes echo "$RUNNER_TEMP/spacetime/bin" >> "$GITHUB_PATH" - "$RUNNER_TEMP/spacetime/bin/spacetime" version install 2.8.3 - "$RUNNER_TEMP/spacetime/bin/spacetime" version use 2.8.3 + "$RUNNER_TEMP/spacetime/bin/spacetime" version install 2.8.3 --use --yes - name: Check submodules run: pnpm submodules:check diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4931549668..f97bb5822c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,7 +137,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)) brotli-size-cli: specifier: ^1.0.0 version: 1.0.0 @@ -158,7 +158,7 @@ importers: version: 5.46.4 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@25.9.5)(typescript@5.9.3) + version: 10.9.2(@types/node@22.18.0)(typescript@5.9.3) tsup: specifier: ^8.1.0 version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.8.2) @@ -170,10 +170,10 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.5 - version: 7.1.5(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + version: 7.1.5(@types/node@22.18.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) crates/bindings-typescript/case-conversion-test-client: dependencies: @@ -346,8 +346,8 @@ importers: spacetime-agents-ts: devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -366,12 +366,6 @@ importers: spacetime-agents-ts/example: dependencies: - '@spacetimedb/agents': - specifier: workspace:* - version: link:.. - '@spacetimedb/auth': - specifier: workspace:* - version: link:../../spacetime-auth-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -383,11 +377,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -417,8 +411,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 tsx: specifier: ^4.21.0 version: 4.23.12 @@ -436,8 +430,8 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -449,8 +443,8 @@ importers: version: link:../spacetime-crypto-ts devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -469,18 +463,6 @@ importers: spacetime-api-keys-ts/example: dependencies: - '@spacetimedb/api-keys': - specifier: workspace:* - version: link:.. - '@spacetimedb/crypto': - specifier: workspace:* - version: link:../../spacetime-crypto-ts - '@spacetimedb/grid': - specifier: workspace:* - version: link:../../spacetime-grid-ts - '@spacetimedb/presence': - specifier: workspace:* - version: link:../../spacetime-presence-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -492,11 +474,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -526,8 +508,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -545,8 +527,8 @@ importers: version: link:../spacetime-rate-limit-ts devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -565,12 +547,6 @@ importers: spacetime-auth-ts/example: dependencies: - '@spacetimedb/auth': - specifier: workspace:* - version: link:.. - '@spacetimedb/rate-limit': - specifier: workspace:* - version: link:../../spacetime-rate-limit-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -582,11 +558,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -602,16 +578,13 @@ importers: '@spacetimedb/auth': specifier: workspace:* version: link:../.. - '@spacetimedb/rate-limit': - specifier: workspace:* - version: link:../../../spacetime-rate-limit-ts spacetimedb: specifier: workspace:* version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -629,8 +602,8 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -642,8 +615,8 @@ importers: version: 5.5.0 devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -662,9 +635,6 @@ importers: spacetime-cron-ts/example: dependencies: - '@spacetimedb/cron': - specifier: workspace:* - version: link:.. dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -676,11 +646,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -701,8 +671,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -717,8 +687,8 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -730,8 +700,8 @@ importers: version: 2.3.0 devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -752,8 +722,8 @@ importers: version: link:../spacetime-crypto-ts devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -786,11 +756,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -811,8 +781,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -820,8 +790,8 @@ importers: spacetime-grid-ts: devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -832,7 +802,7 @@ importers: specifier: workspace:* version: link:../crates/bindings-typescript tsx: - specifier: ^4.22.3 + specifier: ^4.21.0 version: 4.23.12 typescript: specifier: ^5.9.3 @@ -840,15 +810,6 @@ importers: spacetime-grid-ts/example: dependencies: - '@spacetimedb/auth': - specifier: workspace:* - version: link:../../spacetime-auth-ts - '@spacetimedb/grid': - specifier: workspace:* - version: link:.. - '@spacetimedb/rate-limit': - specifier: workspace:* - version: link:../../spacetime-rate-limit-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -860,11 +821,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -891,8 +852,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -900,8 +861,8 @@ importers: spacetime-lobby-ts: devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -931,11 +892,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -956,8 +917,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -965,8 +926,8 @@ importers: spacetime-posthog-ts: devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -996,11 +957,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -1021,8 +982,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1030,8 +991,8 @@ importers: spacetime-presence-ts: devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -1061,11 +1022,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -1095,8 +1056,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1111,8 +1072,8 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1120,8 +1081,8 @@ importers: spacetime-rate-limit-ts: devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -1151,11 +1112,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -1176,8 +1137,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1192,8 +1153,8 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1208,8 +1169,8 @@ importers: version: 1.4.2(typescript@5.9.3) devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -1239,11 +1200,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -1254,8 +1215,8 @@ importers: specifier: ^4.21.0 version: 4.23.12 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: ^5.9.3 + version: 5.9.3 spacetime-resend-ts/example/spacetimedb: dependencies: @@ -1270,17 +1231,17 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: ^5.9.3 + version: 5.9.3 spacetime-retry-ts: devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -1307,8 +1268,8 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1320,14 +1281,14 @@ importers: version: link:../spacetime-crypto-ts stripe: specifier: ^22.1.0 - version: 22.5.0(@types/node@25.9.5) + version: 22.5.0(@types/node@22.18.0) valibot: specifier: ^1.4.2 version: 1.4.2(typescript@5.9.3) devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 eslint: specifier: ^9.17.0 version: 9.33.0(jiti@2.6.1) @@ -1357,11 +1318,11 @@ importers: version: link:../../crates/bindings-typescript devDependencies: '@types/express': - specifier: ^5.0.6 - version: 5.0.6 + specifier: ^4.17.21 + version: 4.17.23 '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 esbuild: specifier: ^0.28.0 version: 0.28.2 @@ -1369,8 +1330,8 @@ importers: specifier: ^4.21.0 version: 4.23.12 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: ^5.9.3 + version: 5.9.3 spacetime-stripe-ts/example/spacetimedb: dependencies: @@ -1382,8 +1343,8 @@ importers: version: link:../../../crates/bindings-typescript devDependencies: '@types/node': - specifier: ^25.6.0 - version: 25.9.5 + specifier: ^22.10.2 + version: 22.18.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1462,7 +1423,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.14 + version: 1.4.0 bun: specifier: ^1.3.2 version: 1.3.9 @@ -1765,7 +1726,7 @@ importers: dependencies: nuxt: specifier: ~3.16.0 - version: 3.16.2(@parcel/watcher@2.5.6)(@types/node@25.9.5)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2) + version: 3.16.2(@parcel/watcher@2.5.6)(@types/node@25.9.5)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2) spacetimedb: specifier: workspace:* version: link:../../crates/bindings-typescript @@ -7410,12 +7371,12 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} - '@types/bun@1.3.14': - resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} - '@types/bun@1.3.9': resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} + '@types/bun@1.4.0': + resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -8786,12 +8747,12 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.3.14: - resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} - bun-types@1.3.9: resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==} + bun-types@1.4.0: + resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==} + bun@1.3.9: resolution: {integrity: sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A==} cpu: [arm64, x64] @@ -10029,6 +9990,7 @@ packages: eslint@9.33.0: resolution: {integrity: sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -14994,6 +14956,7 @@ packages: tsconfck@2.1.2: resolution: {integrity: sha512-ghqN1b0puy3MhhviwO2kGF8SeMDNhEbnKxjK7h6+fvY9JAxqvXi8y5NAHSQv687OVboS2uZIByzGd45/YxrRHg==} engines: {node: ^14.13.1 || ^16 || >=18} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^4.3.5 || ^5.0.0 @@ -15004,6 +14967,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -15110,11 +15074,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -20578,11 +20537,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@nuxt/cli@3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.5.1)': + '@nuxt/cli@3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.3.5)': dependencies: '@bomb.sh/tab': 0.0.12(cac@6.7.14)(citty@0.2.0) '@clack/prompts': 1.0.0 - c12: 3.3.3(magicast@0.5.1) + c12: 3.3.3(magicast@0.3.5) citty: 0.2.0 confbox: 0.2.4 consola: 3.4.2 @@ -20678,9 +20637,9 @@ snapshots: - utf-8-validate - vue - '@nuxt/kit@3.16.2(magicast@0.5.1)': + '@nuxt/kit@3.16.2(magicast@0.3.5)': dependencies: - c12: 3.3.3(magicast@0.5.1) + c12: 3.3.3(magicast@0.3.5) consola: 3.4.2 defu: 6.1.4 destr: 2.0.5 @@ -20738,18 +20697,18 @@ snapshots: pathe: 2.0.3 std-env: 3.10.0 - '@nuxt/telemetry@2.7.0(@nuxt/kit@3.16.2(magicast@0.5.1))': + '@nuxt/telemetry@2.7.0(@nuxt/kit@3.16.2(magicast@0.3.5))': dependencies: - '@nuxt/kit': 3.16.2(magicast@0.5.1) + '@nuxt/kit': 3.16.2(magicast@0.3.5) citty: 0.2.0 consola: 3.4.2 ofetch: 2.0.0-alpha.3 rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@3.16.2(@types/node@25.9.5)(eslint@9.33.0(jiti@2.6.1))(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2)': + '@nuxt/vite-builder@3.16.2(@types/node@25.9.5)(eslint@9.33.0(jiti@2.6.1))(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2)': dependencies: - '@nuxt/kit': 3.16.2(magicast@0.5.1) + '@nuxt/kit': 3.16.2(magicast@0.3.5) '@rollup/plugin-replace': 6.0.3(rollup@4.56.0) '@vitejs/plugin-vue': 5.2.4(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) '@vitejs/plugin-vue-jsx': 4.2.0(vite@6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) @@ -22812,14 +22771,14 @@ snapshots: dependencies: '@types/node': 22.18.0 - '@types/bun@1.3.14': - dependencies: - bun-types: 1.3.14 - '@types/bun@1.3.9': dependencies: bun-types: 1.3.9 + '@types/bun@1.4.0': + dependencies: + bun-types: 1.4.0 + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -22968,6 +22927,7 @@ snapshots: '@types/node@25.9.5': dependencies: undici-types: 7.24.6 + optional: true '@types/object-inspect@1.13.0': {} @@ -23389,7 +23349,7 @@ snapshots: vite: 6.4.1(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) vue: 3.5.26(typescript@5.6.3) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -23404,7 +23364,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.9.5)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -25130,11 +25090,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.3.14: + bun-types@1.3.9: dependencies: '@types/node': 22.18.0 - bun-types@1.3.9: + bun-types@1.4.0: dependencies: '@types/node': 22.18.0 @@ -25885,10 +25845,10 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)): + db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0)): optionalDependencies: better-sqlite3: 12.6.2 - drizzle-orm: 0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0) + drizzle-orm: 0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0) de-indent@1.0.2: {} @@ -26063,28 +26023,28 @@ snapshots: dotenv@17.2.3: {} - drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0): + drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.9)(pg@8.18.0)(sql.js@1.14.0): optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.16.0 '@types/sql.js': 1.4.9 better-sqlite3: 12.6.2 - bun-types: 1.3.14 + bun-types: 1.3.9 pg: 8.18.0 sql.js: 1.14.0 - optional: true - drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.9)(pg@8.18.0)(sql.js@1.14.0): + drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0): optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.16.0 '@types/sql.js': 1.4.9 better-sqlite3: 12.6.2 - bun-types: 1.3.9 + bun-types: 1.4.0 pg: 8.18.0 sql.js: 1.14.0 + optional: true dunder-proto@1.0.1: dependencies: @@ -29441,7 +29401,7 @@ snapshots: neo-async@2.6.2: {} - nitropack@2.13.1(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13): + nitropack@2.13.1(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.56.0) @@ -29462,7 +29422,7 @@ snapshots: cookie-es: 2.0.0 croner: 9.1.0 crossws: 0.3.5 - db0: 0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)) + db0: 0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0)) defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 @@ -29508,7 +29468,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.6.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2) + unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0-beta.13 @@ -29714,19 +29674,19 @@ snapshots: schema-utils: 3.3.0 webpack: 5.102.0 - nuxt@3.16.2(@parcel/watcher@2.5.6)(@types/node@25.9.5)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2): + nuxt@3.16.2(@parcel/watcher@2.5.6)(@types/node@25.9.5)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0)))(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13)(eslint@9.33.0(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.6.3))(yaml@2.8.2): dependencies: - '@nuxt/cli': 3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.5.1) + '@nuxt/cli': 3.33.1(@nuxt/schema@3.16.2)(cac@6.7.14)(magicast@0.3.5) '@nuxt/devalue': 2.0.2 '@nuxt/devtools': 2.7.0(vite@7.3.2(@types/node@25.9.5)(jiti@2.6.1)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2))(vue@3.5.26(typescript@5.6.3)) - '@nuxt/kit': 3.16.2(magicast@0.5.1) + '@nuxt/kit': 3.16.2(magicast@0.3.5) '@nuxt/schema': 3.16.2 - '@nuxt/telemetry': 2.7.0(@nuxt/kit@3.16.2(magicast@0.5.1)) - '@nuxt/vite-builder': 3.16.2(@types/node@25.9.5)(eslint@9.33.0(jiti@2.6.1))(magicast@0.5.1)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2) + '@nuxt/telemetry': 2.7.0(@nuxt/kit@3.16.2(magicast@0.3.5)) + '@nuxt/vite-builder': 3.16.2(@types/node@25.9.5)(eslint@9.33.0(jiti@2.6.1))(magicast@0.3.5)(optionator@0.9.4)(rollup@4.56.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(typescript@5.6.3)(vue-tsc@2.2.12(typescript@5.6.3))(vue@3.5.26(typescript@5.6.3))(yaml@2.8.2) '@oxc-parser/wasm': 0.60.0 '@unhead/vue': 2.1.4(vue@3.5.26(typescript@5.6.3)) '@vue/shared': 3.5.26 - c12: 3.3.3(magicast@0.5.1) + c12: 3.3.3(magicast@0.3.5) chokidar: 4.0.3 compatx: 0.1.8 consola: 3.4.2 @@ -29751,7 +29711,7 @@ snapshots: mlly: 1.8.0 mocked-exports: 0.1.1 nanotar: 0.2.1 - nitropack: 2.13.1(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13) + nitropack: 2.13.1(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0))(encoding@0.1.13) nypm: 0.6.4 ofetch: 1.5.1 ohash: 2.0.11 @@ -29773,7 +29733,7 @@ snapshots: unimport: 4.2.0 unplugin: 2.3.11 unplugin-vue-router: 0.12.0(vue-router@4.6.4(vue@3.5.26(typescript@5.6.3)))(vue@3.5.26(typescript@5.6.3)) - unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2) + unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2) untyped: 2.0.0 vue: 3.5.26(typescript@5.6.3) vue-bundle-renderer: 2.2.0 @@ -32358,9 +32318,9 @@ snapshots: dependencies: js-tokens: 9.0.1 - stripe@22.5.0(@types/node@25.9.5): + stripe@22.5.0(@types/node@22.18.0): optionalDependencies: - '@types/node': 25.9.5 + '@types/node': 22.18.0 structured-clone-es@1.0.0: {} @@ -32685,26 +32645,25 @@ snapshots: '@ts-morph/common': 0.20.0 code-block-writer: 12.0.0 - ts-node@10.9.2(@types/node@25.9.5)(typescript@5.6.3): + ts-node@10.9.2(@types/node@22.18.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 25.9.5 + '@types/node': 22.18.0 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.6.3 + typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optional: true - ts-node@10.9.2(@types/node@25.9.5)(typescript@5.9.3): + ts-node@10.9.2(@types/node@25.9.5)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -32718,9 +32677,10 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.9.3 + typescript: 5.6.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optional: true ts-pattern@5.0.5: {} @@ -32852,8 +32812,6 @@ snapshots: typescript@5.9.3: {} - typescript@6.0.3: {} - ufo@1.6.1: {} ufo@1.6.3: {} @@ -32873,7 +32831,8 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.24.6: {} + undici-types@7.24.6: + optional: true undici@6.21.3: {} @@ -33089,7 +33048,7 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2): + unstorage@1.17.4(db0@0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0)))(ioredis@5.9.2): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -33100,7 +33059,7 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.3 optionalDependencies: - db0: 0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.3.14)(pg@8.18.0)(sql.js@1.14.0)) + db0: 0.3.4(better-sqlite3@12.6.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.6.2)(bun-types@1.4.0)(pg@8.18.0)(sql.js@1.14.0)) ioredis: 5.9.2 untun@0.1.3: diff --git a/spacetime-agents-ts/example/.env.example b/spacetime-agents-ts/example/.env.example index 2212cb7db60..ad375863ae5 100644 --- a/spacetime-agents-ts/example/.env.example +++ b/spacetime-agents-ts/example/.env.example @@ -36,5 +36,5 @@ PORT=8789 # proxies /auth/* over HTTP to the same instance. STDB_URI=ws://127.0.0.1:3000 STDB_HTTP=http://127.0.0.1:3000 -STDB_APP_DATABASE=spacetime-agents-example +SPACETIMEDB_DB_NAME=spacetime-agents-example STDB_SERVER=http://127.0.0.1:3000 diff --git a/spacetime-agents-ts/example/README.md b/spacetime-agents-ts/example/README.md index d78d26495d4..02006eb5ce6 100644 --- a/spacetime-agents-ts/example/README.md +++ b/spacetime-agents-ts/example/README.md @@ -98,7 +98,7 @@ set by the launching process are never overwritten. | `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | | `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth/file proxy. | | `STDB_SERVER` | `STDB_HTTP` | CLI target used for startup configuration. | -| `STDB_APP_DATABASE` | `spacetime-agents-example` | Published database name. | +| `SPACETIMEDB_DB_NAME` | `spacetime-agents-example` | Published database name. | | `HOST` | `127.0.0.1` | Development web-server bind address. | | `PORT` | `8789` | Static-server port. | diff --git a/spacetime-agents-ts/example/package.json b/spacetime-agents-ts/example/package.json index 80779362c00..11035c88d61 100644 --- a/spacetime-agents-ts/example/package.json +++ b/spacetime-agents-ts/example/package.json @@ -14,15 +14,13 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/agents": "workspace:*", - "@spacetimedb/auth": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-agents-ts/example/server.ts b/spacetime-agents-ts/example/server.ts index 2ad1241f54d..25ec71241d5 100644 --- a/spacetime-agents-ts/example/server.ts +++ b/spacetime-agents-ts/example/server.ts @@ -35,7 +35,7 @@ const PORT = Number.parseInt(process.env.PORT ?? '8789', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; -const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-agents-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-agents-example'; const AUTH_ISSUER_URL = process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; @@ -88,7 +88,7 @@ function configureAuthFromEnv(): void { const result = spawnSync( SPACETIME_BIN, - ['call', '--server', STDB_SERVER, STDB_APP_DB, 'set_auth_config', ...args], + ['call', '--server', STDB_SERVER, DB_NAME, 'set_auth_config', ...args], { stdio: 'inherit', shell: false } ); if (result.status !== 0) { @@ -109,7 +109,7 @@ function optU32(value: string | undefined): string { function callReducer(name: string, args: string[]): void { const result = spawnSync( SPACETIME_BIN, - ['call', '--server', STDB_SERVER, STDB_APP_DB, name, ...args], + ['call', '--server', STDB_SERVER, DB_NAME, name, ...args], { stdio: 'inherit', shell: false } ); if (result.status !== 0) { @@ -167,7 +167,7 @@ function proxyStdbRoute(prefix: string) { const qIdx = fullPath.indexOf('?'); const subpath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); const query = qIdx < 0 ? '' : fullPath.slice(qIdx); - const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${subpath}${query}`; + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${subpath}${query}`; const headers: Record = {}; for (const [k, v] of Object.entries(req.headers)) { if (typeof v === 'string') headers[k] = v; @@ -217,13 +217,13 @@ app.use('/files', proxyStdbRoute('/files')); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, app: STDB_APP_DB }); + res.json({ ok: true, app: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { res.json({ stdbUri: STDB_URI, - appDatabase: STDB_APP_DB, + appDatabase: DB_NAME, auth: { issuerUrl: AUTH_ISSUER_URL, baseUrl: AUTH_BASE_URL, @@ -259,5 +259,5 @@ app.listen(PORT, HOST, () => { console.log(`Agents example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*, /files)`); - console.log(` Database -> ${STDB_APP_DB}`); + console.log(` Database -> ${DB_NAME}`); }); diff --git a/spacetime-agents-ts/example/spacetimedb/package.json b/spacetime-agents-ts/example/spacetimedb/package.json index f47f633491b..231189b37e8 100644 --- a/spacetime-agents-ts/example/spacetimedb/package.json +++ b/spacetime-agents-ts/example/spacetimedb/package.json @@ -16,7 +16,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "tsx": "^4.21.0", "typescript": "^5.9.3" } diff --git a/spacetime-agents-ts/package.json b/spacetime-agents-ts/package.json index c57f3a49f27..37de36968f0 100644 --- a/spacetime-agents-ts/package.json +++ b/spacetime-agents-ts/package.json @@ -70,7 +70,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-agents-ts/spacetimedb/package.json b/spacetime-agents-ts/spacetimedb/package.json index cc7fcb43700..e450a177931 100644 --- a/spacetime-agents-ts/spacetimedb/package.json +++ b/spacetime-agents-ts/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-api-keys-ts/example/.env.example b/spacetime-api-keys-ts/example/.env.example index 2a2506f58ad..81decb08050 100644 --- a/spacetime-api-keys-ts/example/.env.example +++ b/spacetime-api-keys-ts/example/.env.example @@ -2,4 +2,4 @@ HOST=127.0.0.1 PORT=8798 STDB_URI=ws://127.0.0.1:3000 STDB_HTTP=http://127.0.0.1:3000 -STDB_DATABASE=spacetime-api-keys-example +SPACETIMEDB_DB_NAME=spacetime-api-keys-example diff --git a/spacetime-api-keys-ts/example/README.md b/spacetime-api-keys-ts/example/README.md index 31a3b1d5411..356b22cea42 100644 --- a/spacetime-api-keys-ts/example/README.md +++ b/spacetime-api-keys-ts/example/README.md @@ -69,13 +69,13 @@ colony, grid, and presence features are application-specific demonstration code. ## Configuration -| Variable | Default | Purpose | -| --------------- | ---------------------------- | ------------------------------------ | -| `HOST` | `127.0.0.1` | Development web-server bind address. | -| `PORT` | `8798` | Development web-server port. | -| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | -| `STDB_HTTP` | `http://127.0.0.1:3000` | Upstream module HTTP endpoint. | -| `STDB_DATABASE` | `spacetime-api-keys-example` | Published database name. | +| Variable | Default | Purpose | +| --------------------- | ---------------------------- | ------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8798` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | Upstream module HTTP endpoint. | +| `SPACETIMEDB_DB_NAME` | `spacetime-api-keys-example` | Published database name. | The publish scripts target the SpacetimeDB server registered as `local`. If that registration resolves to a different endpoint than `STDB_URI` and `STDB_HTTP`, diff --git a/spacetime-api-keys-ts/example/package.json b/spacetime-api-keys-ts/example/package.json index 8c30d6ee8ff..46bddc583d0 100644 --- a/spacetime-api-keys-ts/example/package.json +++ b/spacetime-api-keys-ts/example/package.json @@ -14,17 +14,13 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/api-keys": "workspace:*", - "@spacetimedb/crypto": "workspace:*", - "@spacetimedb/grid": "workspace:*", - "@spacetimedb/presence": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-api-keys-ts/example/public/assets/brand.svg b/spacetime-api-keys-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-api-keys-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-api-keys-ts/example/server.ts b/spacetime-api-keys-ts/example/server.ts index 938c2541efb..f779f84b52c 100644 --- a/spacetime-api-keys-ts/example/server.ts +++ b/spacetime-api-keys-ts/example/server.ts @@ -26,10 +26,7 @@ const PORT = Number.parseInt(process.env.PORT ?? '8798', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; -const STDB_DATABASE = - process.env.STDB_DATABASE ?? - process.env.STDB_APP_DATABASE ?? - 'spacetime-api-keys-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-api-keys-example'; const app = express(); app.use(express.json({ limit: '256kb' })); @@ -37,12 +34,12 @@ app.use(express.json({ limit: '256kb' })); app.get('/api/config', (_req: Request, res: Response) => { res.json({ stdbUri: STDB_URI, - database: STDB_DATABASE, + database: DB_NAME, }); }); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, database: STDB_DATABASE }); + res.json({ ok: true, database: DB_NAME }); }); app.use('/api/colony', async (req: Request, res: Response) => { @@ -50,7 +47,7 @@ app.use('/api/colony', async (req: Request, res: Response) => { const qIdx = fullPath.indexOf('?'); const subpath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); const query = qIdx < 0 ? '' : fullPath.slice(qIdx); - const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_DATABASE}/route${subpath}${query}`; + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${subpath}${query}`; const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { if (typeof value === 'string') headers[key] = value; @@ -98,5 +95,5 @@ app.listen(PORT, HOST, () => { console.log(`Colony running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP}`); - console.log(` Database -> ${STDB_DATABASE}`); + console.log(` Database -> ${DB_NAME}`); }); diff --git a/spacetime-api-keys-ts/example/spacetimedb/package.json b/spacetime-api-keys-ts/example/spacetimedb/package.json index 837676d86e7..4960c019f20 100644 --- a/spacetime-api-keys-ts/example/spacetimedb/package.json +++ b/spacetime-api-keys-ts/example/spacetimedb/package.json @@ -16,7 +16,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-api-keys-ts/package.json b/spacetime-api-keys-ts/package.json index 9f39a981e1c..2cfcc048f8f 100644 --- a/spacetime-api-keys-ts/package.json +++ b/spacetime-api-keys-ts/package.json @@ -41,10 +41,15 @@ "typescript" ], "scripts": { + "build": "spacetime build", "format": "prettier . --write --ignore-path ../.prettierignore", "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", "typecheck": "tsc --noEmit", - "test": "tsx scripts/test.ts" + "test": "tsx scripts/test.ts", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-api-keys", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-api-keys" }, "dependencies": { "@spacetimedb/crypto": "workspace:^" @@ -55,7 +60,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-auth-ts/README.md b/spacetime-auth-ts/README.md index e745f203ecd..0c59d34f276 100644 --- a/spacetime-auth-ts/README.md +++ b/spacetime-auth-ts/README.md @@ -7,7 +7,7 @@ profile management, and in-module rate limiting. ## Install ```bash -npm install @spacetimedb/auth @spacetimedb/rate-limit spacetimedb@^2.8.3 +npm install @spacetimedb/auth spacetimedb@^2.8.3 ``` Requires SpacetimeDB 2.8.3 or later for submodule mounting. @@ -15,26 +15,24 @@ Requires SpacetimeDB 2.8.3 or later for submodule mounting. For the install-to-publish workflow, see [Getting started](https://spacetimedb.com/docs/). -Mount `@spacetimedb/rate-limit/submodule` beside the auth submodule. The -host module owns HTTP route registration and any mail-delivery adapter. +The host module owns HTTP route registration and any mail-delivery adapter. ## Usage ### Integrate into an application Import the mountable namespace, register the handlers your application needs, -then install Auth and its Rate Limit dependency from the host `init` hook. +then install Auth from the host `init` hook. Auth mounts and initializes its +Rate Limit dependency. ```ts import { schema } from 'spacetimedb/server'; import * as auth from '@spacetimedb/auth/submodule'; -import * as rateLimit from '@spacetimedb/rate-limit/submodule'; -const spacetimedb = schema({ auth, rateLimit }); +const spacetimedb = schema({ auth }); export default spacetimedb; export const init = spacetimedb.init(ctx => { - rateLimit.installRateLimit(ctx.as.rateLimit); auth.installAuth(ctx.as.auth); }); ``` diff --git a/spacetime-auth-ts/example/.env.example b/spacetime-auth-ts/example/.env.example index 5b173ef3b9c..31234a33528 100644 --- a/spacetime-auth-ts/example/.env.example +++ b/spacetime-auth-ts/example/.env.example @@ -7,7 +7,7 @@ PORT=8791 # ---------------- SpacetimeDB ---------------- STDB_URI=ws://127.0.0.1:3000 STDB_HTTP=http://127.0.0.1:3000 -STDB_APP_DATABASE=spacetime-auth-example +SPACETIMEDB_DB_NAME=spacetime-auth-example STDB_SERVER=http://127.0.0.1:3000 # ---------------- Auth ---------------- @@ -20,8 +20,6 @@ AUTH_SESSION_TTL_SECONDS=604800 # Use \n escapes if putting a PEM on one line. AUTH_ES256_PRIVATE_KEY_PEM= -# Optional override for the local namespace-capable CLI. - # OAuth (optional). Without these the corresponding buttons are disabled. # Redirect URI to register with each provider: # http://localhost:8791/auth/google/callback diff --git a/spacetime-auth-ts/example/README.md b/spacetime-auth-ts/example/README.md index 9130ec9b6aa..936701a01f5 100644 --- a/spacetime-auth-ts/example/README.md +++ b/spacetime-auth-ts/example/README.md @@ -61,7 +61,7 @@ preserved. This workspace tests the submodule source in this repository. Consumer applications install published releases: ```bash -npm install @spacetimedb/auth @spacetimedb/rate-limit spacetimedb@^2.8.3 +npm install @spacetimedb/auth spacetimedb@^2.8.3 ``` Follow the package's @@ -78,7 +78,7 @@ console mailer and development server before production. | `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | | `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth proxy. | | `STDB_SERVER` | `STDB_HTTP` | CLI target used during startup configuration. | -| `STDB_APP_DATABASE` | `spacetime-auth-example` | Published database name. | +| `SPACETIMEDB_DB_NAME` | `spacetime-auth-example` | Published database name. | | `AUTH_ISSUER_URL` | `http://localhost:8791` | JWT issuer and OAuth redirect origin. | | `AUTH_BASE_URL` | `http://localhost:8791` | Browser-visible auth base URL. | | `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | diff --git a/spacetime-auth-ts/example/package.json b/spacetime-auth-ts/example/package.json index 67a08439dff..d500862b6f5 100644 --- a/spacetime-auth-ts/example/package.json +++ b/spacetime-auth-ts/example/package.json @@ -13,15 +13,13 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/auth": "workspace:*", - "@spacetimedb/rate-limit": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts index e7130d991f7..cd81d0edebb 100644 --- a/spacetime-auth-ts/example/server.ts +++ b/spacetime-auth-ts/example/server.ts @@ -35,7 +35,7 @@ const PORT = Number.parseInt(process.env.PORT ?? '8791', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; -const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-auth-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-auth-example'; const AUTH_ISSUER_URL = process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; @@ -88,7 +88,7 @@ function configureAuthFromEnv(): void { const result = spawnSync( SPACETIME_BIN, - ['call', '--server', STDB_SERVER, STDB_APP_DB, 'set_auth_config', ...args], + ['call', '--server', STDB_SERVER, DB_NAME, 'set_auth_config', ...args], { stdio: 'inherit', shell: false } ); if (result.status !== 0) { @@ -113,7 +113,7 @@ app.use('/auth', async (req, res) => { const qIdx = fullPath.indexOf('?'); const path = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); const query = qIdx < 0 ? '' : fullPath.slice(qIdx); - const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${path}${query}`; + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${path}${query}`; const headers: Record = {}; for (const [k, v] of Object.entries(req.headers)) { if (typeof v === 'string') headers[k] = v; @@ -156,7 +156,7 @@ app.use('/auth', async (req, res) => { app.get('/api/config', (_req: Request, res: Response) => { res.json({ stdbUri: STDB_URI, - appDatabase: STDB_APP_DB, + appDatabase: DB_NAME, auth: { issuerUrl: AUTH_ISSUER_URL, baseUrl: AUTH_BASE_URL, @@ -174,7 +174,7 @@ app.get('/api/config', (_req: Request, res: Response) => { }); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, app: STDB_APP_DB }); + res.json({ ok: true, app: DB_NAME }); }); app.use(express.static(path.join(__dirname, 'public'))); @@ -197,5 +197,5 @@ app.listen(PORT, HOST, () => { console.log(`Notes example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*)`); - console.log(` Database -> ${STDB_APP_DB}`); + console.log(` Database -> ${DB_NAME}`); }); diff --git a/spacetime-auth-ts/example/spacetimedb/package.json b/spacetime-auth-ts/example/spacetimedb/package.json index 1587465fb39..a6666973260 100644 --- a/spacetime-auth-ts/example/spacetimedb/package.json +++ b/spacetime-auth-ts/example/spacetimedb/package.json @@ -10,11 +10,10 @@ }, "dependencies": { "@spacetimedb/auth": "workspace:*", - "@spacetimedb/rate-limit": "workspace:*", "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-auth-ts/package.json b/spacetime-auth-ts/package.json index cf03fdb56c9..8a8bb1ae7ab 100644 --- a/spacetime-auth-ts/package.json +++ b/spacetime-auth-ts/package.json @@ -77,7 +77,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-auth-ts/spacetimedb/package.json b/spacetime-auth-ts/spacetimedb/package.json index d616f0f1996..42e0d4ff5a0 100644 --- a/spacetime-auth-ts/spacetimedb/package.json +++ b/spacetime-auth-ts/spacetimedb/package.json @@ -14,7 +14,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-auth-ts/src/mounted/index.ts b/spacetime-auth-ts/src/mounted/index.ts index 8663e5b25af..cdfbce5fdda 100644 --- a/spacetime-auth-ts/src/mounted/index.ts +++ b/spacetime-auth-ts/src/mounted/index.ts @@ -1,6 +1,16 @@ import { schema, t, table, Router } from 'spacetimedb/server'; import * as rateLimit from '@spacetimedb/rate-limit/submodule'; import { installAuth } from './install'; +import { + authAccountTable as authAccount, + authAdminIdentityTable as authAdminIdentity, + authConfigTable as authConfig, + authConnectionBindingTable as authConnectionBinding, + authOauthStateTable as authOauthState, + authSessionTable as authSession, + authUserTable as authUser, + authVerificationTable as authVerification, +} from '../tables'; import { setAuthConfigParams, setAuthConfigImpl, @@ -44,109 +54,6 @@ const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { ); }; -// STDB requires submodule tables declared inline at the module's literal site, not imported as values. -const authUser = table( - { name: 'auth_user', public: false }, - { - userId: t.string().primaryKey(), - email: t.string().unique(), - emailVerified: t.bool(), - name: t.option(t.string()), - image: t.option(t.string()), - createdAt: t.timestamp(), - updatedAt: t.timestamp(), - } -); - -const authSession = table( - { name: 'auth_session', public: false }, - { - sessionId: t.string().primaryKey(), - userId: t.string().index(), - token: t.string().unique(), - expiresAt: t.timestamp().index(), - ipAddress: t.option(t.string()), - userAgent: t.option(t.string()), - createdAt: t.timestamp(), - } -); - -const authAccount = table( - { name: 'auth_account', public: false }, - { - accountId: t.string().primaryKey(), - userId: t.string().index(), - providerId: t.string().index(), - providerAccountId: t.string().index(), - passwordHash: t.option(t.string()), - accessToken: t.option(t.string()), - refreshToken: t.option(t.string()), - accessTokenExpiresAt: t.option(t.timestamp()), - createdAt: t.timestamp(), - updatedAt: t.timestamp(), - } -); - -const authVerification = table( - { name: 'auth_verification', public: false }, - { - verificationId: t.string().primaryKey(), - identifier: t.string().index(), - value: t.string().unique(), - purpose: t.string(), - expiresAt: t.timestamp().index(), - createdAt: t.timestamp(), - } -); - -const authOauthState = table( - { name: 'auth_oauth_state', public: false }, - { - state: t.string().primaryKey(), - provider: t.string(), - codeVerifier: t.string(), - redirectTo: t.string(), - expiresAt: t.timestamp().index(), - createdAt: t.timestamp(), - } -); - -const authConfig = table( - { name: 'auth_config', public: false }, - { - singleton: t.bool().primaryKey(), - issuerUrl: t.string(), - baseUrl: t.string(), - cookieName: t.string(), - sessionTtlSeconds: t.u64(), - es256PrivateKeyPem: t.string(), - es256PublicKeyPem: t.string(), - keyId: t.string(), - googleClientId: t.option(t.string()), - googleClientSecret: t.option(t.string()), - githubClientId: t.option(t.string()), - githubClientSecret: t.option(t.string()), - updatedAt: t.timestamp(), - } -); - -const authConnectionBinding = table( - { name: 'auth_connection_binding', public: false }, - { - stdbIdentity: t.identity().primaryKey(), - userId: t.string().index(), - linkedAt: t.timestamp(), - } -); - -const authAdminIdentity = table( - { name: 'auth_admin_identity', public: false }, - { - identity: t.identity().primaryKey(), - addedAtMicros: t.i64(), - } -); - const authSweeperTick = table( { name: 'auth_sweeper_tick' }, { diff --git a/spacetime-cron-ts/example/.env.example b/spacetime-cron-ts/example/.env.example index 2b6085f7fc9..b43135687a0 100644 --- a/spacetime-cron-ts/example/.env.example +++ b/spacetime-cron-ts/example/.env.example @@ -6,4 +6,4 @@ PORT=8788 # ---------------- SpacetimeDB ---------------- STDB_URI=ws://127.0.0.1:3000 -STDB_APP_DATABASE=spacetime-cron-example +SPACETIMEDB_DB_NAME=spacetime-cron-example diff --git a/spacetime-cron-ts/example/README.md b/spacetime-cron-ts/example/README.md index 7a747455529..d6080ed7558 100644 --- a/spacetime-cron-ts/example/README.md +++ b/spacetime-cron-ts/example/README.md @@ -81,12 +81,12 @@ The complete server integration is in [`spacetimedb/src/index.ts`](./spacetimedb The static server reads these optional variables from the process environment or `.env` files: -| Variable | Default | Purpose | -| ------------------- | ------------------------ | ------------------------------------ | -| `PORT` | `8788` | Development web-server port. | -| `HOST` | `127.0.0.1` | Development web-server bind address. | -| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | -| `STDB_APP_DATABASE` | `spacetime-cron-example` | Published database name. | +| Variable | Default | Purpose | +| --------------------- | ------------------------ | ------------------------------------ | +| `PORT` | `8788` | Development web-server port. | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `SPACETIMEDB_DB_NAME` | `spacetime-cron-example` | Published database name. | The browser connects directly to SpacetimeDB. The Express process serves static files and `/api/config`. @@ -181,7 +181,7 @@ For a browser release check: ## Troubleshooting -- **No jobs appear:** confirm `STDB_APP_DATABASE` matches +- **No jobs appear:** confirm `SPACETIMEDB_DB_NAME` matches `spacetime-cron-example` and reload after the subscription applies. - **The browser cannot connect:** confirm `STDB_URI` points to the server used by `spacetime publish --server local`. diff --git a/spacetime-cron-ts/example/package.json b/spacetime-cron-ts/example/package.json index ee9313b4ac8..ea2e7400c11 100644 --- a/spacetime-cron-ts/example/package.json +++ b/spacetime-cron-ts/example/package.json @@ -15,12 +15,11 @@ "dependencies": { "dotenv": "^16.4.7", "express": "^4.21.2", - "spacetimedb": "workspace:*", - "@spacetimedb/cron": "workspace:*" + "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-cron-ts/example/server.ts b/spacetime-cron-ts/example/server.ts index 9d2f357b251..c4ff6259fee 100644 --- a/spacetime-cron-ts/example/server.ts +++ b/spacetime-cron-ts/example/server.ts @@ -33,21 +33,21 @@ loadEnv(path.resolve(__dirname, '.env'), true); const PORT = Number.parseInt(process.env.PORT ?? '8788', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; -const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-cron-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-cron-example'; const app = express(); app.use(express.json({ limit: '256kb' })); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, app: STDB_APP_DB }); + res.json({ ok: true, app: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { - res.json({ stdbUri: STDB_URI, appDatabase: STDB_APP_DB }); + res.json({ stdbUri: STDB_URI, appDatabase: DB_NAME }); }); app.listen(PORT, HOST, () => { console.log(`Cron example running at http://${HOST}:${PORT}`); - console.log(` SpacetimeDB: ${STDB_URI} (${STDB_APP_DB})`); + console.log(` SpacetimeDB: ${STDB_URI} (${DB_NAME})`); }); diff --git a/spacetime-cron-ts/example/spacetimedb/package.json b/spacetime-cron-ts/example/spacetimedb/package.json index 1e3b0467bff..d8cb9efbd6e 100644 --- a/spacetime-cron-ts/example/spacetimedb/package.json +++ b/spacetime-cron-ts/example/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-cron-ts/package.json b/spacetime-cron-ts/package.json index 2e01ae6132c..d7469942d48 100644 --- a/spacetime-cron-ts/package.json +++ b/spacetime-cron-ts/package.json @@ -61,7 +61,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-cron-ts/spacetimedb/package.json b/spacetime-cron-ts/spacetimedb/package.json index 8a8465d280f..d7d8053aa05 100644 --- a/spacetime-cron-ts/spacetimedb/package.json +++ b/spacetime-cron-ts/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-crypto-ts/package.json b/spacetime-crypto-ts/package.json index 333aaeffd15..04c4900a497 100644 --- a/spacetime-crypto-ts/package.json +++ b/spacetime-crypto-ts/package.json @@ -49,7 +49,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "tsx": "^4.21.0", "typescript": "^5.9.3" }, diff --git a/spacetime-files-ts/example/.env.example b/spacetime-files-ts/example/.env.example index fee6c6aecb1..10d99a7dba8 100644 --- a/spacetime-files-ts/example/.env.example +++ b/spacetime-files-ts/example/.env.example @@ -4,4 +4,4 @@ HOST=127.0.0.1 PORT=8799 STDB_URI=ws://127.0.0.1:3000 STDB_HTTP=http://127.0.0.1:3000 -STDB_APP_DATABASE=spacetime-files-example +SPACETIMEDB_DB_NAME=spacetime-files-example diff --git a/spacetime-files-ts/example/README.md b/spacetime-files-ts/example/README.md index f8c88628512..e27aa0a40ab 100644 --- a/spacetime-files-ts/example/README.md +++ b/spacetime-files-ts/example/README.md @@ -68,13 +68,13 @@ and file-manager UI are application code in the example. ## Configuration -| Variable | Default | Purpose | -| ------------------- | ------------------------- | ------------------------------------------------ | -| `HOST` | `127.0.0.1` | Development web-server bind address. | -| `PORT` | `8799` | Development web-server port. | -| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | -| `STDB_HTTP` | `http://127.0.0.1:3000` | Upstream endpoint for public file HTTP requests. | -| `STDB_APP_DATABASE` | `spacetime-files-example` | Published database name. | +| Variable | Default | Purpose | +| --------------------- | ------------------------- | ------------------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8799` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | Upstream endpoint for public file HTTP requests. | +| `SPACETIMEDB_DB_NAME` | `spacetime-files-example` | Published database name. | The Node server hosts the bundle and proxies `/files?id=` to the module HTTP router. It receives metadata for authorization decisions. Private bytes travel through @@ -148,7 +148,7 @@ For a release smoke test, use two independent browser identities and verify: - **A preview is empty:** inspect the procedure failure and verify the connected identity owns the file. -- **A public link returns an error:** confirm `STDB_HTTP` and `STDB_APP_DATABASE` +- **A public link returns an error:** confirm `STDB_HTTP` and `SPACETIMEDB_DB_NAME` target the database used by `STDB_URI`. - **An upload exceeds the limit:** keep example files below 4 MB; use an external object store for larger production assets. diff --git a/spacetime-files-ts/example/package.json b/spacetime-files-ts/example/package.json index 9c294c3d642..4ff671d446b 100644 --- a/spacetime-files-ts/example/package.json +++ b/spacetime-files-ts/example/package.json @@ -20,8 +20,8 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-files-ts/example/public/assets/logo.svg b/spacetime-files-ts/example/public/assets/logo.svg deleted file mode 100644 index adaf36cbf9c..00000000000 --- a/spacetime-files-ts/example/public/assets/logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/spacetime-files-ts/example/server.ts b/spacetime-files-ts/example/server.ts index 88ba5085e0f..686958beb9e 100644 --- a/spacetime-files-ts/example/server.ts +++ b/spacetime-files-ts/example/server.ts @@ -29,7 +29,7 @@ const PORT = Number.parseInt(process.env.PORT ?? '8799', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; -const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-files-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-files-example'; const app = express(); app.use(express.json({ limit: '256kb' })); @@ -48,7 +48,7 @@ function proxyStdbRoute(prefix: string) { const qIdx = fullPath.indexOf('?'); const routePath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); const query = qIdx < 0 ? '' : fullPath.slice(qIdx); - const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${routePath}${query}`; + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${routePath}${query}`; const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { @@ -89,16 +89,16 @@ app.use('/files', proxyStdbRoute('/files')); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, app: STDB_APP_DB }); + res.json({ ok: true, app: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { - res.json({ stdbUri: STDB_URI, appDatabase: STDB_APP_DB }); + res.json({ stdbUri: STDB_URI, appDatabase: DB_NAME }); }); app.listen(PORT, HOST, () => { console.log(`Vault example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxy /files/*)`); - console.log(` Database -> ${STDB_APP_DB}`); + console.log(` Database -> ${DB_NAME}`); }); diff --git a/spacetime-files-ts/example/spacetimedb/package.json b/spacetime-files-ts/example/spacetimedb/package.json index d24a0ce1fe1..8782895e2a8 100644 --- a/spacetime-files-ts/example/spacetimedb/package.json +++ b/spacetime-files-ts/example/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-files-ts/package.json b/spacetime-files-ts/package.json index e64f6026302..a05a0ce6e56 100644 --- a/spacetime-files-ts/package.json +++ b/spacetime-files-ts/package.json @@ -71,7 +71,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-grid-ts/example/.env.example b/spacetime-grid-ts/example/.env.example index 8b96e395d91..e5ee1f01136 100644 --- a/spacetime-grid-ts/example/.env.example +++ b/spacetime-grid-ts/example/.env.example @@ -7,7 +7,7 @@ PORT=8793 # ---------------- SpacetimeDB ---------------- STDB_URI=ws://127.0.0.1:3000 STDB_HTTP=http://127.0.0.1:3000 -STDB_APP_DATABASE=spacetime-grid-example +SPACETIMEDB_DB_NAME=spacetime-grid-example STDB_SERVER=http://127.0.0.1:3000 # ---------------- Auth ---------------- @@ -20,8 +20,6 @@ AUTH_SESSION_TTL_SECONDS=604800 # Use \n escapes if putting a PEM on one line. AUTH_ES256_PRIVATE_KEY_PEM= -# Optional override for the SpacetimeDB CLI. - # OAuth (optional). Without these the corresponding buttons are disabled. # Redirect URI to register with each provider: # http://localhost:8793/auth/google/callback diff --git a/spacetime-grid-ts/example/README.md b/spacetime-grid-ts/example/README.md index 5a5d3beecf6..0f0a0a0a882 100644 --- a/spacetime-grid-ts/example/README.md +++ b/spacetime-grid-ts/example/README.md @@ -78,7 +78,7 @@ rules are host-owned example code. | `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | | `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth proxy. | | `STDB_SERVER` | `STDB_HTTP` | CLI target used during startup auth configuration. | -| `STDB_APP_DATABASE` | `spacetime-grid-example` | Published database name. | +| `SPACETIMEDB_DB_NAME` | `spacetime-grid-example` | Published database name. | | `AUTH_ISSUER_URL` / `AUTH_BASE_URL` | `http://localhost:8793` | JWT issuer and browser-visible auth origin. | | `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | | `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | diff --git a/spacetime-grid-ts/example/package.json b/spacetime-grid-ts/example/package.json index c63bba4f7b6..cf5f58ba8d0 100644 --- a/spacetime-grid-ts/example/package.json +++ b/spacetime-grid-ts/example/package.json @@ -14,16 +14,13 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/auth": "workspace:*", - "@spacetimedb/grid": "workspace:*", - "@spacetimedb/rate-limit": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-grid-ts/example/server.ts b/spacetime-grid-ts/example/server.ts index 5c366e220a6..c7383b793c5 100644 --- a/spacetime-grid-ts/example/server.ts +++ b/spacetime-grid-ts/example/server.ts @@ -36,7 +36,7 @@ const PORT = Number.parseInt(process.env.PORT ?? '8793', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; -const STDB_APP_DB = process.env.STDB_APP_DATABASE ?? 'spacetime-grid-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-grid-example'; const AUTH_ISSUER_URL = process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; @@ -89,7 +89,7 @@ function configureAuthFromEnv(): void { const result = spawnSync( SPACETIME_BIN, - ['call', '--server', STDB_SERVER, STDB_APP_DB, 'set_auth_config', ...args], + ['call', '--server', STDB_SERVER, DB_NAME, 'set_auth_config', ...args], { stdio: 'inherit', shell: false } ); if (result.status !== 0) { @@ -111,7 +111,7 @@ app.use('/auth', async (req, res) => { const qIdx = fullPath.indexOf('?'); const subpath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); const query = qIdx < 0 ? '' : fullPath.slice(qIdx); - const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${subpath}${query}`; + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${subpath}${query}`; const headers: Record = {}; for (const [k, v] of Object.entries(req.headers)) { if (typeof v === 'string') headers[k] = v; @@ -151,13 +151,13 @@ app.use('/auth', async (req, res) => { app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, app: STDB_APP_DB }); + res.json({ ok: true, app: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { res.json({ stdbUri: STDB_URI, - appDatabase: STDB_APP_DB, + appDatabase: DB_NAME, auth: { issuerUrl: AUTH_ISSUER_URL, baseUrl: AUTH_BASE_URL, @@ -192,5 +192,5 @@ app.listen(PORT, HOST, () => { console.log(`Grid example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*)`); - console.log(` Database -> ${STDB_APP_DB}`); + console.log(` Database -> ${DB_NAME}`); }); diff --git a/spacetime-grid-ts/example/spacetimedb/package.json b/spacetime-grid-ts/example/spacetimedb/package.json index 495f86f9db1..320a2c4876f 100644 --- a/spacetime-grid-ts/example/spacetimedb/package.json +++ b/spacetime-grid-ts/example/spacetimedb/package.json @@ -15,7 +15,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-grid-ts/package.json b/spacetime-grid-ts/package.json index 4285e6cbaba..f94423ce5ed 100644 --- a/spacetime-grid-ts/package.json +++ b/spacetime-grid-ts/package.json @@ -64,9 +64,9 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", - "tsx": "^4.22.3", + "tsx": "^4.21.0", "typescript": "^5.9.3" } } diff --git a/spacetime-lobby-ts/example/.env.example b/spacetime-lobby-ts/example/.env.example index e773b86eccf..12d8c0dacda 100644 --- a/spacetime-lobby-ts/example/.env.example +++ b/spacetime-lobby-ts/example/.env.example @@ -1,4 +1,4 @@ HOST=127.0.0.1 PORT=8797 STDB_URI=ws://127.0.0.1:3000 -STDB_DATABASE=spacetime-lobby-example +SPACETIMEDB_DB_NAME=spacetime-lobby-example diff --git a/spacetime-lobby-ts/example/README.md b/spacetime-lobby-ts/example/README.md index 7dc9b71f043..cb5c5ec1da8 100644 --- a/spacetime-lobby-ts/example/README.md +++ b/spacetime-lobby-ts/example/README.md @@ -72,12 +72,12 @@ simulation are application-specific demonstration code. ## Configuration -| Variable | Default | Purpose | -| --------------- | ------------------------- | ------------------------------------ | -| `HOST` | `127.0.0.1` | Development web-server bind address. | -| `PORT` | `8797` | Development web-server port. | -| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | -| `STDB_DATABASE` | `spacetime-lobby-example` | Published database name. | +| Variable | Default | Purpose | +| --------------------- | ------------------------- | ------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8797` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `SPACETIMEDB_DB_NAME` | `spacetime-lobby-example` | Published database name. | The Node process serves static files, `GET /api/health`, and browser-safe `GET /api/config`. Matchmaking and combat calls travel directly to SpacetimeDB. diff --git a/spacetime-lobby-ts/example/package.json b/spacetime-lobby-ts/example/package.json index c222a8360eb..6930ea1341c 100644 --- a/spacetime-lobby-ts/example/package.json +++ b/spacetime-lobby-ts/example/package.json @@ -19,8 +19,8 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-lobby-ts/example/server.ts b/spacetime-lobby-ts/example/server.ts index 5cbee4cedfe..447b78e7755 100644 --- a/spacetime-lobby-ts/example/server.ts +++ b/spacetime-lobby-ts/example/server.ts @@ -25,23 +25,23 @@ loadEnv(path.resolve(__dirname, '.env'), true); const PORT = Number.parseInt(process.env.PORT ?? '8797', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; -const STDB_DATABASE = process.env.STDB_DATABASE ?? 'spacetime-lobby-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-lobby-example'; const app = express(); app.use(express.json({ limit: '128kb' })); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, database: STDB_DATABASE }); + res.json({ ok: true, database: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { - res.json({ stdbUri: STDB_URI, database: STDB_DATABASE }); + res.json({ stdbUri: STDB_URI, database: DB_NAME }); }); app.listen(PORT, HOST, () => { process.stdout.write( `\nspacetime-lobby-example listening on http://${HOST}:${PORT}\n` ); - process.stdout.write(` database: ${STDB_URI}/${STDB_DATABASE}\n\n`); + process.stdout.write(` database: ${STDB_URI}/${DB_NAME}\n\n`); }); diff --git a/spacetime-lobby-ts/example/spacetimedb/package.json b/spacetime-lobby-ts/example/spacetimedb/package.json index 8f9e7d95421..e6a98fd7766 100644 --- a/spacetime-lobby-ts/example/spacetimedb/package.json +++ b/spacetime-lobby-ts/example/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-lobby-ts/package.json b/spacetime-lobby-ts/package.json index 5b0bc8f2231..3b2e0b2f0ec 100644 --- a/spacetime-lobby-ts/package.json +++ b/spacetime-lobby-ts/package.json @@ -46,7 +46,7 @@ "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", "typecheck": "tsc --noEmit", "test": "tsx scripts/test.ts", - "generate-ts": "spacetime generate --lang typescript --out-dir ts-codegen", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", "publish:module": "spacetime publish", "publish:local": "spacetime publish --server local --yes spacetime-lobby", "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-lobby" @@ -57,7 +57,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-lobby-ts/src/index.ts b/spacetime-lobby-ts/src/index.ts index 922cb272a5d..d6be0038edf 100644 --- a/spacetime-lobby-ts/src/index.ts +++ b/spacetime-lobby-ts/src/index.ts @@ -1,5 +1,3 @@ -// Registered SpacetimeDB exports for direct module publication. - export { default, init } from './submodule/schema'; export { add_admin_identity, diff --git a/spacetime-posthog-ts/example/.env.example b/spacetime-posthog-ts/example/.env.example index 9edb0dee52c..cac3b0b94ae 100644 --- a/spacetime-posthog-ts/example/.env.example +++ b/spacetime-posthog-ts/example/.env.example @@ -2,7 +2,7 @@ PORT=8796 HOST=127.0.0.1 STDB_URI=ws://127.0.0.1:3000 STDB_HTTP=http://127.0.0.1:3000 -STDB_DATABASE=spacetime-posthog-example +SPACETIMEDB_DB_NAME=spacetime-posthog-example # Optional. When unset, the server creates a persistent local identity token in # .stdb-server-token and the logged-in publishing identity authorizes it. # STDB_SERVER_TOKEN= diff --git a/spacetime-posthog-ts/example/README.md b/spacetime-posthog-ts/example/README.md index 8617af7a12f..03f03e0f0ec 100644 --- a/spacetime-posthog-ts/example/README.md +++ b/spacetime-posthog-ts/example/README.md @@ -82,7 +82,7 @@ its event catalog are demonstration code. | `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host. | | `STDB_URI` | `ws://127.0.0.1:3000` | Browser and server WebSocket endpoint. | | `STDB_HTTP` | `http://127.0.0.1:3000` | CLI administration endpoint. Must address the same server as `STDB_URI`. | -| `STDB_DATABASE` | `spacetime-posthog-example` | Published database name. | +| `SPACETIMEDB_DB_NAME` | `spacetime-posthog-example` | Published database name. | | `STDB_SERVER_TOKEN` | generated locally | Optional pre-provisioned server identity token. | | `HOST` | `127.0.0.1` | Static-server bind address. | | `PORT` | `8796` | Static-server port. | diff --git a/spacetime-posthog-ts/example/package.json b/spacetime-posthog-ts/example/package.json index cb37aced710..7f2d3cf8dd9 100644 --- a/spacetime-posthog-ts/example/package.json +++ b/spacetime-posthog-ts/example/package.json @@ -19,8 +19,8 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-posthog-ts/example/server.ts b/spacetime-posthog-ts/example/server.ts index efc53743d16..e13bf8fa26e 100644 --- a/spacetime-posthog-ts/example/server.ts +++ b/spacetime-posthog-ts/example/server.ts @@ -37,7 +37,7 @@ const PORT = Number.parseInt(process.env.PORT ?? '8796', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; -const STDB_DB = process.env.STDB_DATABASE ?? 'spacetime-posthog-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-posthog-example'; const POSTHOG_HOST = process.env.POSTHOG_HOST ?? 'https://us.i.posthog.com'; const POSTHOG_PROJECT_API_KEY = process.env.POSTHOG_PROJECT_API_KEY ?? ''; const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime'; @@ -57,7 +57,7 @@ function connectAttempt(token: string | undefined): Promise { return new Promise((resolve, reject) => { let builder = DbConnection.builder() .withUri(STDB_URI) - .withDatabaseName(STDB_DB) + .withDatabaseName(DB_NAME) .onConnect((connection, identity, nextToken) => { if (!process.env.STDB_SERVER_TOKEN?.trim()) { saveServerToken(SERVER_TOKEN_PATH, nextToken); @@ -100,7 +100,7 @@ function callSpacetime(procedureName: string, ...args: unknown[]): void { 'call', '--server', STDB_HTTP, - STDB_DB, + DB_NAME, procedureName, ...args.map(arg => JSON.stringify(arg)), ], @@ -206,26 +206,26 @@ app.use(express.json({ limit: '256kb' })); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, database: STDB_DB }); + res.json({ ok: true, database: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { res.json({ stdbUri: STDB_URI, - database: STDB_DB, + database: DB_NAME, posthogAppUrl: POSTHOG_PROJECT_API_KEY ? posthogAppUrl() : null, }); }); (async () => { - console.log(`[stdb] connecting to ${STDB_URI}/${STDB_DB} ...`); + console.log(`[stdb] connecting to ${STDB_URI}/${DB_NAME} ...`); try { const connected = await connectStdb(); stdb = connected.connection; grantServerIdentity({ spacetimeBin: SPACETIME_BIN, server: STDB_HTTP, - database: STDB_DB, + database: DB_NAME, procedure: 'posthog.add_admin_identity', identity: connected.identity, }); @@ -272,6 +272,6 @@ app.get('/api/config', (_req: Request, res: Response) => { ); } process.stdout.write(` spacetime: ${SPACETIME_BIN}\n`); - process.stdout.write(` database: ${STDB_URI}/${STDB_DB}\n\n`); + process.stdout.write(` database: ${STDB_URI}/${DB_NAME}\n\n`); }); })(); diff --git a/spacetime-posthog-ts/example/spacetimedb/package.json b/spacetime-posthog-ts/example/spacetimedb/package.json index f755927b02c..275b7be1f80 100644 --- a/spacetime-posthog-ts/example/spacetimedb/package.json +++ b/spacetime-posthog-ts/example/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-posthog-ts/package.json b/spacetime-posthog-ts/package.json index d3c73c1f9fd..da73748f2b1 100644 --- a/spacetime-posthog-ts/package.json +++ b/spacetime-posthog-ts/package.json @@ -46,7 +46,7 @@ "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", "typecheck": "tsc --noEmit", "test": "tsx scripts/test.ts", - "generate-ts": "spacetime generate --lang typescript --out-dir ts-codegen", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", "publish:module": "spacetime publish", "publish:local": "spacetime publish --server local --yes spacetime-posthog", "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-posthog" @@ -57,7 +57,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-posthog-ts/src/index.ts b/spacetime-posthog-ts/src/index.ts index 1643c75fef7..58964a6825c 100644 --- a/spacetime-posthog-ts/src/index.ts +++ b/spacetime-posthog-ts/src/index.ts @@ -1,5 +1,3 @@ -// Registered SpacetimeDB exports for direct module publication. - export { default, init } from './submodule/schema'; export { set_posthog_config, diff --git a/spacetime-presence-ts/example/.env.example b/spacetime-presence-ts/example/.env.example index b8f84760c7a..56b03fdb217 100644 --- a/spacetime-presence-ts/example/.env.example +++ b/spacetime-presence-ts/example/.env.example @@ -8,7 +8,7 @@ PORT=8794 STDB_URI=ws://127.0.0.1:3000 STDB_HTTP=http://127.0.0.1:3000 STDB_SERVER=http://127.0.0.1:3000 -STDB_APP_DATABASE=spacetime-presence-example +SPACETIMEDB_DB_NAME=spacetime-presence-example # ---------------- Auth ---------------- # Issuer URL is what gets embedded in the JWT and used for OAuth redirect diff --git a/spacetime-presence-ts/example/README.md b/spacetime-presence-ts/example/README.md index 69dca03e57c..259ba41639d 100644 --- a/spacetime-presence-ts/example/README.md +++ b/spacetime-presence-ts/example/README.md @@ -79,7 +79,7 @@ rooms, messages, and reactions belong to the host application. | `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | | `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth/file proxy. | | `STDB_SERVER` | `STDB_HTTP` | CLI target used during startup configuration. | -| `STDB_APP_DATABASE` | `spacetime-presence-example` | Published database name. | +| `SPACETIMEDB_DB_NAME` | `spacetime-presence-example` | Published database name. | | `AUTH_ISSUER_URL` | `http://localhost:8794` | JWT issuer and OAuth redirect origin. | | `AUTH_BASE_URL` | `AUTH_ISSUER_URL` | Browser-visible auth base URL. | | `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | diff --git a/spacetime-presence-ts/example/package.json b/spacetime-presence-ts/example/package.json index 9fc6cc3bcb8..30691642ac4 100644 --- a/spacetime-presence-ts/example/package.json +++ b/spacetime-presence-ts/example/package.json @@ -19,8 +19,8 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-presence-ts/example/public/assets/brand.svg b/spacetime-presence-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-presence-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-presence-ts/example/server.ts b/spacetime-presence-ts/example/server.ts index eb7962e4575..a63024708d1 100644 --- a/spacetime-presence-ts/example/server.ts +++ b/spacetime-presence-ts/example/server.ts @@ -32,8 +32,7 @@ const PORT = Number.parseInt(process.env.PORT ?? '8794', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; -const STDB_APP_DB = - process.env.STDB_APP_DATABASE ?? 'spacetime-presence-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-presence-example'; const AUTH_ISSUER_URL = process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; @@ -78,7 +77,7 @@ function configureAuthFromEnv(): void { const result = spawnSync( SPACETIME_BIN, - ['call', '--server', STDB_SERVER, STDB_APP_DB, 'set_auth_config', ...args], + ['call', '--server', STDB_SERVER, DB_NAME, 'set_auth_config', ...args], { stdio: 'inherit', shell: false } ); if (result.status !== 0) { @@ -100,7 +99,7 @@ function proxyStdbRoute(prefix: string) { const qIdx = fullPath.indexOf('?'); const routePath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); const query = qIdx < 0 ? '' : fullPath.slice(qIdx); - const upstreamUrl = `${STDB_HTTP}/v1/database/${STDB_APP_DB}/route${routePath}${query}`; + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${routePath}${query}`; const headers: Record = {}; for (const [k, v] of Object.entries(req.headers)) { @@ -149,13 +148,13 @@ app.use('/auth', proxyStdbRoute('/auth')); app.use('/files', proxyStdbRoute('/files')); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, app: STDB_APP_DB }); + res.json({ ok: true, app: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { res.json({ stdbUri: STDB_URI, - appDatabase: STDB_APP_DB, + appDatabase: DB_NAME, auth: { issuerUrl: AUTH_ISSUER_URL, baseUrl: AUTH_BASE_URL, @@ -198,5 +197,5 @@ app.listen(PORT, HOST, () => { console.log(`Chat example running at http://${HOST}:${PORT}`); console.log(` STDB ws -> ${STDB_URI}`); console.log(` STDB http-> ${STDB_HTTP} (proxy /auth/*, /files)`); - console.log(` Database -> ${STDB_APP_DB}`); + console.log(` Database -> ${DB_NAME}`); }); diff --git a/spacetime-presence-ts/example/spacetimedb/package.json b/spacetime-presence-ts/example/spacetimedb/package.json index 5751003f618..2762c9d48d2 100644 --- a/spacetime-presence-ts/example/spacetimedb/package.json +++ b/spacetime-presence-ts/example/spacetimedb/package.json @@ -15,7 +15,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-presence-ts/package.json b/spacetime-presence-ts/package.json index 5f0707335d3..cecf3855096 100644 --- a/spacetime-presence-ts/package.json +++ b/spacetime-presence-ts/package.json @@ -60,7 +60,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-presence-ts/spacetimedb/package.json b/spacetime-presence-ts/spacetimedb/package.json index c1b056b0161..6d1bae6c1a3 100644 --- a/spacetime-presence-ts/spacetimedb/package.json +++ b/spacetime-presence-ts/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-presence-ts/src/mounted/index.ts b/spacetime-presence-ts/src/mounted/index.ts index 39bb7b0d8d1..63dc1d08f23 100644 --- a/spacetime-presence-ts/src/mounted/index.ts +++ b/spacetime-presence-ts/src/mounted/index.ts @@ -20,31 +20,20 @@ import { updatePresenceConfig, upsertPresence, } from '../index'; +import { + presenceConfigRow, + presenceEntryRow, + presenceSweepTickRow, +} from '../tables'; const presenceEntry = table( { name: 'presence_entry', public: true }, - { - key: t.string().primaryKey(), - scope: t.string().index(), - subject: t.string().index(), - status: t.string().index(), - activity: t.option(t.string()), - payloadJson: t.option(t.string()), - joinedAt: t.timestamp().index(), - lastSeenAt: t.timestamp().index(), - expiresAt: t.timestamp().index(), - updatedAt: t.timestamp(), - } + presenceEntryRow ); const presenceConfig = table( { name: 'presence_config', public: true }, - { - singleton: t.bool().primaryKey(), - defaultTtlSeconds: t.u32(), - sweepBatch: t.u32(), - updatedAt: t.timestamp(), - } + presenceConfigRow ); const presenceAdminIdentity = table( @@ -57,10 +46,7 @@ const presenceAdminIdentity = table( const presenceSweepTick = table( { name: 'presence_sweep_tick' }, - { - scheduledId: t.u64().primaryKey().autoInc(), - scheduledAt: t.scheduleAt(), - } + presenceSweepTickRow ); const spacetimedb = schema({ diff --git a/spacetime-rate-limit-ts/example/.env.example b/spacetime-rate-limit-ts/example/.env.example index 0dd610719b4..e911ba7a27d 100644 --- a/spacetime-rate-limit-ts/example/.env.example +++ b/spacetime-rate-limit-ts/example/.env.example @@ -1,4 +1,4 @@ HOST=127.0.0.1 PORT=8792 STDB_URI=ws://127.0.0.1:3000 -STDB_APP_DATABASE=spacetime-rate-limit-example +SPACETIMEDB_DB_NAME=spacetime-rate-limit-example diff --git a/spacetime-rate-limit-ts/example/README.md b/spacetime-rate-limit-ts/example/README.md index 522d02f1789..5c4ef82aae1 100644 --- a/spacetime-rate-limit-ts/example/README.md +++ b/spacetime-rate-limit-ts/example/README.md @@ -70,12 +70,12 @@ heat model are application code. ## Configuration -| Variable | Default | Purpose | -| ------------------- | ------------------------------ | ------------------------------------ | -| `HOST` | `127.0.0.1` | Development web-server bind address. | -| `PORT` | `8792` | Development web-server port. | -| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | -| `STDB_APP_DATABASE` | `spacetime-rate-limit-example` | Published database name. | +| Variable | Default | Purpose | +| --------------------- | ------------------------------ | ------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8792` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `SPACETIMEDB_DB_NAME` | `spacetime-rate-limit-example` | Published database name. | The Node process serves static files, `GET /api/health`, and browser-safe `GET /api/config`. Gameplay calls go directly from the browser to SpacetimeDB. diff --git a/spacetime-rate-limit-ts/example/package.json b/spacetime-rate-limit-ts/example/package.json index e2c369b567d..191eb30c2b2 100644 --- a/spacetime-rate-limit-ts/example/package.json +++ b/spacetime-rate-limit-ts/example/package.json @@ -19,8 +19,8 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-rate-limit-ts/example/server.ts b/spacetime-rate-limit-ts/example/server.ts index b66aa149583..978c2ba5743 100644 --- a/spacetime-rate-limit-ts/example/server.ts +++ b/spacetime-rate-limit-ts/example/server.ts @@ -11,8 +11,8 @@ const __dirname = path.dirname(__filename); const PORT = Number.parseInt(process.env.PORT ?? '8792', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; -const STDB_APP_DB = - process.env.STDB_APP_DATABASE ?? 'spacetime-rate-limit-example'; +const DB_NAME = + process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-rate-limit-example'; const app = express(); app.use(express.json({ limit: '256kb' })); @@ -27,14 +27,14 @@ app.use( ); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, app: STDB_APP_DB }); + res.json({ ok: true, app: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { - res.json({ stdbUri: STDB_URI, appDatabase: STDB_APP_DB }); + res.json({ stdbUri: STDB_URI, appDatabase: DB_NAME }); }); app.listen(PORT, HOST, () => { console.log(`Rate-limit example running at http://${HOST}:${PORT}`); - console.log(` STDB -> ${STDB_URI} (${STDB_APP_DB})`); + console.log(` STDB -> ${STDB_URI} (${DB_NAME})`); }); diff --git a/spacetime-rate-limit-ts/example/spacetimedb/package.json b/spacetime-rate-limit-ts/example/spacetimedb/package.json index e699cda6b13..a3962a12a79 100644 --- a/spacetime-rate-limit-ts/example/spacetimedb/package.json +++ b/spacetime-rate-limit-ts/example/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-rate-limit-ts/package.json b/spacetime-rate-limit-ts/package.json index 6849acab585..c828b291e19 100644 --- a/spacetime-rate-limit-ts/package.json +++ b/spacetime-rate-limit-ts/package.json @@ -56,7 +56,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-rate-limit-ts/spacetimedb/package.json b/spacetime-rate-limit-ts/spacetimedb/package.json index 017abaf1ca1..e1bc09577f9 100644 --- a/spacetime-rate-limit-ts/spacetimedb/package.json +++ b/spacetime-rate-limit-ts/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-rate-limit-ts/src/submodule/operations.ts b/spacetime-rate-limit-ts/src/submodule/operations.ts index 6d2e0f52163..bda705dcaad 100644 --- a/spacetime-rate-limit-ts/src/submodule/operations.ts +++ b/spacetime-rate-limit-ts/src/submodule/operations.ts @@ -42,7 +42,7 @@ function requireAdmin(ctx: ReducerModuleCtx): void { function toU32(name: string, value: number, max = 0xffff_ffff): number { if (!Number.isInteger(value) || value <= 0 || value > max) { - throw new Error(`rate_limit.invalid_${name}`); + throw new SenderError(`rate_limit.invalid_${name}`); } return value; } diff --git a/spacetime-resend-ts/example/.env.example b/spacetime-resend-ts/example/.env.example index fd1d523da42..c162013b626 100644 --- a/spacetime-resend-ts/example/.env.example +++ b/spacetime-resend-ts/example/.env.example @@ -15,7 +15,7 @@ RESEND_ALLOWED_RECIPIENTS=you@example.com # ---------------- SpacetimeDB ---------------- STDB_URI=ws://127.0.0.1:3000 STDB_HTTP=http://127.0.0.1:3000 -STDB_DATABASE=spacetime-resend-example +SPACETIMEDB_DB_NAME=spacetime-resend-example # Optional. When unset, the server creates a persistent local identity token in # .stdb-server-token and the logged-in publishing identity authorizes it. # STDB_SERVER_TOKEN= diff --git a/spacetime-resend-ts/example/README.md b/spacetime-resend-ts/example/README.md index 9615a9224e8..29f10cf4b8a 100644 --- a/spacetime-resend-ts/example/README.md +++ b/spacetime-resend-ts/example/README.md @@ -84,7 +84,7 @@ replace the example's development identity bootstrap in production. | `DEFAULT_FROM` | `onboarding@resend.dev` | Default sender; use a verified address outside Resend's test flow. | | `STDB_URI` | `ws://127.0.0.1:3000` | Browser and server WebSocket endpoint. | | `STDB_HTTP` | `http://127.0.0.1:3000` | CLI and native-route HTTP endpoint. Must match `STDB_URI`. | -| `STDB_DATABASE` | `spacetime-resend-example` | Published database name. | +| `SPACETIMEDB_DB_NAME` | `spacetime-resend-example` | Published database name. | | `STDB_SERVER_TOKEN` | generated locally | Optional pre-provisioned server identity token. | | `HOST` | `127.0.0.1` | Static-server bind address. | | `PORT` | `8790` | Static-server port. | diff --git a/spacetime-resend-ts/example/package.json b/spacetime-resend-ts/example/package.json index 634b317db32..c046372a183 100644 --- a/spacetime-resend-ts/example/package.json +++ b/spacetime-resend-ts/example/package.json @@ -19,11 +19,11 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "resend": "^6.12.2", "tsx": "^4.21.0", - "typescript": "^6.0.3" + "typescript": "^5.9.3" } } diff --git a/spacetime-resend-ts/example/server.ts b/spacetime-resend-ts/example/server.ts index 158f8973450..525afd7a197 100644 --- a/spacetime-resend-ts/example/server.ts +++ b/spacetime-resend-ts/example/server.ts @@ -42,7 +42,7 @@ const PORT = Number.parseInt(process.env.PORT ?? '8790', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; -const STDB_DB = process.env.STDB_DATABASE ?? 'spacetime-resend-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-resend-example'; const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime'; const RESEND_API_KEY = process.env.RESEND_API_KEY ?? ''; const RESEND_WEBHOOK_SECRET = process.env.RESEND_WEBHOOK_SECRET ?? ''; @@ -75,7 +75,7 @@ function connectAttempt(token: string | undefined): Promise { return new Promise((resolve, reject) => { let builder = DbConnection.builder() .withUri(STDB_URI) - .withDatabaseName(STDB_DB) + .withDatabaseName(DB_NAME) .onConnect((connection, identity, nextToken) => { if (!process.env.STDB_SERVER_TOKEN?.trim()) { saveServerToken(SERVER_TOKEN_PATH, nextToken); @@ -129,13 +129,13 @@ app.use(express.json({ limit: '512kb' })); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, database: STDB_DB }); + res.json({ ok: true, database: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { res.json({ stdbUri: STDB_URI, - database: STDB_DB, + database: DB_NAME, resendConfigured, defaultFrom: DEFAULT_FROM, allowedRecipients: ALLOWED_RECIPIENTS, @@ -147,7 +147,7 @@ app.get('/api/config', (_req: Request, res: Response) => { async function handleResendWebhook(req: Request, res: Response): Promise { const rawBody = req.body instanceof Buffer ? req.body : Buffer.from(String(req.body ?? '')); - const url = `${STDB_HTTP}/v1/database/${STDB_DB}/route/webhook/resend`; + const url = `${STDB_HTTP}/v1/database/${DB_NAME}/route/webhook/resend`; const headers: Record = { 'content-type': 'application/json', @@ -195,14 +195,14 @@ async function bootstrapResendConfig(): Promise { } (async () => { - console.log(`[stdb] connecting to ${STDB_URI}/${STDB_DB} ...`); + console.log(`[stdb] connecting to ${STDB_URI}/${DB_NAME} ...`); try { const connected = await connectStdb(); stdb = connected.connection; grantServerIdentity({ spacetimeBin: SPACETIME_BIN, server: STDB_HTTP, - database: STDB_DB, + database: DB_NAME, procedure: 'resend.add_admin_identity', identity: connected.identity, }); @@ -236,6 +236,6 @@ async function bootstrapResendConfig(): Promise { process.stdout.write( ` webhook endpoint: POST http://127.0.0.1:${PORT}/webhook/resend\n` ); - process.stdout.write(` database: ${STDB_URI}/${STDB_DB}\n\n`); + process.stdout.write(` database: ${STDB_URI}/${DB_NAME}\n\n`); }); })(); diff --git a/spacetime-resend-ts/example/spacetimedb/package.json b/spacetime-resend-ts/example/spacetimedb/package.json index 6bf47b66746..375d324832a 100644 --- a/spacetime-resend-ts/example/spacetimedb/package.json +++ b/spacetime-resend-ts/example/spacetimedb/package.json @@ -14,7 +14,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", - "typescript": "^6.0.3" + "@types/node": "^22.10.2", + "typescript": "^5.9.3" } } diff --git a/spacetime-resend-ts/package.json b/spacetime-resend-ts/package.json index fe39fe9bd49..d28e548afad 100644 --- a/spacetime-resend-ts/package.json +++ b/spacetime-resend-ts/package.json @@ -46,7 +46,7 @@ "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", "typecheck": "tsc --noEmit", "test": "tsx scripts/test-unit.ts", - "generate-ts": "spacetime generate --lang typescript --out-dir ts-codegen", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", "publish:module": "spacetime publish", "publish:local": "spacetime publish --server local --yes spacetime-resend", "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-resend", @@ -63,7 +63,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-resend-ts/src/index.ts b/spacetime-resend-ts/src/index.ts index b9ccdc72c28..d940a097db9 100644 --- a/spacetime-resend-ts/src/index.ts +++ b/spacetime-resend-ts/src/index.ts @@ -1,5 +1,3 @@ -// Registered SpacetimeDB exports for direct module publication. - export { default, init } from './submodule/schema'; export { ingest_resend_webhook, diff --git a/spacetime-retry-ts/package.json b/spacetime-retry-ts/package.json index c6ec8edde6c..01fe9a89565 100644 --- a/spacetime-retry-ts/package.json +++ b/spacetime-retry-ts/package.json @@ -56,7 +56,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/spacetime-retry-ts/spacetimedb/package.json b/spacetime-retry-ts/spacetimedb/package.json index 10d92ea39cc..2538269257d 100644 --- a/spacetime-retry-ts/spacetimedb/package.json +++ b/spacetime-retry-ts/spacetimedb/package.json @@ -13,7 +13,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-stripe-ts/example/.env.example b/spacetime-stripe-ts/example/.env.example index 204512d2494..51fd88a7224 100644 --- a/spacetime-stripe-ts/example/.env.example +++ b/spacetime-stripe-ts/example/.env.example @@ -22,7 +22,7 @@ NODE_ENV=development # Optional: override DB target. STDB_URI=ws://127.0.0.1:3000 STDB_HTTP=http://127.0.0.1:3000 -STDB_DATABASE=spacetime-stripe-example +SPACETIMEDB_DB_NAME=spacetime-stripe-example # Optional. When unset, the server creates a persistent local identity token in # .stdb-server-token and the logged-in publishing identity authorizes it. # STDB_SERVER_TOKEN= diff --git a/spacetime-stripe-ts/example/README.md b/spacetime-stripe-ts/example/README.md index d80e95dd06d..6a8f078564f 100644 --- a/spacetime-stripe-ts/example/README.md +++ b/spacetime-stripe-ts/example/README.md @@ -90,7 +90,7 @@ webhook route. The product catalog and storefront are demonstration code. | `NODE_ENV` | empty | Set to `production` to disable development-only defaults. | | `STDB_URI` | `ws://127.0.0.1:3000` | Browser and server WebSocket endpoint. | | `STDB_HTTP` | `http://127.0.0.1:3000` | CLI administration endpoint. Must match `STDB_URI`. | -| `STDB_DATABASE` | `spacetime-stripe-example` | Published database name. | +| `SPACETIMEDB_DB_NAME` | `spacetime-stripe-example` | Published database name. | | `STDB_SERVER_TOKEN` | generated locally | Optional pre-provisioned server identity token. | | `HOST` | `127.0.0.1` | Static-server bind address. | | `PORT` | `8787` | Static-server port. | diff --git a/spacetime-stripe-ts/example/package.json b/spacetime-stripe-ts/example/package.json index 2bf90708860..8a301cd12c0 100644 --- a/spacetime-stripe-ts/example/package.json +++ b/spacetime-stripe-ts/example/package.json @@ -18,10 +18,10 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", "esbuild": "^0.28.0", "tsx": "^4.21.0", - "typescript": "^6.0.3" + "typescript": "^5.9.3" } } diff --git a/spacetime-stripe-ts/example/public/assets/logo.svg b/spacetime-stripe-ts/example/public/assets/logo.svg deleted file mode 100644 index adaf36cbf9c..00000000000 --- a/spacetime-stripe-ts/example/public/assets/logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/spacetime-stripe-ts/example/public/ui.js b/spacetime-stripe-ts/example/public/ui.js index 5aefee812e2..08c41ea3540 100644 --- a/spacetime-stripe-ts/example/public/ui.js +++ b/spacetime-stripe-ts/example/public/ui.js @@ -11,6 +11,18 @@ const state = { const byId = id => document.getElementById(id); +const HTML_ESCAPE = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, character => HTML_ESCAPE[character]); +} + const ui = { btnCart: byId('btnCart'), btnSettings: byId('btnSettings'), @@ -326,6 +338,9 @@ function renderCartList() { const unitPrice = parseAmountFromPriceLabel(item.priceLabel); const lineTotal = unitPrice * item.quantity; const cartKey = getCartKey(item.id, item.priceId); + const itemId = escapeHtml(item.id); + const itemName = escapeHtml(item.name); + const priceId = escapeHtml(item.priceId); const stepperClass = state.animatedStepperKey === cartKey ? ' card-stepper bump cart-stepper' @@ -335,15 +350,15 @@ function renderCartList() {
          -
          ${item.name}
          +
          ${itemName}
          ${item.mode === 'subscription' ? 'Subscription' : 'One-time payment'}
          ${formatUsd(unitPrice)} x ${item.quantity} = ${formatUsd(lineTotal)}${item.mode === 'subscription' ? ' /mo' : ''}
          - +
          ${item.quantity} in cart
          - +
          @@ -410,23 +425,29 @@ function renderCatalog() { ? 'card-stepper bump' : 'card-stepper'; const isMissingPrice = !item.priceId; + const itemId = escapeHtml(item.id); + const itemName = escapeHtml(item.name); + const itemDescription = escapeHtml(item.description); + const priceId = escapeHtml(item.priceId); + const priceLabel = escapeHtml(item.priceLabel); + const badge = merch?.badge ? escapeHtml(merch.badge) : ''; return ` -
          +
          - ${merch?.badge ? `${merch.badge}` : ''} + ${badge ? `${badge}` : ''} ${purchaseType}
          -

          ${item.name}

          -

          ${item.description}

          +

          ${itemName}

          +

          ${itemDescription}

          ${rating.toFixed(1)}${stars}(${reviews})
          -
          ${item.priceLabel}
          +
          ${priceLabel}
          ${compareAt ? `
          List: ${formatUsd(compareAt)}
          ` : ''}
          @@ -435,14 +456,14 @@ function renderCatalog() { inCartQty > 0 ? `
          - +
          ${inCartQty} in cart
          - +
          ` : isMissingPrice ? '' - : `` + : `` }
          diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts index 0e01c46c393..8f71d6fbe4e 100644 --- a/spacetime-stripe-ts/example/server.ts +++ b/spacetime-stripe-ts/example/server.ts @@ -27,7 +27,7 @@ const PORT = Number.parseInt(process.env.PORT ?? '8787', 10); const HOST = process.env.HOST?.trim() || '127.0.0.1'; const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; -const STDB_DATABASE = process.env.STDB_DATABASE ?? 'spacetime-stripe-example'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-stripe-example'; const NODE_ENV = (process.env.NODE_ENV ?? '').replace(/^['"]|['"]$/g, ''); const IS_PRODUCTION = NODE_ENV === 'production'; const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime'; @@ -126,7 +126,7 @@ function connectAttempt(token: string | undefined): Promise { return new Promise((resolve, reject) => { let builder = DbConnection.builder() .withUri(STDB_URI) - .withDatabaseName(STDB_DATABASE) + .withDatabaseName(DB_NAME) .onConnect((connection, identity, nextToken) => { if (!process.env.STDB_SERVER_TOKEN?.trim()) { saveServerToken(SERVER_TOKEN_PATH, nextToken); @@ -207,14 +207,14 @@ function staticOptions() { } app.get('/api/health', (_req: Request, res: Response) => { - res.json({ ok: true, database: STDB_DATABASE }); + res.json({ ok: true, database: DB_NAME }); }); app.get('/api/config', (_req: Request, res: Response) => { const envStripeSecret = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; res.json({ stdbUri: STDB_URI, - database: STDB_DATABASE, + database: DB_NAME, hasStripeSecretKey: envStripeSecret.length > 0, stripeConfigured, adminEndpointsEnabled: false, @@ -356,23 +356,21 @@ async function seedCatalogIfEmpty(conn: DbConnection): Promise { } (async () => { - console.log( - `[stdb] connecting to ${STDB_URI} (database=${STDB_DATABASE}) ...` - ); + console.log(`[stdb] connecting to ${STDB_URI} (database=${DB_NAME}) ...`); try { const connected = await connectStdb(); stdb = connected.connection; grantServerIdentity({ spacetimeBin: SPACETIME_BIN, server: STDB_HTTP, - database: STDB_DATABASE, + database: DB_NAME, procedure: 'add_admin_identity', identity: connected.identity, }); grantServerIdentity({ spacetimeBin: SPACETIME_BIN, server: STDB_HTTP, - database: STDB_DATABASE, + database: DB_NAME, procedure: 'stripe.add_admin_identity', identity: connected.identity, }); diff --git a/spacetime-stripe-ts/example/spacetimedb/package.json b/spacetime-stripe-ts/example/spacetimedb/package.json index 9e2b4de8251..096c0ea30f3 100644 --- a/spacetime-stripe-ts/example/spacetimedb/package.json +++ b/spacetime-stripe-ts/example/spacetimedb/package.json @@ -14,7 +14,7 @@ "spacetimedb": "workspace:*" }, "devDependencies": { - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "typescript": "^5.9.3" } } diff --git a/spacetime-stripe-ts/package.json b/spacetime-stripe-ts/package.json index 2840d93e6b3..e2ad55ec51e 100644 --- a/spacetime-stripe-ts/package.json +++ b/spacetime-stripe-ts/package.json @@ -46,7 +46,7 @@ "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", "typecheck": "tsc --noEmit", "test": "tsx scripts/test-unit.ts", - "generate-ts": "spacetime generate --lang typescript --out-dir ts-codegen", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", "publish:module": "spacetime publish", "publish:local": "spacetime publish --server local --yes spacetime-stripe", "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-stripe", @@ -65,7 +65,7 @@ "devDependencies": { "eslint": "^9.17.0", "prettier": "^3.3.3", - "@types/node": "^25.6.0", + "@types/node": "^22.10.2", "spacetimedb": "workspace:*", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/tools/release-check.mjs b/tools/release-check.mjs index 81411166860..0f56b73f306 100644 --- a/tools/release-check.mjs +++ b/tools/release-check.mjs @@ -313,6 +313,27 @@ for (const packageDir of releasePackages) { fail(packageDir, 'typecheck script is required'); if (!manifest.scripts?.test) fail(packageDir, 'a test script is required'); + const rootSource = readFileSync(resolve(directory, 'src/index.ts'), 'utf8'); + const isStandaloneModule = + /export\s*\{(?=[^}]*\bdefault\b)(?=[^}]*\binit\b)[^}]*\}/s.test(rootSource); + if (isStandaloneModule) { + if (manifest.scripts?.build !== 'spacetime build') { + fail( + packageDir, + 'standalone module packages must provide a spacetime build script' + ); + } + if ( + manifest.scripts?.['spacetime:generate'] !== + 'spacetime generate --lang typescript --out-dir ts-codegen' + ) { + fail( + packageDir, + 'standalone module packages must provide the standard spacetime:generate script' + ); + } + } + for (const requiredFile of ['src', 'README.md', 'LICENSE.txt']) { if (!manifest.files?.includes(requiredFile)) fail(packageDir, `files must include ${requiredFile}`); diff --git a/tools/run-example-smokes.mjs b/tools/run-example-smokes.mjs index e5f4d19a01f..419a6dd7b31 100644 --- a/tools/run-example-smokes.mjs +++ b/tools/run-example-smokes.mjs @@ -118,8 +118,7 @@ function smokeEnvironment(example) { STDB_URI: 'ws://127.0.0.1:3000', STDB_HTTP: 'http://127.0.0.1:3000', STDB_SERVER: 'http://127.0.0.1:3000', - STDB_DATABASE: example.database, - STDB_APP_DATABASE: example.database, + SPACETIMEDB_DB_NAME: example.database, AUTH_ISSUER_URL: `http://127.0.0.1:${example.port}`, AUTH_BASE_URL: `http://127.0.0.1:${example.port}`, AUTH_COOKIE_NAME: 'stdb_auth', From ed62991144aba9d8fd34eb59b3abbc0dbbf1499d Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 25 Aug 2026 21:04:09 -0400 Subject: [PATCH 15/33] Fix optional CLI arguments in provider docs --- spacetime-resend-ts/README.md | 12 +++++++----- spacetime-stripe-ts/README.md | 5 ++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/spacetime-resend-ts/README.md b/spacetime-resend-ts/README.md index 67543b8d458..05987075414 100644 --- a/spacetime-resend-ts/README.md +++ b/spacetime-resend-ts/README.md @@ -51,11 +51,13 @@ fresh database seeds the owner into the private `resend_admin_identity` table. ```bash spacetime call --server http://127.0.0.1:3000 resend-ts set_resend_config \ '"re_..."' \ - '"whsec_..."' \ - '"onboarding@resend.dev"' + '{"some":"whsec_..."}' \ + '{"some":"onboarding@resend.dev"}' ``` Args: `apiKey`, `webhookSigningSecret` (required for webhook ingest), `defaultFrom` (optional). +For `t.option(...)` CLI arguments, use `null` for no value and +`{"some":"value"}` for a string value. Verify: @@ -196,16 +198,16 @@ For real Resend test-mode: ```bash # Bootstrap once with your real key: -spacetime call --server http://127.0.0.1:3000 resend-ts set_resend_config '"re_..."' null '"onboarding@resend.dev"' +spacetime call --server http://127.0.0.1:3000 resend-ts set_resend_config '"re_..."' null '{"some":"onboarding@resend.dev"}' # Then send to one of Resend's test addresses (delivered@/bounced@/complained@): spacetime call --server http://127.0.0.1:3000 resend-ts send_email \ null \ '["delivered@resend.dev"]' \ '"Test from SpacetimeDB"' \ - '"

          Hello.

          "' \ + '{"some":"

          Hello.

          "}' \ null null null null \ - '"{\"userId\":\"u_123\"}"' \ + '{"some":"{\"userId\":\"u_123\"}"}' \ null null null ``` diff --git a/spacetime-stripe-ts/README.md b/spacetime-stripe-ts/README.md index de893f7f88f..a89a93870d6 100644 --- a/spacetime-stripe-ts/README.md +++ b/spacetime-stripe-ts/README.md @@ -89,9 +89,12 @@ fresh database seeds the owner into the private `stripe_admin_identity` table. spacetime call --server http://127.0.0.1:3000 stripe-ts set_stripe_config \ '"sk_test_..."' \ null \ - '"whsec_..."' # webhook signing secret, optional + '{"some":"whsec_..."}' # webhook signing secret, optional ``` +For `t.option(...)` CLI arguments, use `null` for no value and +`{"some":"value"}` for a string value. + Verify: ```bash From 77498f2547746274e1b48261088f7dcb8ff4ac55 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 09:12:56 -0400 Subject: [PATCH 16/33] Use standard workspace scripts for submodules --- package.json | 25 +- pnpm-lock.yaml | 21 - spacetime-agents-ts/example/README.md | 2 +- spacetime-agents-ts/example/package.json | 2 +- .../example/spacetimedb/package.json | 2 +- spacetime-api-keys-ts/example/package.json | 2 +- spacetime-files-ts/example/package.json | 2 +- spacetime-grid-ts/example/package.json | 2 +- spacetime-lobby-ts/example/package.json | 2 +- spacetime-posthog-ts/example/package.json | 2 +- spacetime-presence-ts/example/package.json | 2 +- spacetime-rate-limit-ts/example/package.json | 2 +- spacetime-resend-ts/example/package.json | 2 +- tools/check-spacetime-release.mjs | 39 - tools/consumer-install-check.mjs | 228 ------ tools/doc-check.mjs | 197 ----- tools/release-check.mjs | 484 ------------ tools/release-packages.mjs | 25 - tools/run-example-builds.mjs | 36 - tools/run-example-smokes.mjs | 737 ------------------ tools/run-example-tests.mjs | 39 - tools/run-module-builds.mjs | 49 -- tools/run-package-checks.mjs | 42 - 23 files changed, 16 insertions(+), 1928 deletions(-) delete mode 100644 tools/check-spacetime-release.mjs delete mode 100644 tools/consumer-install-check.mjs delete mode 100644 tools/doc-check.mjs delete mode 100644 tools/release-check.mjs delete mode 100644 tools/release-packages.mjs delete mode 100644 tools/run-example-builds.mjs delete mode 100644 tools/run-example-smokes.mjs delete mode 100644 tools/run-example-tests.mjs delete mode 100644 tools/run-module-builds.mjs delete mode 100644 tools/run-package-checks.mjs diff --git a/package.json b/package.json index 14c445c9036..ea402ecedaa 100644 --- a/package.json +++ b/package.json @@ -7,27 +7,13 @@ }, "type": "module", "scripts": { - "run-all": "pnpm -r -F ./crates/bindings-typescript -F ./crates/bindings-typescript/examples/quickstart-chat -F ./crates/bindings-typescript/test-app -F ./docs -F \"./templates/**\" run", - "format": "pnpm run-all format && pnpm submodules:format && prettier eslint.config.js --write", - "lint": "pnpm run-all lint && pnpm submodules:lint && prettier eslint.config.js --check", + "run-all": "pnpm -r -F ./crates/bindings-typescript -F ./crates/bindings-typescript/examples/quickstart-chat -F ./crates/bindings-typescript/test-app -F ./docs -F \"./templates/**\" -F \"./spacetime-*-ts/**\" run", + "format": "pnpm run-all format && prettier eslint.config.js --write", + "lint": "pnpm run-all lint && prettier eslint.config.js --check", "build": "pnpm run-all build", - "test": "pnpm run-all test && pnpm submodules:test", + "test": "pnpm run-all test", "generate": "pnpm run-all generate", - "clean": "pnpm -r exec rimraf dist .tsbuildinfo coverage", - "submodules:format": "prettier \"spacetime-*-ts/**/*.{js,cjs,mjs,ts,tsx,json,md,html,css,yml,yaml}\" \"tools/{check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks}.mjs\" tools/example-server-identity.ts --write --ignore-path .prettierignore", - "submodules:lint": "node tools/doc-check.mjs && node tools/release-check.mjs && eslint \"tools/{check-spacetime-release,consumer-install-check,doc-check,release-check,release-packages,run-example-builds,run-example-smokes,run-example-tests,run-module-builds,run-package-checks}.mjs\" tools/example-server-identity.ts && pnpm -r --filter \"./spacetime-*-ts\" run lint", - "submodules:test": "node tools/run-package-checks.mjs && node tools/run-example-tests.mjs", - "submodules:toolchain:check": "node tools/check-spacetime-release.mjs", - "submodules:build": "pnpm submodules:toolchain:check && pnpm --dir crates/bindings-typescript run build && node tools/run-module-builds.mjs && node tools/run-example-builds.mjs", - "submodules:consumer:check": "node tools/consumer-install-check.mjs", - "submodules:audit:prod": "node tools/consumer-install-check.mjs --audit", - "submodules:test:cron:local": "pnpm --dir spacetime-cron-ts run test:module:local && pnpm --dir spacetime-cron-ts run test:recovery", - "submodules:browser:install": "playwright install chromium", - "submodules:smoke:examples:http:fresh": "node tools/run-example-smokes.mjs --confirm-delete-data", - "submodules:smoke:examples:ephemeral": "node tools/run-example-smokes.mjs --ephemeral --browser", - "submodules:smoke:examples:fresh": "node tools/run-example-smokes.mjs --confirm-delete-data --browser", - "submodules:check": "pnpm submodules:lint && pnpm submodules:test && pnpm submodules:consumer:check", - "submodules:release:check:local": "pnpm submodules:check && pnpm submodules:build && pnpm submodules:audit:prod && pnpm submodules:test:cron:local && pnpm submodules:smoke:examples:ephemeral" + "clean": "pnpm -r exec rimraf dist .tsbuildinfo coverage" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -39,7 +25,6 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", - "playwright": "1.62.1", "prettier": "^3.3.3", "rimraf": "^6.0.1", "typescript": "~5.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f97bb5822c8..18b971d00d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: globals: specifier: ^15.14.0 version: 15.15.0 - playwright: - specifier: 1.62.1 - version: 1.62.1 prettier: specifier: ^3.3.3 version: 3.6.2 @@ -12838,16 +12835,6 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} - playwright-core@1.62.1: - resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} - engines: {node: '>=20'} - hasBin: true - - playwright@1.62.1: - resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} - engines: {node: '>=20'} - hasBin: true - pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -30277,14 +30264,6 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - playwright-core@1.62.1: {} - - playwright@1.62.1: - dependencies: - playwright-core: 1.62.1 - optionalDependencies: - fsevents: 2.3.2 - pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} diff --git a/spacetime-agents-ts/example/README.md b/spacetime-agents-ts/example/README.md index 02006eb5ce6..e8640d29868 100644 --- a/spacetime-agents-ts/example/README.md +++ b/spacetime-agents-ts/example/README.md @@ -183,7 +183,7 @@ After changing an agent or tool, republish the module and regenerate the client. ```powershell pnpm --dir spacetimedb run build -pnpm --dir spacetimedb run test:unit +pnpm --dir spacetimedb test pnpm run build pnpm exec tsc -p tsconfig.json ``` diff --git a/spacetime-agents-ts/example/package.json b/spacetime-agents-ts/example/package.json index 11035c88d61..ef12b966d6e 100644 --- a/spacetime-agents-ts/example/package.json +++ b/spacetime-agents-ts/example/package.json @@ -8,7 +8,7 @@ "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-agents-example && pnpm run spacetime:generate && pnpm run build:app", "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-agents-example && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", - "test:unit": "node scripts/test-markdown.mjs", + "test": "node scripts/test-markdown.mjs", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" diff --git a/spacetime-agents-ts/example/spacetimedb/package.json b/spacetime-agents-ts/example/spacetimedb/package.json index 231189b37e8..596846d2fa2 100644 --- a/spacetime-agents-ts/example/spacetimedb/package.json +++ b/spacetime-agents-ts/example/spacetimedb/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "spacetime build", "publish:local": "spacetime publish --server local --yes spacetime-agents-example", - "test:unit": "tsx scripts/test-loop.ts" + "test": "tsx scripts/test-loop.ts" }, "dependencies": { "@spacetimedb/agents": "workspace:*", diff --git a/spacetime-api-keys-ts/example/package.json b/spacetime-api-keys-ts/example/package.json index 46bddc583d0..169843eae5d 100644 --- a/spacetime-api-keys-ts/example/package.json +++ b/spacetime-api-keys-ts/example/package.json @@ -8,7 +8,7 @@ "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", - "test:unit": "tsx scripts/test-model.ts && tsx scripts/test-share-key.ts", + "test": "tsx scripts/test-model.ts && tsx scripts/test-share-key.ts", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" diff --git a/spacetime-files-ts/example/package.json b/spacetime-files-ts/example/package.json index 4ff671d446b..2bc198c921b 100644 --- a/spacetime-files-ts/example/package.json +++ b/spacetime-files-ts/example/package.json @@ -9,7 +9,7 @@ "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "check": "tsc --noEmit", - "test:unit": "tsx scripts/test-downloads.ts && tsx scripts/test-selection.ts", + "test": "tsx scripts/test-downloads.ts && tsx scripts/test-selection.ts", "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" }, diff --git a/spacetime-grid-ts/example/package.json b/spacetime-grid-ts/example/package.json index cf5f58ba8d0..5e32ebb1e4d 100644 --- a/spacetime-grid-ts/example/package.json +++ b/spacetime-grid-ts/example/package.json @@ -8,7 +8,7 @@ "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", - "test:unit": "node scripts/test-hex-geometry.mjs", + "test": "node scripts/test-hex-geometry.mjs", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" diff --git a/spacetime-lobby-ts/example/package.json b/spacetime-lobby-ts/example/package.json index 6930ea1341c..f274eb6d40e 100644 --- a/spacetime-lobby-ts/example/package.json +++ b/spacetime-lobby-ts/example/package.json @@ -8,7 +8,7 @@ "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", - "test:unit": "tsx scripts/test-model.ts", + "test": "tsx scripts/test-model.ts", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" diff --git a/spacetime-posthog-ts/example/package.json b/spacetime-posthog-ts/example/package.json index 7f2d3cf8dd9..816fb08c308 100644 --- a/spacetime-posthog-ts/example/package.json +++ b/spacetime-posthog-ts/example/package.json @@ -11,7 +11,7 @@ "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts", - "test:unit": "tsx scripts/test-economy.ts" + "test": "tsx scripts/test-economy.ts" }, "dependencies": { "dotenv": "^16.4.7", diff --git a/spacetime-presence-ts/example/package.json b/spacetime-presence-ts/example/package.json index 30691642ac4..5f73837b232 100644 --- a/spacetime-presence-ts/example/package.json +++ b/spacetime-presence-ts/example/package.json @@ -8,7 +8,7 @@ "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-presence-example && pnpm run spacetime:generate && pnpm run build:app", "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-presence-example && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", - "test:unit": "node scripts/test-ui-model.mjs", + "test": "node scripts/test-ui-model.mjs", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" diff --git a/spacetime-rate-limit-ts/example/package.json b/spacetime-rate-limit-ts/example/package.json index 191eb30c2b2..2ffbc3b47a8 100644 --- a/spacetime-rate-limit-ts/example/package.json +++ b/spacetime-rate-limit-ts/example/package.json @@ -8,7 +8,7 @@ "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", - "test:unit": "tsx scripts/test-reactor-rules.ts", + "test": "tsx scripts/test-reactor-rules.ts", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" diff --git a/spacetime-resend-ts/example/package.json b/spacetime-resend-ts/example/package.json index c046372a183..6349fb2a266 100644 --- a/spacetime-resend-ts/example/package.json +++ b/spacetime-resend-ts/example/package.json @@ -8,7 +8,7 @@ "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", "check": "tsc --noEmit", - "test:unit": "tsx scripts/test-message.ts", + "test": "tsx scripts/test-message.ts", "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", "build": "pnpm run spacetime:generate && pnpm run build:app", "dev": "pnpm run build && tsx server.ts" diff --git a/tools/check-spacetime-release.mjs b/tools/check-spacetime-release.mjs deleted file mode 100644 index 615f791be6e..00000000000 --- a/tools/check-spacetime-release.mjs +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { spacetimedbVersion } from './release-packages.mjs'; - -const command = process.platform === 'win32' ? 'spacetime.exe' : 'spacetime'; -const result = spawnSync(command, ['--version'], { - encoding: 'utf8', - shell: false, -}); - -if (result.error) { - console.error(`Could not run ${command}: ${result.error.message}`); - console.error( - 'Install the released CLI from https://spacetimedb.com/install.' - ); - process.exit(1); -} - -if (result.status !== 0) { - console.error( - result.stderr || result.stdout || `${command} exited with ${result.status}` - ); - process.exit(result.status ?? 1); -} - -const output = `${result.stdout}\n${result.stderr}`.trim(); -const toolVersion = output.match(/spacetimedb tool version\s+([^\s;]+)/)?.[1]; -const libVersion = output.match(/spacetimedb-lib version\s+([^\s;]+)/)?.[1]; - -if (toolVersion !== spacetimedbVersion || libVersion !== spacetimedbVersion) { - console.error(`Expected SpacetimeDB tool and library ${spacetimedbVersion}.`); - console.error(output || 'The CLI did not report version information.'); - console.error(`Run: spacetime version install ${spacetimedbVersion}`); - console.error(`Then: spacetime version use ${spacetimedbVersion}`); - process.exit(1); -} - -console.log(`SpacetimeDB released toolchain ${spacetimedbVersion} is active.`); diff --git a/tools/consumer-install-check.mjs b/tools/consumer-install-check.mjs deleted file mode 100644 index 2f23acdb34a..00000000000 --- a/tools/consumer-install-check.mjs +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { basename, dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { releasePackages, spacetimedbVersion } from './release-packages.mjs'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; -const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'; -const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const spacetimeCommand = - process.platform === 'win32' ? 'spacetime.exe' : 'spacetime'; -const temporaryRoot = mkdtempSync(join(tmpdir(), 'stdb-submodules-consumer-')); -const packDirectory = join(temporaryRoot, 'packs'); -mkdirSync(packDirectory); - -function run(command, args, cwd = temporaryRoot) { - const result = spawnSync(command, args, { - cwd, - encoding: 'utf8', - shell: process.platform === 'win32', - stdio: ['ignore', 'pipe', 'pipe'], - }); - if (result.status !== 0) { - const detail = result.error?.message || result.stderr || result.stdout; - throw new Error(`${command} ${args.join(' ')} failed:\n${detail.trim()}`); - } - return result.stdout; -} - -function packageSpecifier(packageName, exportName) { - return exportName === '.' - ? packageName - : `${packageName}${exportName.slice(1)}`; -} - -try { - const dependencies = { - spacetimedb: spacetimedbVersion, - }; - const importLines = []; - let importIndex = 0; - - for (const packageDirectory of releasePackages) { - const directory = resolve(root, packageDirectory); - const manifest = JSON.parse( - readFileSync(resolve(directory, 'package.json'), 'utf8') - ); - const output = run( - pnpmCommand, - ['pack', '--json', '--pack-destination', packDirectory], - directory - ); - const result = JSON.parse(output); - const tarball = resolve(result.filename); - if (!existsSync(tarball)) - throw new Error(`npm pack did not create ${tarball}`); - dependencies[manifest.name] = `file:./packs/${basename(tarball)}`; - - for (const exportName of Object.keys( - manifest.exports ?? { '.': manifest.main } - )) { - const alias = `packageExport${importIndex}`; - importLines.push( - `import * as ${alias} from '${packageSpecifier(manifest.name, exportName)}';` - ); - importLines.push(`void ${alias};`); - importIndex += 1; - } - } - - writeFileSync( - join(temporaryRoot, 'package.json'), - `${JSON.stringify( - { - name: 'spacetimedb-submodules-consumer-check', - private: true, - type: 'module', - dependencies, - devDependencies: { typescript: '^5.9.3' }, - }, - null, - 2 - )}\n` - ); - writeFileSync( - join(temporaryRoot, 'tsconfig.json'), - `${JSON.stringify( - { - compilerOptions: { - allowImportingTsExtensions: true, - module: 'ESNext', - moduleResolution: 'Bundler', - noEmit: true, - skipLibCheck: true, - strict: true, - target: 'ES2022', - }, - include: ['consumer.ts'], - }, - null, - 2 - )}\n` - ); - writeFileSync( - join(temporaryRoot, 'consumer.ts'), - `${importLines.join('\n')}\n` - ); - mkdirSync(join(temporaryRoot, 'src')); - writeFileSync( - join(temporaryRoot, 'src', 'index.ts'), - ` -import { schema } from 'spacetimedb/server'; -import * as apiKeys from '@spacetimedb/api-keys/submodule'; -import * as auth from '@spacetimedb/auth/submodule'; -import * as files from '@spacetimedb/files/submodule'; -import * as grid from '@spacetimedb/grid/submodule'; -import * as lobby from '@spacetimedb/lobby/submodule'; -import * as posthog from '@spacetimedb/posthog/submodule'; -import * as presence from '@spacetimedb/presence/submodule'; -import * as rateLimit from '@spacetimedb/rate-limit/submodule'; -import * as resend from '@spacetimedb/resend/submodule'; -import * as stripe from '@spacetimedb/stripe/submodule'; - -const spacetimedb = schema({ - apiKeys, - auth, - files, - grid, - lobby, - posthog, - presence, - rateLimit, - resend, - stripe, -}); -export default spacetimedb; - -export const init = spacetimedb.init(ctx => { - apiKeys.installApiKeys(ctx.as.apiKeys); - auth.installAuth(ctx.as.auth); - files.installFiles(ctx.as.files); - grid.installGrid(ctx.as.grid); - lobby.installLobby(ctx.as.lobby); - posthog.installPostHog(ctx.as.posthog); - presence.installPresence(ctx.as.presence); - rateLimit.installRateLimit(ctx.as.rateLimit); - resend.installResend(ctx.as.resend); - stripe.installStripe(ctx.as.stripe); -}); -` - ); - - run(npmCommand, ['install', '--ignore-scripts', '--no-audit', '--no-fund']); - if (process.argv.includes('--audit')) { - const audit = spawnSync(npmCommand, ['audit', '--omit=dev', '--json'], { - cwd: temporaryRoot, - encoding: 'utf8', - shell: process.platform === 'win32', - stdio: ['ignore', 'pipe', 'pipe'], - }); - let report; - try { - report = JSON.parse(audit.stdout); - } catch { - throw new Error( - `npm audit did not return JSON${audit.stderr ? `:\n${audit.stderr.trim()}` : ''}` - ); - } - const counts = report.metadata?.vulnerabilities ?? {}; - const total = ['info', 'low', 'moderate', 'high', 'critical'].reduce( - (sum, severity) => sum + Number(counts[severity] ?? 0), - 0 - ); - if (audit.status !== 0 || total > 0) { - const summary = ['info', 'low', 'moderate', 'high', 'critical'] - .filter(severity => Number(counts[severity] ?? 0) > 0) - .map(severity => `${severity}=${counts[severity]}`) - .join(', '); - throw new Error(`packed production dependency audit failed: ${summary}`); - } - console.log('Packed production dependency audit passed.'); - } - run(npxCommand, ['tsc', '--project', 'tsconfig.json']); - run(spacetimeCommand, ['build']); - - for (const packageDirectory of releasePackages) { - const sourceManifest = JSON.parse( - readFileSync(resolve(root, packageDirectory, 'package.json'), 'utf8') - ); - const installedDirectory = resolve( - temporaryRoot, - 'node_modules', - ...sourceManifest.name.split('/') - ); - const installedManifest = JSON.parse( - readFileSync(resolve(installedDirectory, 'package.json'), 'utf8') - ); - for (const value of Object.values(installedManifest.exports ?? {})) { - const targets = - typeof value === 'string' ? [value] : Object.values(value); - for (const target of targets) { - if (typeof target !== 'string') continue; - if (!existsSync(resolve(installedDirectory, target))) { - throw new Error( - `${installedManifest.name} export is absent after install: ${target}` - ); - } - } - } - } - - console.log( - `Consumer install check passed for ${releasePackages.length} packed packages, ${importIndex} exports, and a clean host-module build.` - ); -} finally { - rmSync(temporaryRoot, { force: true, recursive: true }); -} diff --git a/tools/doc-check.mjs b/tools/doc-check.mjs deleted file mode 100644 index b166be6cb26..00000000000 --- a/tools/doc-check.mjs +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env node - -import { createRequire } from 'node:module'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { releasePackages } from './release-packages.mjs'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const requireFromPackage = createRequire( - resolve(root, 'spacetime-agents-ts', 'package.json') -); -const ts = requireFromPackage('typescript'); -const failures = []; - -const documentationFiles = [ - ...releasePackages.flatMap(packageDir => { - const files = [`${packageDir}/README.md`]; - const exampleReadme = `${packageDir}/example/README.md`; - if (existsSync(resolve(root, exampleReadme))) files.push(exampleReadme); - return files; - }), -]; - -function fail(file, line, message) { - failures.push(`${file}:${line}: ${message}`); -} - -function lineNumberAt(text, offset) { - return text.slice(0, offset).split('\n').length; -} - -function validateLinks(file, text) { - const linkPattern = /!?\[[^\]]*\]\(([^)]+)\)/g; - for (const match of text.matchAll(linkPattern)) { - let target = match[1].trim(); - if (target.startsWith('<') && target.endsWith('>')) - target = target.slice(1, -1); - target = target.split(/\s+["']/)[0]; - if (/^(?:https?:|mailto:|#)/i.test(target)) continue; - const path = decodeURIComponent(target.split('#')[0]); - if (!path) continue; - if (!existsSync(resolve(root, dirname(file), path))) { - fail( - file, - lineNumberAt(text, match.index), - `broken relative link: ${target}` - ); - } - } -} - -function validateFences(file, text) { - const lines = text.split(/\r?\n/); - let fence; - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index]; - const opening = line.match(/^```\s*([A-Za-z0-9_-]*)\s*$/); - if (!fence && opening) { - fence = { - language: opening[1].toLowerCase(), - start: index + 1, - lines: [], - }; - continue; - } - if (fence && /^```\s*$/.test(line)) { - if (['ts', 'typescript', 'js', 'javascript'].includes(fence.language)) { - const source = fence.lines.join('\n'); - const result = ts.transpileModule(source, { - compilerOptions: { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.ESNext, - }, - fileName: `${file}.${fence.language.startsWith('j') ? 'js' : 'ts'}`, - reportDiagnostics: true, - }); - for (const diagnostic of result.diagnostics ?? []) { - if (diagnostic.category !== ts.DiagnosticCategory.Error) continue; - const position = - diagnostic.file && diagnostic.start != null - ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) - : { line: 0 }; - fail( - file, - fence.start + 1 + position.line, - `invalid ${fence.language} example: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}` - ); - } - } - fence = undefined; - continue; - } - if (fence) fence.lines.push(line); - } - if (fence) fail(file, fence.start, 'unclosed code fence'); -} - -for (const file of documentationFiles) { - const path = resolve(root, file); - if (!existsSync(path)) { - fail(file, 1, 'documentation file is missing'); - continue; - } - const text = readFileSync(path, 'utf8'); - validateLinks(file, text); - validateFences(file, text); - if (/\b(?:TODO|FIXME|WIP)\b/i.test(text)) { - fail(file, 1, 'contains draft-work marker (TODO/FIXME/WIP)'); - } - if (/\bnamespace[- ]branch\b/i.test(text)) { - fail(file, 1, 'contains obsolete namespace-branch wording'); - } - if (/SpacetimeDBPrivate/.test(text)) { - fail(file, 1, 'leaks a contributor-local repository name'); - } -} - -for (const packageDir of releasePackages) { - const file = `${packageDir}/README.md`; - const text = readFileSync(resolve(root, file), 'utf8'); - const manifest = JSON.parse( - readFileSync(resolve(root, packageDir, 'package.json'), 'utf8') - ); - const firstLine = text.split(/\r?\n/, 1)[0]; - if (firstLine !== `# ${manifest.name}`) - fail(file, 1, `must start with # ${manifest.name}`); - const requiredHeadings = ['Install', 'Usage', 'API', 'Testing', 'License']; - let previousHeadingOffset = -1; - for (const heading of requiredHeadings) { - const match = new RegExp(`^## ${heading}(?:\\s|$)`, 'mi').exec(text); - if (!match) { - fail(file, 1, `missing release README section: ${heading}`); - continue; - } - if (match.index < previousHeadingOffset) { - fail( - file, - lineNumberAt(text, match.index), - `release README section is out of order: ${heading}` - ); - } - previousHeadingOffset = match.index; - } - if (!/^### Integrate into an application\s*$/m.test(text)) { - fail( - file, - 1, - 'missing consumer onboarding section: Integrate into an application' - ); - } - if ( - !text.includes('spacetimedb@^2.8.3') && - packageDir !== 'spacetime-crypto-ts' - ) { - fail( - file, - 1, - 'install command must pin the compatible SpacetimeDB 2.8 peer range' - ); - } - if (!text.includes('https://spacetimedb.com/docs/')) { - fail(file, 1, 'missing link to the official getting-started guide'); - } -} - -for (const packageDir of releasePackages) { - const file = `${packageDir}/example/README.md`; - const path = resolve(root, file); - if (!existsSync(path)) continue; - const text = readFileSync(path, 'utf8'); - for (const heading of [ - 'Prerequisites', - 'Quick start', - 'Use in your project', - ]) { - if (!new RegExp(`^## ${heading}\\s*$`, 'm').test(text)) { - fail(file, 1, `missing example onboarding section: ${heading}`); - } - } - if (!text.includes('spacetime version use 2.8.3')) { - fail(file, 1, 'quick start must select SpacetimeDB CLI 2.8.3'); - } - if (!text.includes('spacetime start')) { - fail(file, 1, 'quick start must explain how to start the local server'); - } -} - -if (failures.length > 0) { - console.error(`Documentation check failed with ${failures.length} issue(s):`); - for (const failure of failures) console.error(`- ${failure}`); - process.exit(1); -} - -console.log( - `Documentation check passed for ${documentationFiles.length} files.` -); diff --git a/tools/release-check.mjs b/tools/release-check.mjs deleted file mode 100644 index 0f56b73f306..00000000000 --- a/tools/release-check.mjs +++ /dev/null @@ -1,484 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { - existsSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - statSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, relative, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; -import { - releasePackages, - releasePackageName, - spacetimedbPeerRange, - spacetimedbVersion, -} from './release-packages.mjs'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const failures = []; -const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const canonicalLicense = readFileSync( - resolve(root, releasePackages[0], 'LICENSE.txt'), - 'utf8' -); - -function fail(packageName, message) { - failures.push(`${packageName}: ${message}`); -} - -function readJson(path, packageName) { - try { - return JSON.parse(readFileSync(path, 'utf8')); - } catch (error) { - fail( - packageName, - `invalid JSON in ${relative(root, path)}: ${error.message}` - ); - return undefined; - } -} - -function isScheduledCallbackAny(node) { - const callback = node.parent; - if (!ts.isArrowFunction(callback) || callback.type !== node) return false; - const property = callback.parent; - if (!ts.isPropertyAssignment(property)) return false; - return ( - (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && - property.name.text === 'scheduled' - ); -} - -function checkExplicitAny(repositoryPath, absolutePath) { - const source = readFileSync(absolutePath, 'utf8'); - const sourceFile = ts.createSourceFile( - repositoryPath, - source, - ts.ScriptTarget.Latest, - true - ); - const visit = node => { - if ( - node.kind === ts.SyntaxKind.AnyKeyword && - !isScheduledCallbackAny(node) - ) { - const { line, character } = sourceFile.getLineAndCharacterOfPosition( - node.getStart(sourceFile) - ); - fail( - 'repository', - `${repositoryPath}:${line + 1}:${character + 1} uses explicit any outside the SpacetimeDB scheduled callback boundary` - ); - } - ts.forEachChild(node, visit); - }; - visit(sourceFile); -} - -const tracked = spawnSync( - 'git', - ['ls-files', '--cached', '--others', '--exclude-standard'], - { cwd: root, encoding: 'utf8', shell: process.platform === 'win32' } -); -if (tracked.status !== 0) { - fail( - 'repository', - `could not enumerate files: ${(tracked.stderr || tracked.stdout).trim()}` - ); -} else { - for (const repositoryPath of tracked.stdout.split(/\r?\n/).filter(Boolean)) { - const absolutePath = resolve(root, repositoryPath); - if (!existsSync(absolutePath)) continue; - - const normalizedRepositoryPath = repositoryPath.replaceAll('\\', '/'); - const isSubmodulePath = releasePackages.some( - packageDir => - normalizedRepositoryPath === packageDir || - normalizedRepositoryPath.startsWith(`${packageDir}/`) - ); - if (!isSubmodulePath && normalizedRepositoryPath !== 'pnpm-lock.yaml') { - continue; - } - if ( - /^spacetime-[^/]+-ts\/(?:module|app-module|store-module)(?:\/|$)/.test( - normalizedRepositoryPath - ) || - /^spacetime-[^/]+-ts\/example\/(?:module|app-module|store-module)(?:\/|$)/.test( - normalizedRepositoryPath - ) - ) { - fail( - 'repository', - `module directory must be named spacetimedb: ${repositoryPath}` - ); - } - - if ( - /tools\/(?:run-namespace-cli|use-namespace-cli)/.test( - normalizedRepositoryPath - ) - ) { - fail( - 'repository', - `obsolete local CLI helper remains: ${repositoryPath}` - ); - } - - if ( - /\.tsx?$/.test(repositoryPath) && - !normalizedRepositoryPath.includes('/codegen/') && - !normalizedRepositoryPath.includes('/module_bindings/') - ) { - const source = readFileSync(absolutePath, 'utf8'); - if (/\.find\([^\n]*\)\s*(?:===|!==)\s*undefined/.test(source)) { - fail( - 'repository', - `${repositoryPath} compares a table lookup with undefined; SpacetimeDB returns null for a missing row` - ); - } - checkExplicitAny(repositoryPath, absolutePath); - } - - if (repositoryPath.endsWith('pnpm-lock.yaml')) { - const lockfile = readFileSync(absolutePath, 'utf8'); - if (/SpacetimeDBPrivate|spacetimedb@file:/.test(lockfile)) { - fail( - 'repository', - `${repositoryPath} resolves SpacetimeDB from a local filesystem path` - ); - } - continue; - } - - if (!repositoryPath.endsWith('package.json')) continue; - const manifest = readJson(absolutePath, 'repository'); - if (!manifest) continue; - const workspaceMatch = normalizedRepositoryPath.match( - /^spacetime-([a-z0-9-]+)-ts\/(example\/spacetimedb|example|spacetimedb)\/package\.json$/ - ); - if (workspaceMatch) { - const [, slug, workspaceKind] = workspaceMatch; - const expectedWorkspaceName = - workspaceKind === 'example' - ? `spacetime-${slug}-example` - : workspaceKind === 'example/spacetimedb' - ? `spacetime-${slug}-example-module` - : `spacetime-${slug}-module`; - if (manifest.name !== expectedWorkspaceName) { - fail( - 'repository', - `${repositoryPath} name must be ${expectedWorkspaceName}` - ); - } - } - for (const section of [ - 'dependencies', - 'devDependencies', - 'optionalDependencies', - ]) { - const version = manifest[section]?.spacetimedb; - if (version && version !== 'workspace:*') { - fail( - 'repository', - `${repositoryPath} ${section}.spacetimedb must be workspace:*` - ); - } - } - const peerVersion = manifest.peerDependencies?.spacetimedb; - if (peerVersion && peerVersion !== spacetimedbPeerRange) { - fail( - 'repository', - `${repositoryPath} peerDependencies.spacetimedb must be ${spacetimedbPeerRange}` - ); - } - } -} - -function exportTargets(exportsField) { - const targets = []; - for (const value of Object.values(exportsField ?? {})) { - if (typeof value === 'string') { - targets.push(value); - continue; - } - if (value && typeof value === 'object') { - for (const target of Object.values(value)) { - if (typeof target === 'string') targets.push(target); - } - } - } - return [...new Set(targets)]; -} - -function filesUnder(path) { - if (!existsSync(path)) return []; - const out = []; - for (const entry of readdirSync(path)) { - const child = resolve(path, entry); - if (statSync(child).isDirectory()) out.push(...filesUnder(child)); - else out.push(child); - } - return out; -} - -for (const packageDir of releasePackages) { - const directory = resolve(root, packageDir); - const manifestPath = resolve(directory, 'package.json'); - const manifest = readJson(manifestPath, packageDir); - if (!manifest) continue; - - const packageSlug = packageDir.replace(/^spacetime-/, ''); - const expectedName = releasePackageName(packageDir); - if (manifest.name !== expectedName) - fail(packageDir, `name must be ${expectedName}`); - if ( - !/^(?:0|[1-9]\d*)\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test( - manifest.version ?? '' - ) - ) { - fail(packageDir, 'version must be valid semver'); - } - if ( - typeof manifest.description !== 'string' || - manifest.description.length < 20 || - manifest.description.length > 180 - ) { - fail(packageDir, 'description must be 20-180 characters'); - } - if (manifest.license !== 'BUSL-1.1') - fail(packageDir, 'license must be BUSL-1.1'); - const packageLicense = readFileSync( - resolve(directory, 'LICENSE.txt'), - 'utf8' - ); - if (packageLicense !== canonicalLicense) { - fail(packageDir, 'LICENSE.txt must match the other release packages'); - } - if ( - !packageLicense.includes( - `Licensed Work: SpacetimeDB ${spacetimedbVersion}` - ) - ) { - fail( - packageDir, - `LICENSE.txt must cover SpacetimeDB ${spacetimedbVersion}` - ); - } - if (manifest.type !== 'module') fail(packageDir, 'type must be module'); - if ( - manifest.main !== './src/index.ts' || - manifest.types !== './src/index.ts' - ) { - fail(packageDir, 'main and types must point to ./src/index.ts'); - } - if (manifest.publishConfig?.access !== 'public') - fail(packageDir, 'publishConfig.access must be public'); - if (manifest.publishConfig?.registry !== 'https://registry.npmjs.org/') { - fail( - packageDir, - 'publishConfig.registry must be https://registry.npmjs.org/' - ); - } - if ( - manifest.repository?.url !== - 'git+https://github.com/clockworklabs/SpacetimeDB.git' - ) { - fail(packageDir, 'repository URL is missing or incorrect'); - } - if (manifest.repository?.directory !== packageDir) - fail(packageDir, 'repository.directory must match the package directory'); - if (!manifest.homepage || !manifest.bugs?.url) - fail(packageDir, 'homepage and bugs metadata are required'); - if (!Array.isArray(manifest.keywords) || manifest.keywords.length < 3) - fail(packageDir, 'at least three keywords are required'); - if ( - manifest.scripts?.format !== - 'prettier . --write --ignore-path ../.prettierignore' - ) { - fail(packageDir, 'format script must match the SpacetimeDB workspace'); - } - if ( - manifest.scripts?.lint !== - 'eslint . && prettier . --check --ignore-path ../.prettierignore' - ) { - fail(packageDir, 'lint script must match the SpacetimeDB workspace'); - } - if (!manifest.scripts?.typecheck) - fail(packageDir, 'typecheck script is required'); - if (!manifest.scripts?.test) fail(packageDir, 'a test script is required'); - - const rootSource = readFileSync(resolve(directory, 'src/index.ts'), 'utf8'); - const isStandaloneModule = - /export\s*\{(?=[^}]*\bdefault\b)(?=[^}]*\binit\b)[^}]*\}/s.test(rootSource); - if (isStandaloneModule) { - if (manifest.scripts?.build !== 'spacetime build') { - fail( - packageDir, - 'standalone module packages must provide a spacetime build script' - ); - } - if ( - manifest.scripts?.['spacetime:generate'] !== - 'spacetime generate --lang typescript --out-dir ts-codegen' - ) { - fail( - packageDir, - 'standalone module packages must provide the standard spacetime:generate script' - ); - } - } - - for (const requiredFile of ['src', 'README.md', 'LICENSE.txt']) { - if (!manifest.files?.includes(requiredFile)) - fail(packageDir, `files must include ${requiredFile}`); - if (!existsSync(resolve(directory, requiredFile))) - fail(packageDir, `${requiredFile} does not exist`); - } - - if (!manifest.exports?.['.']) fail(packageDir, 'the root export is required'); - for (const target of exportTargets(manifest.exports)) { - const normalized = target.replace(/^\.\//, ''); - if (!existsSync(resolve(directory, normalized))) - fail(packageDir, `export target does not exist: ${target}`); - const included = manifest.files?.some( - entry => normalized === entry || normalized.startsWith(`${entry}/`) - ); - if (!included) - fail(packageDir, `export target is excluded from the tarball: ${target}`); - } - - const dependencySections = ['dependencies', 'optionalDependencies']; - for (const section of dependencySections) { - for (const [name, version] of Object.entries(manifest[section] ?? {})) { - if (/^(?:file|link):/.test(version)) - fail( - packageDir, - `${section}.${name} must not use a filesystem dependency` - ); - if (name.startsWith('@spacetimedb/') && version !== 'workspace:^') { - fail(packageDir, `${section}.${name} must be workspace:^`); - } - } - } - - if (packageSlug !== 'crypto-ts') { - if (manifest.peerDependencies?.spacetimedb !== spacetimedbPeerRange) { - fail( - packageDir, - `spacetimedb peer dependency must be ${spacetimedbPeerRange}` - ); - } - if (Object.hasOwn(manifest.scripts ?? {}, 'publish')) { - fail( - packageDir, - 'scripts.publish is an npm lifecycle hook; use an explicit name such as publish:module' - ); - } - if (manifest.devDependencies?.spacetimedb !== 'workspace:*') { - fail(packageDir, 'spacetimedb devDependency must be workspace:*'); - } - } - - const readme = readFileSync(resolve(directory, 'README.md'), 'utf8'); - const firstLine = readme.split(/\r?\n/, 1)[0]; - if (firstLine !== `# ${expectedName}`) - fail(packageDir, `README must start with # ${expectedName}`); - for (const heading of ['Install', 'Testing', 'License']) { - if (!new RegExp(`^## ${heading}\\s*$`, 'm').test(readme)) - fail(packageDir, `README is missing the ${heading} section`); - } - if (readme.split(/\r?\n/).length > 400) - fail( - packageDir, - 'README exceeds 400 lines; move internal design notes elsewhere' - ); - for (const smell of [ - '## Backlog', - '## Roadmap', - 'What this package actually contains', - 'What STDB needs to ship for production', - ]) { - if (readme.includes(smell)) - fail(packageDir, `README contains internal or draft wording: ${smell}`); - } - - const submoduleTarget = manifest.exports?.['./submodule']?.default; - if (submoduleTarget) { - const source = readFileSync( - resolve(directory, submoduleTarget.replace(/^\.\//, '')), - 'utf8' - ); - if (/export\s*\{[^}]*\binit\b[^}]*\}/s.test(source)) - fail(packageDir, './submodule must not export init'); - } - - for (const sourcePath of [ - ...filesUnder(resolve(directory, 'src')), - ...filesUnder(resolve(directory, 'spacetimedb', 'src')), - ]) { - if (!sourcePath.endsWith('.ts')) continue; - const source = readFileSync(sourcePath, 'utf8'); - const sourceName = relative(root, sourcePath).split(sep).join('/'); - if (/@ts-(?:ignore|nocheck)/.test(source)) - fail(packageDir, `${sourceName} disables TypeScript checking`); - if (/from\s+['"]node:|\brequire\s*\(|\bprocess\./.test(source)) - fail(packageDir, `${sourceName} imports a Node-only API`); - } - - const packDirectory = mkdtempSync(join(tmpdir(), 'stdb-submodule-pack-')); - const packed = spawnSync( - pnpmCommand, - ['pack', '--json', '--pack-destination', packDirectory], - { cwd: directory, encoding: 'utf8', shell: process.platform === 'win32' } - ); - if (packed.status !== 0) { - const detail = - packed.error?.message || - packed.stderr || - packed.stdout || - `exit ${packed.status}`; - fail(packageDir, `pnpm pack failed: ${detail.trim()}`); - rmSync(packDirectory, { force: true, recursive: true }); - continue; - } - let packResult; - try { - packResult = JSON.parse(packed.stdout); - } catch (error) { - fail(packageDir, `could not parse pnpm pack output: ${error.message}`); - rmSync(packDirectory, { force: true, recursive: true }); - continue; - } - const packedFiles = (packResult.files ?? []).map(entry => - entry.path.replaceAll('\\', '/') - ); - for (const required of ['package.json', 'README.md', 'LICENSE.txt']) { - if (!packedFiles.includes(required)) - fail(packageDir, `tarball is missing ${required}`); - } - for (const path of packedFiles) { - if ( - /^(?:example|scripts|node_modules|ts-codegen|dist|target)\//.test(path) || - path === 'pnpm-lock.yaml' - ) { - fail(packageDir, `tarball contains development-only file: ${path}`); - } - } - rmSync(packDirectory, { force: true, recursive: true }); -} - -if (failures.length > 0) { - console.error(`Release check failed with ${failures.length} issue(s):`); - for (const failure of failures) console.error(`- ${failure}`); - process.exit(1); -} - -console.log( - `Release-preparation check passed for ${releasePackages.length} packages.` -); diff --git a/tools/release-packages.mjs b/tools/release-packages.mjs deleted file mode 100644 index f3d4ee53ff6..00000000000 --- a/tools/release-packages.mjs +++ /dev/null @@ -1,25 +0,0 @@ -export const releasePackages = [ - 'spacetime-agents-ts', - 'spacetime-api-keys-ts', - 'spacetime-auth-ts', - 'spacetime-cron-ts', - 'spacetime-crypto-ts', - 'spacetime-files-ts', - 'spacetime-grid-ts', - 'spacetime-lobby-ts', - 'spacetime-posthog-ts', - 'spacetime-presence-ts', - 'spacetime-rate-limit-ts', - 'spacetime-resend-ts', - 'spacetime-retry-ts', - 'spacetime-stripe-ts', -]; - -export const spacetimedbVersion = '2.8.3'; -export const spacetimedbPeerRange = 'workspace:^'; -export const packedSpacetimedbPeerRange = '^2.8.3'; - -export function releasePackageName(packageDir) { - const slug = packageDir.replace(/^spacetime-/, '').replace(/-ts$/, ''); - return `@spacetimedb/${slug}`; -} diff --git a/tools/run-example-builds.mjs b/tools/run-example-builds.mjs deleted file mode 100644 index 4b7fcbb710e..00000000000 --- a/tools/run-example-builds.mjs +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { releasePackages } from './release-packages.mjs'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const targets = releasePackages - .map(packageDir => `${packageDir}/example`) - .filter(target => { - const manifestPath = resolve(root, target, 'package.json'); - if (!existsSync(manifestPath)) return false; - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - return Boolean(manifest.scripts?.build); - }); - -const failures = []; -for (const target of targets) { - console.log(`\nBuilding ${target}`); - const result = spawnSync(pnpmCommand, ['--dir', target, 'run', 'build'], { - cwd: root, - stdio: 'inherit', - shell: process.platform === 'win32', - }); - if (result.status !== 0) failures.push(target); -} - -if (failures.length > 0) { - console.error(`\nExample builds failed: ${failures.join(', ')}`); - process.exit(1); -} - -console.log(`\nExample builds passed for ${targets.length} browser examples.`); diff --git a/tools/run-example-smokes.mjs b/tools/run-example-smokes.mjs deleted file mode 100644 index 419a6dd7b31..00000000000 --- a/tools/run-example-smokes.mjs +++ /dev/null @@ -1,737 +0,0 @@ -#!/usr/bin/env node - -/* global document */ - -import { spawn, spawnSync } from 'node:child_process'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { chromium } from 'playwright'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const isWindows = process.platform === 'win32'; -const pnpmCommand = isWindows ? 'pnpm.cmd' : 'pnpm'; -const spacetimeCommand = isWindows ? 'spacetime.exe' : 'spacetime'; -const confirmFlag = '--confirm-delete-data'; -const browserFlag = '--browser'; -const ephemeralFlag = '--ephemeral'; - -const examples = [ - { - dir: 'spacetime-stripe-ts/example', - database: 'spacetime-stripe-example', - port: 8787, - }, - { - dir: 'spacetime-cron-ts/example', - database: 'spacetime-cron-example', - port: 8788, - }, - { - dir: 'spacetime-agents-ts/example', - database: 'spacetime-agents-example', - port: 8789, - }, - { - dir: 'spacetime-resend-ts/example', - database: 'spacetime-resend-example', - port: 8790, - }, - { - dir: 'spacetime-auth-ts/example', - database: 'spacetime-auth-example', - port: 8791, - }, - { - dir: 'spacetime-rate-limit-ts/example', - database: 'spacetime-rate-limit-example', - port: 8792, - }, - { - dir: 'spacetime-grid-ts/example', - database: 'spacetime-grid-example', - port: 8793, - }, - { - dir: 'spacetime-presence-ts/example', - database: 'spacetime-presence-example', - port: 8794, - }, - { - dir: 'spacetime-posthog-ts/example', - database: 'spacetime-posthog-example', - port: 8796, - }, - { - dir: 'spacetime-lobby-ts/example', - database: 'spacetime-lobby-example', - port: 8797, - }, - { - dir: 'spacetime-api-keys-ts/example', - database: 'spacetime-api-keys-example', - port: 8798, - }, - { - dir: 'spacetime-files-ts/example', - database: 'spacetime-files-example', - port: 8799, - }, -]; - -function selectedExamples() { - const onlyIndex = process.argv.indexOf('--only'); - if (onlyIndex < 0) return examples; - const requested = process.argv[onlyIndex + 1]; - if (!requested) - throw new Error('--only requires an example directory or database name'); - const selected = examples.filter( - item => item.dir === requested || item.database === requested - ); - if (selected.length === 0) throw new Error(`unknown example: ${requested}`); - return selected; -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd ?? root, - env: options.env ?? process.env, - encoding: 'utf8', - stdio: options.inherit ? 'inherit' : 'pipe', - shell: isWindows && command === pnpmCommand, - windowsHide: true, - }); - if (result.error) throw result.error; - if (result.status !== 0) { - const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(); - throw new Error( - `${command} ${args.join(' ')} exited with ${result.status}${output ? `\n${output}` : ''}` - ); - } - return `${result.stdout ?? ''}${result.stderr ?? ''}`; -} - -function smokeEnvironment(example) { - return { - ...process.env, - HOST: '127.0.0.1', - PORT: String(example.port), - STDB_URI: 'ws://127.0.0.1:3000', - STDB_HTTP: 'http://127.0.0.1:3000', - STDB_SERVER: 'http://127.0.0.1:3000', - SPACETIMEDB_DB_NAME: example.database, - AUTH_ISSUER_URL: `http://127.0.0.1:${example.port}`, - AUTH_BASE_URL: `http://127.0.0.1:${example.port}`, - AUTH_COOKIE_NAME: 'stdb_auth', - AUTH_SESSION_TTL_SECONDS: '604800', - AUTH_ES256_PRIVATE_KEY_PEM: '', - // The generic smoke suite must never spend money or mutate provider accounts. - OPENROUTER_API_KEY: '', - OPENAI_API_KEY: '', - ANTHROPIC_API_KEY: '', - POSTHOG_PROJECT_API_KEY: '', - STRIPE_SECRET_KEY: '', - STRIPE_SYNC_PRICES: '0', - RESEND_API_KEY: '', - RESEND_WEBHOOK_SECRET: '', - GOOGLE_CLIENT_ID: '', - GOOGLE_CLIENT_SECRET: '', - GITHUB_CLIENT_ID: '', - GITHUB_CLIENT_SECRET: '', - }; -} - -function startServer(example) { - const chunks = []; - const tsxCli = resolve( - root, - example.dir, - 'node_modules', - 'tsx', - 'dist', - 'cli.mjs' - ); - const child = spawn(process.execPath, [tsxCli, 'server.ts'], { - cwd: resolve(root, example.dir), - env: smokeEnvironment(example), - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }); - const capture = chunk => { - chunks.push(chunk.toString()); - if (chunks.join('').length > 40_000) chunks.shift(); - }; - child.stdout.on('data', capture); - child.stderr.on('data', capture); - return { child, output: () => chunks.join('') }; -} - -async function stopServer(child) { - if (child.exitCode !== null) return; - if (isWindows) { - const killed = spawnSync( - 'taskkill.exe', - ['/pid', String(child.pid), '/t', '/f'], - { - stdio: 'ignore', - windowsHide: true, - timeout: 10_000, - } - ); - if (killed.error || killed.status !== 0) child.kill(); - } else { - child.kill('SIGTERM'); - } - await Promise.race([ - new Promise(resolveExit => child.once('exit', resolveExit)), - new Promise(resolveTimeout => setTimeout(resolveTimeout, 5_000)), - ]); - if (child.exitCode === null) { - child.kill('SIGKILL'); - await Promise.race([ - new Promise(resolveExit => child.once('exit', resolveExit)), - new Promise(resolveTimeout => setTimeout(resolveTimeout, 2_000)), - ]); - if (child.exitCode === null) { - throw new Error(`failed to stop example server process ${child.pid}`); - } - } -} - -async function request(url, options) { - return fetch(url, { ...options, signal: AbortSignal.timeout(2_000) }); -} - -async function waitForHealth(example, server) { - const deadline = Date.now() + 45_000; - const url = `http://127.0.0.1:${example.port}/api/health`; - let lastError = 'server did not answer'; - while (Date.now() < deadline) { - if (server.child.exitCode !== null) { - throw new Error( - `server exited with ${server.child.exitCode}\n${server.output()}` - ); - } - try { - const response = await request(url); - const body = await response.json(); - const reportedDatabase = body.database ?? body.app; - if ( - !response.ok || - body.ok !== true || - reportedDatabase !== example.database - ) { - throw new Error( - `unexpected health response ${response.status}: ${JSON.stringify(body)}` - ); - } - return; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - await new Promise(resolveWait => setTimeout(resolveWait, 250)); - } - } - throw new Error(`${lastError}\n${server.output()}`); -} - -async function checkHttpSurface(example) { - const origin = `http://127.0.0.1:${example.port}`; - const rootResponse = await request(`${origin}/`); - if ( - !rootResponse.ok || - !(rootResponse.headers.get('content-type') ?? '').includes('text/html') - ) { - throw new Error(`GET / did not return HTML (${rootResponse.status})`); - } - - const configResponse = await request(`${origin}/api/config`); - const config = await configResponse.json(); - const configuredDatabase = config.database ?? config.appDatabase; - if (!configResponse.ok || configuredDatabase !== example.database) { - throw new Error( - `unexpected /api/config response: ${JSON.stringify(config)}` - ); - } - - if (example.baseDatabase === 'spacetime-posthog-example') { - const removedRoute = await request(`${origin}/api/admin/identity`); - if (removedRoute.status !== 404) - throw new Error('removed PostHog admin route is reachable'); - } - if (example.baseDatabase === 'spacetime-stripe-example') { - for (const route of [ - '/api/admin/configure', - '/api/admin/seed', - '/api/admin/sync', - ]) { - const removedRoute = await request(`${origin}${route}`, { - method: 'POST', - }); - if (removedRoute.status !== 404) - throw new Error(`removed Stripe admin route is reachable: ${route}`); - } - const unavailableCheckout = await request(`${origin}/api/checkout`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{}', - }); - if (unavailableCheckout.status !== 503) { - throw new Error( - `unconfigured Stripe checkout returned ${unavailableCheckout.status}, expected 503` - ); - } - } - if (example.baseDatabase === 'spacetime-resend-example') { - const unsigned = await request(`${origin}/webhook/resend`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: '{}', - }); - if (unsigned.status !== 400) { - throw new Error( - `unsigned Resend webhook returned ${unsigned.status}, expected 400` - ); - } - } -} - -async function waitForEnabled(page, selector) { - await page.locator(selector).waitFor({ state: 'visible' }); - await page.waitForFunction( - target => !document.querySelector(target)?.hasAttribute('disabled'), - selector - ); -} - -async function checkExampleInteraction(example, page) { - switch (example.baseDatabase) { - case 'spacetime-stripe-example': - await page.locator('#btnCart').click(); - await page.waitForFunction( - () => !document.querySelector('#cartPopout')?.hasAttribute('hidden') - ); - await page.locator('#btnCartClose').click(); - return; - - case 'spacetime-cron-example': - await page.waitForFunction( - () => - document.querySelector('#connection')?.dataset.state === 'connected' - ); - await page.waitForFunction( - () => Number(document.querySelector('#stat-jobs')?.textContent) === 2 - ); - await page.locator('#open-scheduler').click(); - await page.locator('#job-name').waitFor({ state: 'visible' }); - return; - - case 'spacetime-agents-example': - await page.locator('#toggle-link').click(); - await page.waitForFunction( - () => - document.querySelector('#auth-title')?.textContent === - 'Create an account' - ); - return; - - case 'spacetime-resend-example': - await page.locator('#subject-input').fill('Browser smoke'); - await page.locator('#message-input').fill('Rendered preview'); - await page.locator('.msg-tab[data-tab="preview"]').click(); - await page.waitForFunction( - () => - !document.querySelector('#message-preview')?.hasAttribute('hidden') - ); - return; - - case 'spacetime-auth-example': - await page.locator('#toggle-link').click(); - await page.waitForFunction( - () => - document.querySelector('#auth-title')?.textContent === - 'Create account' - ); - return; - - case 'spacetime-rate-limit-example': { - try { - await waitForEnabled(page, '#tapBtn'); - } catch (error) { - const diagnostics = await page.evaluate(() => ({ - connection: globalThis.__reactorConnectionState, - tapVisible: document.querySelector('#tapBtn') != null, - tapDisabled: - document.querySelector('#tapBtn')?.hasAttribute('disabled') ?? null, - hasActionApi: typeof globalThis.reactor?.tap === 'function', - })); - throw new Error( - `Rate Limit did not become ready: ${JSON.stringify(diagnostics)}`, - { cause: error } - ); - } - if ( - await page.evaluate( - () => globalThis.__reactorConnectedBeforeReady === true - ) - ) { - throw new Error('Rate Limit reported connected before its action API'); - } - const before = await page.locator('#energy').textContent(); - await page.locator('#tapBtn').click(); - try { - await page.waitForFunction( - previous => - document.querySelector('#energy')?.textContent !== previous, - before - ); - } catch (error) { - const diagnostics = await page.evaluate(() => ({ - connectedBeforeReady: - globalThis.__reactorConnectedBeforeReady === true, - energy: document.querySelector('#energy')?.textContent ?? null, - tapDisabled: - document.querySelector('#tapBtn')?.hasAttribute('disabled') ?? null, - hasActionApi: typeof globalThis.reactor?.tap === 'function', - })); - throw new Error( - `Rate Limit tap did not update energy: ${JSON.stringify(diagnostics)}`, - { cause: error } - ); - } - return; - } - - case 'spacetime-grid-example': - await page.locator('#toggle-link').click(); - await page.waitForFunction( - () => - document.querySelector('#auth-title')?.textContent === - 'Create an account' - ); - return; - - case 'spacetime-presence-example': - await page.locator('#toggleLink').click(); - await page.waitForFunction( - () => - document.querySelector('#landingAuthTitle')?.textContent === - 'Create an account' - ); - return; - - case 'spacetime-posthog-example': - await waitForEnabled(page, '#tickOnce'); - await page.locator('#tickOnce').click(); - return; - - case 'spacetime-lobby-example': - await waitForEnabled(page, '#findDuel'); - await page.locator('#displayName').fill('Browser Smoke'); - await page.locator('#findDuel').click(); - await page.waitForFunction( - () => - document - .querySelector('#waitingScreen') - ?.classList.contains('active') || - document.querySelector('#duelScreen')?.classList.contains('active') - ); - return; - - case 'spacetime-api-keys-example': - await page.waitForFunction( - () => document.querySelector('#connChip')?.dataset.state === 'connected' - ); - await page.locator('#shareBtn').click(); - await page.locator('#keyNameInput').fill('Browser smoke'); - await page.locator('#createKeyBtn').click(); - await page.waitForFunction( - () => - !document.querySelector('#linkBox')?.hasAttribute('hidden') && - document.querySelectorAll('#keyList [data-key]').length === 1 - ); - { - const firstLink = await page - .locator('#linkBox .link-code') - .textContent(); - await page.locator('#keyList [data-rotate]').click(); - await page.waitForFunction( - previous => - document.querySelector('#linkBox .link-code')?.textContent !== - previous, - firstLink - ); - } - await page.locator('#keyList [data-revoke]').click(); - await page.waitForFunction( - () => document.querySelectorAll('#keyList [data-key]').length === 0 - ); - return; - - case 'spacetime-files-example': - await waitForEnabled(page, '#new-folder'); - await page.locator('#new-folder').click(); - await page.waitForFunction(() => - document.querySelector('#dialog')?.classList.contains('open') - ); - await page.locator('#dialog-cancel').click(); - await page.locator('#file-input').setInputFiles([ - { name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('a') }, - { name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('b') }, - { name: 'c.txt', mimeType: 'text/plain', buffer: Buffer.from('c') }, - ]); - await page.waitForFunction( - () => document.querySelectorAll('[data-file]').length === 3 - ); - await page.locator('[data-file="/a.txt"]').click(); - await page - .locator('[data-file="/c.txt"]') - .click({ modifiers: ['Shift'] }); - await page.waitForFunction( - () => - document.querySelectorAll('[data-file].selected').length === 3 && - document.querySelector('#bulk-count')?.textContent === '3 selected' - ); - await page.locator('#bulk-public').click(); - await page.waitForFunction( - () => - document.querySelectorAll( - '[data-file] .vis-dot.public, [data-file] .badge.public' - ).length === 3 - ); - await page.locator('#bulk-delete').click(); - await page.waitForFunction(() => - document.querySelector('#dialog')?.classList.contains('open') - ); - await page.locator('#dialog-ok').click(); - await page.waitForFunction( - () => document.querySelectorAll('[data-file]').length === 0 - ); - return; - - default: - throw new Error( - `missing browser interaction for ${example.baseDatabase}` - ); - } -} - -async function checkBrowserSurface(example, browser) { - const origin = `http://127.0.0.1:${example.port}`; - const context = await browser.newContext(); - const page = await context.newPage(); - const errors = []; - - if (example.baseDatabase === 'spacetime-rate-limit-example') { - await page.addInitScript(() => { - globalThis.__reactorConnectedBeforeReady = false; - globalThis.__reactorConnectionState = null; - globalThis.addEventListener('reactor:connState', event => { - globalThis.__reactorConnectionState = event.detail ?? null; - if ( - event.detail?.state === 'connected' && - typeof globalThis.reactor?.tap !== 'function' - ) { - globalThis.__reactorConnectedBeforeReady = true; - } - }); - }); - } - - page.on('pageerror', error => errors.push(`page error: ${error.message}`)); - page.on('console', message => { - if (message.type() !== 'error') return; - if (message.text().startsWith('Failed to load resource:')) return; - errors.push(`console: ${message.text()}`); - }); - page.on('response', response => { - if (response.status() < 400) return; - const url = new URL(response.url()); - if ( - url.origin === origin && - response.status() === 401 && - url.pathname === '/auth/session/refresh' - ) { - return; - } - errors.push( - `HTTP ${response.status()}: ${response.request().method()} ${url.href}` - ); - }); - page.on('requestfailed', request => { - const url = new URL(request.url()); - if ( - !['document', 'script', 'stylesheet', 'xhr', 'fetch'].includes( - request.resourceType() - ) - ) { - return; - } - errors.push( - `request failed: ${request.method()} ${url.href} (${request.failure()?.errorText ?? 'unknown'})` - ); - }); - - try { - const response = await page.goto(origin, { waitUntil: 'load' }); - if (!response?.ok()) { - throw new Error( - `browser GET / returned ${response?.status() ?? 'no response'}` - ); - } - await page.waitForFunction(() => - [...document.styleSheets].some(sheet => - sheet.href?.endsWith('/styles.css') - ) - ); - await checkExampleInteraction(example, page); - await page.waitForTimeout(250); - if (errors.length > 0) throw new Error(errors.join('\n')); - } finally { - await context.close(); - } -} - -async function smoke(example, browser) { - const startedAt = Date.now(); - console.log(`\n[smoke] ${example.dir}: fresh publish as ${example.database}`); - if (process.argv.includes(ephemeralFlag)) { - run( - spacetimeCommand, - [ - 'publish', - '--server', - 'local', - '--yes', - '--module-path', - resolve(root, example.dir, 'spacetimedb'), - example.database, - ], - { inherit: true } - ); - run(pnpmCommand, ['--dir', example.dir, 'run', 'spacetime:generate'], { - inherit: true, - }); - run(pnpmCommand, ['--dir', example.dir, 'run', 'build:app'], { - inherit: true, - }); - } else { - try { - run(pnpmCommand, ['--dir', example.dir, 'run', 'build:module:fresh'], { - inherit: true, - }); - } catch (firstError) { - console.warn( - `[smoke] ${example.dir}: fresh publish failed; retrying once` - ); - try { - run(pnpmCommand, ['--dir', example.dir, 'run', 'build:module:fresh'], { - inherit: true, - }); - } catch (retryError) { - throw new Error( - `${retryError instanceof Error ? retryError.message : String(retryError)}\nFirst attempt: ${firstError instanceof Error ? firstError.message : String(firstError)}` - ); - } - } - } - - console.log( - `[smoke] ${example.dir}: start and probe http://127.0.0.1:${example.port}` - ); - const server = startServer(example); - try { - await waitForHealth(example, server); - await checkHttpSurface(example); - if (browser) await checkBrowserSurface(example, browser); - } catch (error) { - const output = server.output().trim(); - throw new Error( - `${error instanceof Error ? error.message : String(error)}${output ? `\nServer output:\n${output}` : ''}` - ); - } finally { - await stopServer(server.child); - } - console.log( - `[smoke] ${example.dir}: passed (${((Date.now() - startedAt) / 1000).toFixed(1)}s)` - ); -} - -async function main() { - const ephemeral = process.argv.includes(ephemeralFlag); - if (!process.argv.includes(confirmFlag) && !ephemeral) { - console.error( - `Refusing to replace local example databases without ${confirmFlag}.` - ); - console.error( - 'This suite runs each build:module:fresh script with --delete-data=always.' - ); - process.exit(2); - } - - run(process.execPath, [resolve(root, 'tools/check-spacetime-release.mjs')], { - inherit: true, - }); - run(spacetimeCommand, ['server', 'ping', 'local']); - run(spacetimeCommand, ['login', 'show']); - - const suffix = `smoke-${process.pid}-${Date.now()}`; - const selected = selectedExamples().map(example => ({ - ...example, - baseDatabase: example.database, - database: ephemeral ? `${example.database}-${suffix}` : example.database, - })); - const failures = []; - const browser = process.argv.includes(browserFlag) - ? await chromium.launch({ headless: true }) - : undefined; - try { - for (const example of selected) { - try { - await smoke(example, browser); - } catch (error) { - failures.push({ - example: example.dir, - error: error instanceof Error ? error.message : String(error), - }); - console.error( - `[smoke] ${example.dir}: FAILED\n${failures.at(-1).error}` - ); - } finally { - if (ephemeral) { - try { - run(spacetimeCommand, [ - 'delete', - '--server', - 'local', - '--yes', - example.database, - ]); - } catch (error) { - failures.push({ - example: example.dir, - error: `ephemeral cleanup failed: ${error instanceof Error ? error.message : String(error)}`, - }); - } - } - } - } - } finally { - await browser?.close(); - } - - if (failures.length > 0) { - console.error( - `\nExample smoke failures (${failures.length}/${selected.length}):` - ); - for (const failure of failures) - console.error(`- ${failure.example}: ${failure.error.split('\n')[0]}`); - process.exit(1); - } - console.log( - `\nFresh-database smoke passed for ${selected.length} example app(s).` - ); -} - -main().catch(error => { - console.error(error instanceof Error ? error.stack : String(error)); - process.exit(1); -}); diff --git a/tools/run-example-tests.mjs b/tools/run-example-tests.mjs deleted file mode 100644 index ae19940d917..00000000000 --- a/tools/run-example-tests.mjs +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { releasePackages } from './release-packages.mjs'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const targets = releasePackages - .flatMap(packageDir => [ - `${packageDir}/example`, - `${packageDir}/example/spacetimedb`, - ]) - .filter(target => { - const manifestPath = resolve(root, target, 'package.json'); - if (!existsSync(manifestPath)) return false; - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - return Boolean(manifest.scripts?.['test:unit']); - }); - -const failures = []; -for (const target of targets) { - console.log(`\nTesting ${target}`); - const result = spawnSync(pnpmCommand, ['--dir', target, 'run', 'test:unit'], { - cwd: root, - stdio: 'inherit', - shell: process.platform === 'win32', - }); - if (result.status !== 0) failures.push(target); -} - -if (failures.length > 0) { - console.error(`\nExample tests failed: ${failures.join(', ')}`); - process.exit(1); -} - -console.log(`\nExample tests passed for ${targets.length} targeted suites.`); diff --git a/tools/run-module-builds.mjs b/tools/run-module-builds.mjs deleted file mode 100644 index 6e4a262bada..00000000000 --- a/tools/run-module-builds.mjs +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { releasePackages } from './release-packages.mjs'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const targets = releasePackages - .flatMap(packageDir => [ - packageDir, - `${packageDir}/spacetimedb`, - `${packageDir}/example/spacetimedb`, - ]) - .filter(target => { - const manifestPath = resolve(root, target, 'package.json'); - if (!existsSync(manifestPath)) return false; - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); - return manifest.scripts?.build === 'spacetime build'; - }); - -const failures = []; -for (const target of targets) { - console.log(`\nBuilding ${target}`); - const result = spawnSync(pnpmCommand, ['--dir', target, 'run', 'build'], { - cwd: root, - encoding: 'utf8', - shell: process.platform === 'win32', - }); - if (result.stdout) process.stdout.write(result.stdout); - if (result.stderr) process.stderr.write(result.stderr); - const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; - const reportedRuntimeError = /^Error: Uncaught\b/m.test(output); - if (reportedRuntimeError) { - console.error(`Build reported a runtime error for ${target}.`); - } - if (result.status !== 0 || result.error || reportedRuntimeError) { - failures.push(target); - } -} - -if (failures.length > 0) { - console.error(`\nModule builds failed: ${failures.join(', ')}`); - process.exit(1); -} - -console.log(`\nModule builds passed for ${targets.length} release fixtures.`); diff --git a/tools/run-package-checks.mjs b/tools/run-package-checks.mjs deleted file mode 100644 index cdb5114cb6a..00000000000 --- a/tools/run-package-checks.mjs +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { releasePackages } from './release-packages.mjs'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; -const failures = []; - -function run(packageDir, script) { - console.log(`\n${packageDir}: ${script}`); - const result = spawnSync(pnpmCommand, ['--dir', packageDir, 'run', script], { - cwd: root, - encoding: 'utf8', - stdio: 'inherit', - shell: process.platform === 'win32', - }); - if (result.status !== 0) failures.push(`${packageDir}:${script}`); -} - -for (const packageDir of releasePackages) { - const manifest = JSON.parse( - readFileSync(resolve(root, packageDir, 'package.json'), 'utf8') - ); - run(packageDir, 'lint'); - run(packageDir, 'typecheck'); - if (!manifest.scripts?.test) { - failures.push(`${packageDir}:missing-test-script`); - continue; - } - run(packageDir, 'test'); -} - -if (failures.length > 0) { - console.error(`\nPackage checks failed: ${failures.join(', ')}`); - process.exit(1); -} - -console.log(`\nPackage checks passed for ${releasePackages.length} packages.`); From 5255e9079fc83a59a046bc9332bde6c4d01b5a6e Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 09:33:48 -0400 Subject: [PATCH 17/33] Narrow submodule ignore rules --- .gitignore | 3 +-- .prettierignore | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index c4bd1df8b38..3cb5bce0458 100644 --- a/.gitignore +++ b/.gitignore @@ -206,8 +206,7 @@ __pycache__/ .idea/ # TypeScript submodule development outputs -.stdb-*/ -spacetime-*-ts/example/.stdb-* +spacetime-*-ts/example/.stdb-server-token spacetime-*-ts/example/public/app.js spacetime-*-ts/example/public/app.js.map spacetime-*-ts/ts-codegen/ diff --git a/.prettierignore b/.prettierignore index 373df442c74..41800b56b39 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,8 +4,3 @@ dist target .github coverage -**/public/app.js -**/public/app.js.map -**/src/module_bindings/** -**/ts-codegen/** -.stdb-* From 60dcbce83c3cbb75da200f9eeb576e9271867163 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 09:34:30 -0400 Subject: [PATCH 18/33] cleanup --- spacetime-agents-ts/README.md | 49 +- spacetime-agents-ts/example/public/ui.js | 28 - spacetime-agents-ts/example/server.ts | 6 +- .../example/spacetimedb/src/index.ts | 1 - .../example/spacetimedb/src/loop.ts | 2 - .../example/spacetimedb/src/summarize.ts | 3 - .../example/spacetimedb/src/sweeper.ts | 2 - .../example/spacetimedb/src/tools/echo.ts | 2 - .../example/spacetimedb/src/tools/getTime.ts | 4 +- .../example/spacetimedb/src/tools/index.ts | 1 - spacetime-agents-ts/example/src/app.ts | 11 +- spacetime-agents-ts/package.json | 4 + spacetime-agents-ts/spacetimedb/src/index.ts | 1036 +---------------- spacetime-agents-ts/src/mounted/index.ts | 1027 ++++++++++++++++ .../src => src/mounted}/install.ts | 0 .../{spacetimedb/src => src/mounted}/loop.ts | 2 +- .../{spacetimedb/src => src/mounted}/model.ts | 0 .../src => src/mounted}/summarize.ts | 0 .../{spacetimedb => }/src/submodule.ts | 6 +- .../example/spacetimedb/src/index.ts | 15 - spacetime-api-keys-ts/example/src/app.ts | 27 - spacetime-auth-ts/example/public/ui.js | 7 - spacetime-auth-ts/example/server.ts | 7 +- .../example/spacetimedb/src/index.ts | 1 - spacetime-auth-ts/example/src/app.ts | 13 +- spacetime-auth-ts/src/caller.ts | 2 +- spacetime-auth-ts/src/keys.ts | 2 - spacetime-auth-ts/src/procedures.ts | 1 - spacetime-cron-ts/example/server.ts | 3 - .../example/spacetimedb/src/index.ts | 2 - spacetime-cron-ts/example/src/app.ts | 4 +- spacetime-cron-ts/spacetimedb/src/index.ts | 2 - spacetime-crypto-ts/src/hmac.ts | 2 - spacetime-crypto-ts/src/index.ts | 2 - spacetime-crypto-ts/src/sha256.ts | 2 - spacetime-crypto-ts/src/timing.ts | 2 - spacetime-crypto-ts/src/vendors.ts | 3 - spacetime-files-ts/example/src/app.ts | 38 - spacetime-files-ts/example/src/utils.ts | 6 - spacetime-files-ts/example/src/zip.ts | 1 - spacetime-grid-ts/example/public/ui.js | 64 +- spacetime-grid-ts/example/server.ts | 7 +- .../example/spacetimedb/src/index.ts | 36 - .../example/spacetimedb/src/schema.ts | 10 - .../example/spacetimedb/src/views.ts | 8 - spacetime-grid-ts/example/src/app.ts | 28 +- spacetime-grid-ts/src/math/coords.ts | 2 - spacetime-grid-ts/src/math/distance.ts | 5 - spacetime-grid-ts/src/math/neighbors.ts | 2 - .../example/catalog/catalog.ts | 2 - spacetime-posthog-ts/example/src/app.ts | 6 - spacetime-presence-ts/example/src/app.ts | 21 +- spacetime-resend-ts/example/server.ts | 6 - .../example/spacetimedb/src/index.ts | 3 - spacetime-resend-ts/src/submodule/webhooks.ts | 1 - spacetime-stripe-ts/example/server.ts | 2 - .../example/spacetimedb/src/store/webhooks.ts | 2 - .../src/submodule/operations/billing.ts | 1 - 58 files changed, 1111 insertions(+), 1421 deletions(-) create mode 100644 spacetime-agents-ts/src/mounted/index.ts rename spacetime-agents-ts/{spacetimedb/src => src/mounted}/install.ts (100%) rename spacetime-agents-ts/{spacetimedb/src => src/mounted}/loop.ts (99%) rename spacetime-agents-ts/{spacetimedb/src => src/mounted}/model.ts (100%) rename spacetime-agents-ts/{spacetimedb/src => src/mounted}/summarize.ts (100%) rename spacetime-agents-ts/{spacetimedb => }/src/submodule.ts (78%) diff --git a/spacetime-agents-ts/README.md b/spacetime-agents-ts/README.md index 2499f7ce36c..a63e4964518 100644 --- a/spacetime-agents-ts/README.md +++ b/spacetime-agents-ts/README.md @@ -1,8 +1,7 @@ # @spacetimedb/agents -Typed tools, agent definitions, chat-provider adapters, embeddings helpers, and -dispatch utilities for SpacetimeDB TypeScript modules. This pure helper leaves -persistence, authorization, and lifecycle hooks to the host module. +A ready-to-mount agent submodule and lower-level tools for custom SpacetimeDB +TypeScript modules. ## Install @@ -16,14 +15,36 @@ to build the host module. For the install-to-publish workflow, see [Getting started](https://spacetimedb.com/docs/). -## Usage +## Quick start + +Mount the standard submodule when you want an identity-owned chat backend with +private provider keys, caller-scoped views, typed tools, summaries, embeddings, +and stale-lock cleanup. + +```ts +import { schema } from 'spacetimedb/server'; +import * as agents from '@spacetimedb/agents/submodule'; + +const spacetimedb = schema({ agents }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + agents.installAgents(ctx); +}); +``` + +`installAgents` makes the installing identity the first Agents administrator +and schedules stale-lock cleanup. Configure provider keys through the mounted +administration operations after publishing the host module. + +## Custom integration ### Integrate into an application -This helper package supplies agent and provider primitives. Your host module -owns conversation tables, authorization, provider-key storage, and the -procedure that performs HTTP. Define the registry at module scope, then call -the provider from a procedure with `ctx.http`. +Use the lower-level agent and provider primitives when your application needs +its own conversation tables, authorization, provider-key storage, or HTTP +procedure. Define the registry at module scope, then call the provider from a +procedure with `ctx.http`. Define tools with SpacetimeDB type builders. The declaration produces the JSON Schema sent to the model and validates every returned tool call before the @@ -90,8 +111,8 @@ for private configuration, caller-scoped views, and an agent loop. embedding requests. - `cosineSimilarity` and `topKByScore` provide in-memory ranking helpers. -Documented subpath exports are `./kit`, `./openrouter`, `./providers`, -`./embeddings`, and `./stale-locks`. +Documented subpath exports are `./submodule`, `./kit`, `./openrouter`, +`./providers`, `./embeddings`, and `./stale-locks`. Tool dispatch rejects malformed JSON, missing and unknown fields, incorrect types, unsafe integers, inputs above 64 KiB, arrays above 1,000 items, and tool @@ -119,6 +140,8 @@ views. Package entrypoints: - `@spacetimedb/agents` exports the complete public surface. +- `@spacetimedb/agents/submodule` exports the ready-to-mount Agents schema and + installer. - `@spacetimedb/agents/kit` exports typed agents, tools, and dispatch. - `@spacetimedb/agents/providers` exports provider adapters. - `@spacetimedb/agents/embeddings` exports embedding and ranking helpers. @@ -133,9 +156,9 @@ pnpm test pnpm run lint ``` -The unit suite uses mocked HTTP with deterministic provider fixtures. See the -[complete example](./example/) -for a host module and client. +The unit suite uses mocked HTTP with deterministic provider fixtures. The +repository also builds the direct-publish module under `spacetimedb/`. See the +[complete example](./example/) for a custom host module and client. ## License diff --git a/spacetime-agents-ts/example/public/ui.js b/spacetime-agents-ts/example/public/ui.js index f3f72394e14..63a81f11ccc 100644 --- a/spacetime-agents-ts/example/public/ui.js +++ b/spacetime-agents-ts/example/public/ui.js @@ -17,8 +17,6 @@ let inFlightSend = new Set(); let configState = { kind: 'unknown' }; let connState = 'connecting'; -// Confirmation dialog -// Usage: const ok = await confirmDialog({ title, body, confirmText, danger }); let confirmResolver = null; function confirmDialog({ title = 'Confirm', @@ -46,7 +44,6 @@ function closeConfirm(result) { } $('confirm-ok').addEventListener('click', () => closeConfirm(true)); $('confirm-cancel').addEventListener('click', () => closeConfirm(false)); -// Esc cancels, Enter confirms when the dialog is open. document.addEventListener('keydown', e => { if (!$('confirm-backdrop').classList.contains('open')) return; if (e.key === 'Escape') { @@ -57,12 +54,10 @@ document.addEventListener('keydown', e => { closeConfirm(true); } }); -// Click on backdrop (not modal) cancels. $('confirm-backdrop').addEventListener('click', e => { if (e.target === $('confirm-backdrop')) closeConfirm(false); }); -// Image viewer function openImageViewer(src, title) { $('image-full').src = src; $('image-full').alt = title; @@ -88,7 +83,6 @@ document.addEventListener('keydown', e => { } }); -// Toast messages let toastTimer = null; function toast(kind, text) { const el = $('toast'); @@ -100,7 +94,6 @@ function toast(kind, text) { toastTimer = setTimeout(() => el.classList.remove('show'), 2400); } -// Connection state window.addEventListener('stdb:connState', e => { const { state, detail } = e.detail; connState = state; @@ -128,7 +121,6 @@ window.addEventListener('stdb:ready', () => { updateButtons(); }); -// Sidebar collapse toggle. $('btn-toggle-sidebar').addEventListener('click', () => { const sb = $('sidebar'); sb.classList.toggle('collapsed'); @@ -137,12 +129,10 @@ $('btn-toggle-sidebar').addEventListener('click', () => { : '«'; }); -// Hero "+ New chat" delegates to the sidebar's handler. $('btn-new-thread-hero').addEventListener('click', () => $('btn-new-thread').click() ); -// Configuration window.addEventListener('stdb:config', e => { configState = e.detail.state; if (configState.kind === 'unconfigured') { @@ -245,13 +235,11 @@ $('setup-form').addEventListener('submit', async e => { $('btn-settings').addEventListener('click', openSetup); -// Agent overrides window.addEventListener('stdb:overrides', e => { overrides = new Map((e.detail.overrides ?? []).map(o => [o.agentName, o])); renderMessages(); }); -// Locks window.addEventListener('stdb:locks', e => { lockedThreads = new Map(e.detail.locks); renderThreads(); @@ -259,7 +247,6 @@ window.addEventListener('stdb:locks', e => { updateButtons(); }); -// Threads window.addEventListener('stdb:threads', e => { allThreads = e.detail.threads; renderThreads(); @@ -273,7 +260,6 @@ window.addEventListener('stdb:threads', e => { ) { selectThread(allThreads[0]?.id ?? null); } - // Re-render the chat header so title/model updates on the active thread flow through. if (activeThreadId !== null) renderMessages(); updateButtons(); }); @@ -322,9 +308,6 @@ function renderThreads() { } } -// "+ New chat" creates a thread immediately with the default agent -// (first registered). System prompt override + agent-specific -// tweaks live in the Rename modal after creation. const DEFAULT_AGENT_PREFERENCE = ['chat']; function pickDefaultAgent() { const agents = @@ -367,7 +350,6 @@ $('btn-new-thread').addEventListener('click', async () => { } }); -// Thread row menu let openMenuEl = null; function closeRowMenu() { if (openMenuEl) { @@ -413,7 +395,6 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeRowMenu(); }); -// Thread rename and delete actions let renameTargetId = null; function openRenameFor(threadId) { const t = allThreads.find(x => x.id === threadId); @@ -478,7 +459,6 @@ $('rename-form').addEventListener('submit', async e => { } }); -// Messages window.addEventListener('stdb:messages', e => { allMessages = e.detail.messages; allAttachments = e.detail.attachments ?? {}; @@ -492,7 +472,6 @@ function renderMessages() { const composer = $('composer'); const hero = $('empty-hero'); - // Empty state: hide messages + composer + head, show the hero. if (activeThreadId === null) { head.hidden = true; hero.style.display = 'flex'; @@ -545,7 +524,6 @@ function renderMessages() { body.className = 'body'; if (m.role === 'assistant' && !m.isError) { body.innerHTML = renderMarkdown(m.content) || '(empty)'; - // Wrap each
           in .code-block + add copy button.
                 body.querySelectorAll('pre').forEach(pre => {
                   const wrap = document.createElement('div');
                   wrap.className = 'code-block';
          @@ -627,7 +605,6 @@ function renderMessages() {
                 node.appendChild(tc);
               }
           
          -    // Single hover-revealed footer: icon actions left, usage right.
               if (m.role === 'assistant') {
                 const footer = document.createElement('div');
                 footer.className = 'msg-footer';
          @@ -733,7 +710,6 @@ function formatToolCalls(arr) {
               .join('\n');
           }
           
          -// Message composer
           const MAX_ATTACH_BYTES = 4_000_000;
           const MAX_ATTACH_COUNT = 4;
           const MAX_ATTACH_TOTAL_BYTES = 12_000_000;
          @@ -760,7 +736,6 @@ function renderPendingAttachments() {
             });
           }
           
          -// Model picker
           // Model list comes from OpenRouter's /api/v1/models so it's always
           // current. Cached per page load.
           let modelListCache = null;
          @@ -1017,7 +992,6 @@ function updateButtons() {
             $('btn-stop').textContent = lockedThreads.get(tid) ? 'stopping…' : 'Stop';
           }
           
          -// Auth view + login card + user panel + agent strip
           let currentUserState = null;
           let authMode = 'login'; // 'login' | 'signup' | 'forgot'
           
          @@ -1153,7 +1127,6 @@ function applyConnStateToAvatar(state) {
                     : 'offline';
           }
           
          -// Swap views based on auth state.
           function showAuthView() {
             $('auth-shell').hidden = false;
             $('shell').hidden = true;
          @@ -1184,7 +1157,6 @@ window.addEventListener('auth:state', e => {
             }
           });
           
          -// Mirror conn state into the user-panel status dot.
           window.addEventListener('stdb:connState', e => {
             applyConnStateToAvatar(e.detail.state);
           });
          diff --git a/spacetime-agents-ts/example/server.ts b/spacetime-agents-ts/example/server.ts
          index 25ec71241d5..c97ab593a88 100644
          --- a/spacetime-agents-ts/example/server.ts
          +++ b/spacetime-agents-ts/example/server.ts
          @@ -1,6 +1,3 @@
          -// Express + static. Browser connects to STDB directly via WebSocket; this
          -// server also proxies module HTTP handlers so cookies stay same-origin.
          -
           import path from 'node:path';
           import { fileURLToPath } from 'node:url';
           import { spawnSync } from 'node:child_process';
          @@ -154,8 +151,7 @@ function configureAgentsFromEnv(): void {
           const app = express();
           app.use(express.json({ limit: '256kb' }));
           
          -// Reset-password email link serves the SPA so the frontend can read ?token=...
          -// Must be registered BEFORE the /auth proxy below.
          +// Register this before the /auth proxy so reset links reach the SPA.
           app.get('/auth/password/reset', (_req: Request, res: Response) => {
             res.sendFile(path.join(__dirname, 'public', 'index.html'));
           });
          diff --git a/spacetime-agents-ts/example/spacetimedb/src/index.ts b/spacetime-agents-ts/example/spacetimedb/src/index.ts
          index 6104bccc195..fab404d1d39 100644
          --- a/spacetime-agents-ts/example/spacetimedb/src/index.ts
          +++ b/spacetime-agents-ts/example/spacetimedb/src/index.ts
          @@ -1,4 +1,3 @@
          -// Multi-agent submodule. Effective config = thread > agent_override > code default.
           import {
             schema,
             table,
          diff --git a/spacetime-agents-ts/example/spacetimedb/src/loop.ts b/spacetime-agents-ts/example/spacetimedb/src/loop.ts
          index 478f41fa598..64d41f4ef61 100644
          --- a/spacetime-agents-ts/example/spacetimedb/src/loop.ts
          +++ b/spacetime-agents-ts/example/spacetimedb/src/loop.ts
          @@ -1,5 +1,3 @@
          -// Pure agent loop. No STDB imports; tests inject fakes.
          -
           import {
             callChat,
             type ChatMessage,
          diff --git a/spacetime-agents-ts/example/spacetimedb/src/summarize.ts b/spacetime-agents-ts/example/spacetimedb/src/summarize.ts
          index 338d005d2c4..0da8ebdbb13 100644
          --- a/spacetime-agents-ts/example/spacetimedb/src/summarize.ts
          +++ b/spacetime-agents-ts/example/spacetimedb/src/summarize.ts
          @@ -1,8 +1,5 @@
          -// Auto-summarization helpers. HTTP call lives in index.ts.
          -
           import type { LoopMessage } from './loop';
           
          -// Returns null if nothing new to summarize.
           export function pickSummarizationCandidates(
             messages: LoopMessage[], // ascending by id
             maxHistoryMessages: number,
          diff --git a/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts b/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts
          index 694168ce918..d4cab2280f9 100644
          --- a/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts
          +++ b/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts
          @@ -1,5 +1,3 @@
          -// Pure; isolated from spacetimedb/server so tsx tests can import it.
          -
           const ONE_SECOND_MICROS = 1_000_000n;
           const ONE_MINUTE_MICROS = 60n * ONE_SECOND_MICROS;
           
          diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts
          index 79f54e573ad..3af39f1c2c1 100644
          --- a/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts
          +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts
          @@ -1,5 +1,3 @@
          -// Demo tool: echoes the given message back.
          -
           import { t } from 'spacetimedb/server';
           import { agentTool } from '@spacetimedb/agents/kit';
           
          diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts
          index d52b8a98880..498d35e938d 100644
          --- a/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts
          +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts
          @@ -1,6 +1,3 @@
          -// Demo tool: returns the current server time. ctx cast to Tx avoids a
          -// circular type reference between the kit and the schema-derived Tx.
          -
           import { t } from 'spacetimedb/server';
           import { agentTool } from '@spacetimedb/agents/kit';
           import type { Tx } from '../types';
          @@ -9,6 +6,7 @@ export default agentTool(
             'returns the current server time as an ISO-8601 string',
             t.unit(),
             ctx => {
          +    // This cast avoids a circular reference between the kit and schema types.
               const tx = ctx as Tx;
               const micros = tx.timestamp.microsSinceUnixEpoch as bigint;
               return new Date(Number(micros / 1000n)).toISOString();
          diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts
          index 19ea082273f..cb0ff5c3b54 100644
          --- a/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts
          +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts
          @@ -1,2 +1 @@
          -// Tool dir barrel. Agents import specific tools from ../agents/*.
           export {};
          diff --git a/spacetime-agents-ts/example/src/app.ts b/spacetime-agents-ts/example/src/app.ts
          index 594fcb48b8e..9be1cd0a7e4 100644
          --- a/spacetime-agents-ts/example/src/app.ts
          +++ b/spacetime-agents-ts/example/src/app.ts
          @@ -1,4 +1,3 @@
          -// STDB connection + chat ops + auth. Exposes window.auth and window.stdb.
           import {
             DbConnection,
             tables,
          @@ -249,11 +248,11 @@ function saveStdbToken(token: string): void {
             }
           }
           
          -function buildConnection(uri: string, db: string): Promise {
          +function connect(uri: string, databaseName: string): Promise {
             return new Promise((resolve, reject) => {
               DbConnection.builder()
                 .withUri(uri)
          -      .withDatabaseName(db)
          +      .withDatabaseName(databaseName)
                 .withToken(loadStdbToken())
                 .onConnect((c, _identity, token) => {
                   if (token) saveStdbToken(token);
          @@ -323,7 +322,7 @@ function setActiveThread(threadId: bigint | null): void {
               .subscribe([tables.myMessages.where(row => row.threadId.eq(threadId))]);
           }
           
          -function wireRowHandlers(conn: DbConnection): void {
          +function registerRowCallbacks(conn: DbConnection): void {
             conn.db.myThreads.onInsert(() => broadcastThreads());
             conn.db.myThreads.onUpdate(() => broadcastThreads());
             conn.db.myThreads.onDelete(() => broadcastThreads());
          @@ -380,7 +379,7 @@ async function bindSession(
             if (!currentConn) {
               broadcastConn('connecting');
               try {
          -      const conn = await buildConnection(
          +      const conn = await connect(
                   serverCfg.stdbUri,
                   serverCfg.appDatabase
                 );
          @@ -393,7 +392,7 @@ async function bindSession(
                 broadcastLocks();
                 broadcastOverrides();
           
          -      wireRowHandlers(conn);
          +      registerRowCallbacks(conn);
               } catch (err) {
                 broadcastConn('error', err instanceof Error ? err.message : String(err));
                 return;
          diff --git a/spacetime-agents-ts/package.json b/spacetime-agents-ts/package.json
          index 37de36968f0..d674f023e53 100644
          --- a/spacetime-agents-ts/package.json
          +++ b/spacetime-agents-ts/package.json
          @@ -30,6 +30,10 @@
               "./stale-locks": {
                 "types": "./src/stale-locks.ts",
                 "default": "./src/stale-locks.ts"
          +    },
          +    "./submodule": {
          +      "types": "./src/submodule.ts",
          +      "default": "./src/submodule.ts"
               }
             },
             "files": [
          diff --git a/spacetime-agents-ts/spacetimedb/src/index.ts b/spacetime-agents-ts/spacetimedb/src/index.ts
          index fb37228854d..ddd02a77d65 100644
          --- a/spacetime-agents-ts/spacetimedb/src/index.ts
          +++ b/spacetime-agents-ts/spacetimedb/src/index.ts
          @@ -1,1034 +1,2 @@
          -import {
          -  schema,
          -  table,
          -  t,
          -  Range,
          -  SenderError,
          -  type TransactionCtx,
          -  type InferSchema,
          -  type ProcedureCtx,
          -  type ReducerCtx,
          -} from 'spacetimedb/server';
          -import { Timestamp, type Identity } from 'spacetimedb';
          -import {
          -  deleteStaleThreadLocks,
          -  staleLockCutoffMicros,
          -} from '@spacetimedb/agents/stale-locks';
          -import { installAgents } from './install';
          -import {
          -  agentTool,
          -  defineAgent,
          -  makeAgentRegistry,
          -} from '@spacetimedb/agents/kit';
          -import {
          -  callChat,
          -  type ChatMessage,
          -  type Provider,
          -  type HttpLike,
          -} from '@spacetimedb/agents/openrouter';
          -import { BUILT_IN_PROVIDERS } from '@spacetimedb/agents/providers';
          -import {
          -  BUILT_IN_EMBEDDING_PROVIDERS,
          -  cosineSimilarity,
          -  topKByScore,
          -} from '@spacetimedb/agents/embeddings';
          -import {
          -  runAgentLoop,
          -  USER_CONTENT_MAX,
          -  type LoopConfig,
          -  type LoopMessage,
          -  type LoopTx,
          -} from './loop';
          -import {
          -  augmentSystemWithSummary,
          -  buildSummarizerUserContent,
          -  pickSummarizationCandidates,
          -} from './summarize';
          -
          -const ONE_SECOND_MICROS = 1_000_000n;
          -const DEFAULT_STALE_LOCK_THRESHOLD_SECS = 15 * 60;
          -
          -function throwSenderError(msg: string): never {
          -  throw new SenderError(msg);
          -}
          -
          -const echo = agentTool(
          -  'echoes the given message back to the caller',
          -  t.object('EchoArgs', { message: t.string() }),
          -  (_ctx, args) => `echo: ${args.message}`
          -);
          -
          -const getTime = agentTool(
          -  'returns the current server time as an ISO-8601 string',
          -  t.unit(),
          -  ctx => {
          -    const tx = ctx as { timestamp: { microsSinceUnixEpoch: bigint } };
          -    const micros = tx.timestamp.microsSinceUnixEpoch;
          -    return new Date(Number(micros / 1000n)).toISOString();
          -  }
          -);
          -
          -const chatAgent = defineAgent({
          -  defaultModel: 'anthropic/claude-haiku-4.5',
          -  defaultSystemPrompt:
          -    'You are a helpful assistant. Use tools when they make the answer better.',
          -  defaultMaxTurns: 10,
          -  defaultMaxHistoryMessages: 50,
          -  defaultRetries: 2,
          -  summarizerAgentName: 'summarizer',
          -  embeddingsProvider: 'openai',
          -  embeddingsModel: 'text-embedding-3-small',
          -  ragTopK: 4,
          -  tools: {
          -    get_time: getTime,
          -    echo,
          -  },
          -});
          -
          -const summarizerAgent = defineAgent({
          -  defaultModel: 'anthropic/claude-haiku-4.5',
          -  defaultSystemPrompt:
          -    'You produce concise running summaries of chat conversations. ' +
          -    'Capture facts, decisions, names, numbers, and ongoing tasks the ' +
          -    'main assistant must remember. Skip pleasantries. If the user ' +
          -    'provides an existing summary, EXTEND it with the new content. ' +
          -    'Do not restart from scratch and do not duplicate prior facts. ' +
          -    'Reply with the updated summary as plain prose, no preamble.',
          -  defaultMaxTurns: 1,
          -  defaultMaxHistoryMessages: 100,
          -  defaultMaxTokens: 600,
          -  defaultRetries: 2,
          -  tools: {},
          -});
          -
          -const agents = {
          -  chat: chatAgent,
          -  summarizer: summarizerAgent,
          -};
          -
          -import {
          -  apiKey,
          -  agentSecret,
          -  agentAdminIdentity,
          -  agentOverride,
          -  thread,
          -  message,
          -  threadLock,
          -  messageEmbedding,
          -} from './model';
          -
          -const threadLockSweeperTick = table(
          -  { name: 'thread_lock_sweeper_tick' },
          -  {
          -    scheduledId: t.u64().primaryKey().autoInc(),
          -    scheduledAt: t.scheduleAt(),
          -  }
          -);
          -
          -const spacetimedb = schema({
          -  apiKey,
          -  agentSecret,
          -  agentAdminIdentity,
          -  agentOverride,
          -  thread,
          -  message,
          -  threadLock,
          -  threadLockSweeperTick,
          -  messageEmbedding,
          -});
          -export default spacetimedb;
          -
          -type Schema = InferSchema;
          -type WriteCtx = TransactionCtx;
          -
          -const registry = makeAgentRegistry(agents);
          -
          -export const myThreads = spacetimedb.view(
          -  { name: 'my_threads', public: true },
          -  t.array(thread.rowType),
          -  ctx => [...ctx.db.thread.owner.filter(ctx.sender)]
          -);
          -
          -export const myMessages = spacetimedb.view(
          -  { name: 'my_messages', public: true },
          -  t.array(message.rowType),
          -  ctx => [...ctx.db.message.owner.filter(ctx.sender)]
          -);
          -
          -export const myThreadLocks = spacetimedb.view(
          -  { name: 'my_thread_locks', public: true },
          -  t.array(threadLock.rowType),
          -  ctx => [...ctx.db.threadLock.owner.filter(ctx.sender)]
          -);
          -
          -export const myMessageEmbeddings = spacetimedb.view(
          -  { name: 'my_message_embeddings', public: true },
          -  t.array(messageEmbedding.rowType),
          -  ctx => [...ctx.db.messageEmbedding.owner.filter(ctx.sender)]
          -);
          -
          -function requireAdmin(tx: WriteCtx): void {
          -  if (tx.db.agentAdminIdentity.identity.find(tx.sender) == null) {
          -    throwSenderError('agent.not_authorized');
          -  }
          -}
          -
          -type CallerCtx = ProcedureCtx | ReducerCtx;
          -
          -function callerIdentity(ctx: CallerCtx): Identity {
          -  return ctx.sender;
          -}
          -
          -function requireOwnedThread(tx: WriteCtx, threadId: bigint, owner: Identity) {
          -  const row = tx.db.thread.id.find(threadId);
          -  if (!row) throwSenderError(`agent.thread_not_found:${threadId}`);
          -  if (!row.owner.isEqual(owner)) {
          -    throwSenderError(`agent.not_thread_owner:${threadId}`);
          -  }
          -  return row;
          -}
          -
          -export const init = spacetimedb.init(ctx => {
          -  installAgents(ctx);
          -});
          -
          -export const set_agent_secret = spacetimedb.reducer(
          -  { staleLockThresholdSecs: t.option(t.u32()) },
          -  (ctx, args) => {
          -    const staleLockThresholdSecs =
          -      args.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS;
          -    if (staleLockThresholdSecs === 0) {
          -      throwSenderError('agent.invalid_stale_lock_threshold:must be > 0');
          -    }
          -
          -    const tx = ctx;
          -    requireAdmin(tx);
          -
          -    const existing = tx.db.agentSecret.singleton.find(true);
          -    const row = {
          -      singleton: true,
          -      staleLockThresholdSecs,
          -      updatedAt: tx.timestamp,
          -    };
          -    if (existing) {
          -      tx.db.agentSecret.singleton.update(row);
          -    } else {
          -      tx.db.agentSecret.insert(row);
          -    }
          -  }
          -);
          -
          -export const set_api_key = spacetimedb.reducer(
          -  { provider: t.string(), key: t.string() },
          -  (ctx, args) => {
          -    if (args.provider.length === 0)
          -      throwSenderError('agent.invalid_provider:empty');
          -    if (args.key.length === 0) throwSenderError('agent.invalid_api_key:empty');
          -    if (!Object.hasOwn(BUILT_IN_PROVIDERS, args.provider)) {
          -      throwSenderError(`agent.unknown_provider:${args.provider}`);
          -    }
          -    const tx = ctx;
          -    requireAdmin(tx);
          -    const existing = tx.db.apiKey.provider.find(args.provider);
          -    const row = {
          -      provider: args.provider,
          -      key: args.key,
          -      updatedAt: tx.timestamp,
          -    };
          -    if (existing) {
          -      tx.db.apiKey.provider.update(row);
          -    } else {
          -      tx.db.apiKey.insert(row);
          -    }
          -  }
          -);
          -
          -export const clear_api_key = spacetimedb.reducer(
          -  { provider: t.string() },
          -  (ctx, { provider }) => {
          -    const tx = ctx;
          -    requireAdmin(tx);
          -    const existing = tx.db.apiKey.provider.find(provider);
          -    if (existing) tx.db.apiKey.delete(existing);
          -  }
          -);
          -
          -export const set_agent_override = spacetimedb.reducer(
          -  {
          -    agentName: t.string(),
          -    provider: t.option(t.string()),
          -    model: t.option(t.string()),
          -    systemPrompt: t.option(t.string()),
          -    maxTurns: t.option(t.u32()),
          -    maxHistoryMessages: t.option(t.u32()),
          -    maxTokens: t.option(t.u32()),
          -    retries: t.option(t.u32()),
          -  },
          -  (ctx, args) => {
          -    if (!registry.has(args.agentName)) {
          -      throwSenderError(`agent.unknown:${args.agentName}`);
          -    }
          -    if (
          -      args.provider !== undefined &&
          -      !Object.hasOwn(BUILT_IN_PROVIDERS, args.provider)
          -    ) {
          -      throwSenderError(`agent.unknown_provider:${args.provider}`);
          -    }
          -    if (args.maxTurns !== undefined && args.maxTurns === 0) {
          -      throwSenderError('agent.invalid_max_turns:must be > 0');
          -    }
          -    if (
          -      args.maxHistoryMessages !== undefined &&
          -      args.maxHistoryMessages === 0
          -    ) {
          -      throwSenderError('agent.invalid_max_history:must be > 0');
          -    }
          -
          -    const tx = ctx;
          -    requireAdmin(tx);
          -    const existing = tx.db.agentOverride.agentName.find(args.agentName);
          -    const row = {
          -      agentName: args.agentName,
          -      provider: args.provider,
          -      model: args.model,
          -      systemPrompt: args.systemPrompt,
          -      maxTurns: args.maxTurns,
          -      maxHistoryMessages: args.maxHistoryMessages,
          -      maxTokens: args.maxTokens,
          -      retries: args.retries,
          -      updatedAt: tx.timestamp,
          -    };
          -    if (existing) {
          -      tx.db.agentOverride.agentName.update(row);
          -    } else {
          -      tx.db.agentOverride.insert(row);
          -    }
          -  }
          -);
          -
          -export const clear_agent_override = spacetimedb.reducer(
          -  { agentName: t.string() },
          -  (ctx, { agentName }) => {
          -    const tx = ctx;
          -    requireAdmin(tx);
          -    const existing = tx.db.agentOverride.agentName.find(agentName);
          -    if (existing) tx.db.agentOverride.delete(existing);
          -  }
          -);
          -
          -export const add_agent_admin_identity = spacetimedb.reducer(
          -  { identity: t.identity() },
          -  (ctx, { identity }) => {
          -    const tx = ctx;
          -    requireAdmin(tx);
          -    if (tx.db.agentAdminIdentity.identity.find(identity) == null) {
          -      tx.db.agentAdminIdentity.insert({
          -        identity,
          -        addedAtMicros: ctx.timestamp.microsSinceUnixEpoch,
          -      });
          -    }
          -  }
          -);
          -
          -export const remove_agent_admin_identity = spacetimedb.reducer(
          -  { identity: t.identity() },
          -  (ctx, { identity }) => {
          -    const tx = ctx;
          -    requireAdmin(tx);
          -    const existing = tx.db.agentAdminIdentity.identity.find(identity);
          -    if (!existing) return;
          -    if (tx.db.agentAdminIdentity.count() <= 1n) {
          -      throwSenderError('agent.cannot_remove_last_admin');
          -    }
          -    tx.db.agentAdminIdentity.delete(existing);
          -  }
          -);
          -
          -export const get_agent_config_status = spacetimedb.procedure(
          -  {},
          -  t.object('AgentConfigStatus', {
          -    isConfigured: t.bool(),
          -    staleLockThresholdSecs: t.u32(),
          -    agents: t.array(
          -      t.object('AgentInfo', {
          -        name: t.string(),
          -        defaultProvider: t.string(),
          -        defaultModel: t.string(),
          -      })
          -    ),
          -    configuredProviders: t.array(t.string()),
          -  }),
          -  ctx =>
          -    ctx.withTx(tx => {
          -      const secret = tx.db.agentSecret.singleton.find(true);
          -      const configuredProviders = [...tx.db.apiKey.iter()]
          -        .map(r => r.provider)
          -        .sort();
          -      const agentInfos = registry.names().map(name => {
          -        const def = registry.agentDef(name)!;
          -        return {
          -          name,
          -          defaultProvider: def.defaultProvider,
          -          defaultModel: def.defaultModel,
          -        };
          -      });
          -      return {
          -        isConfigured: secret != null,
          -        staleLockThresholdSecs:
          -          secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS,
          -        agents: agentInfos,
          -        configuredProviders,
          -      };
          -    })
          -);
          -
          -export const start_thread = spacetimedb.procedure(
          -  {
          -    agentName: t.string(),
          -    title: t.option(t.string()),
          -    systemPromptOverride: t.option(t.string()),
          -    metadata: t.option(t.string()),
          -  },
          -  t.u64(),
          -  (ctx, args) => {
          -    const owner = callerIdentity(ctx);
          -    if (!registry.has(args.agentName)) {
          -      throwSenderError(`agent.unknown:${args.agentName}`);
          -    }
          -    return ctx.withTx(tx => {
          -      const inserted = tx.db.thread.insert({
          -        id: 0n,
          -        owner,
          -        agentName: args.agentName,
          -        title: args.title,
          -        systemPromptOverride: args.systemPromptOverride,
          -        modelOverride: undefined,
          -        metadata: args.metadata,
          -        summary: undefined,
          -        summarizedThroughId: undefined,
          -        createdAt: tx.timestamp,
          -        updatedAt: tx.timestamp,
          -      });
          -      return inserted.id;
          -    });
          -  }
          -);
          -
          -export const update_thread = spacetimedb.reducer(
          -  {
          -    threadId: t.u64(),
          -    title: t.option(t.string()),
          -    systemPromptOverride: t.option(t.string()),
          -    modelOverride: t.option(t.string()),
          -    metadata: t.option(t.string()),
          -    clearTitle: t.bool(),
          -    clearSystemPromptOverride: t.bool(),
          -    clearModelOverride: t.bool(),
          -    clearMetadata: t.bool(),
          -  },
          -  (ctx, args) => {
          -    const owner = callerIdentity(ctx);
          -    const tx = ctx;
          -    const row = requireOwnedThread(tx, args.threadId, owner);
          -    tx.db.thread.id.update({
          -      ...row,
          -      title: args.clearTitle ? undefined : (args.title ?? row.title),
          -      systemPromptOverride: args.clearSystemPromptOverride
          -        ? undefined
          -        : (args.systemPromptOverride ?? row.systemPromptOverride),
          -      modelOverride: args.clearModelOverride
          -        ? undefined
          -        : (args.modelOverride ?? row.modelOverride),
          -      metadata: args.clearMetadata
          -        ? undefined
          -        : (args.metadata ?? row.metadata),
          -      updatedAt: tx.timestamp,
          -    });
          -  }
          -);
          -
          -export const delete_thread = spacetimedb.reducer(
          -  { threadId: t.u64() },
          -  (ctx, { threadId }) => {
          -    const owner = callerIdentity(ctx);
          -    const tx = ctx;
          -    const row = requireOwnedThread(tx, threadId, owner);
          -    if (tx.db.threadLock.threadId.find(threadId) != null) {
          -      throwSenderError(`agent.thread_busy:${threadId}`);
          -    }
          -    for (const e of [...tx.db.messageEmbedding.threadId.filter(threadId)]) {
          -      tx.db.messageEmbedding.delete(e);
          -    }
          -    for (const m of [...tx.db.message.threadId.filter(threadId)]) {
          -      tx.db.message.delete(m);
          -    }
          -    tx.db.thread.delete(row);
          -  }
          -);
          -
          -// Admin-gated and bypasses ownership, to clear a wedged lock.
          -export const clear_thread_lock = spacetimedb.reducer(
          -  { threadId: t.u64() },
          -  (ctx, { threadId }) => {
          -    const tx = ctx;
          -    requireAdmin(tx);
          -    const lock = tx.db.threadLock.threadId.find(threadId);
          -    if (lock) tx.db.threadLock.delete(lock);
          -  }
          -);
          -
          -export const request_cancel = spacetimedb.reducer(
          -  { threadId: t.u64() },
          -  (ctx, { threadId }) => {
          -    const owner = callerIdentity(ctx);
          -    const tx = ctx;
          -    requireOwnedThread(tx, threadId, owner);
          -    const lock = tx.db.threadLock.threadId.find(threadId);
          -    if (!lock) throwSenderError(`agent.thread_not_running:${threadId}`);
          -    if (lock.cancelRequested) return;
          -    tx.db.threadLock.threadId.update({ ...lock, cancelRequested: true });
          -  }
          -);
          -
          -function resolveProvider(name: string): Provider {
          -  const p = BUILT_IN_PROVIDERS[name];
          -  if (!p) throwSenderError(`agent.unknown_provider:${name}`);
          -  return p;
          -}
          -
          -function loadLoopConfigOrThrow(
          -  tx: WriteCtx,
          -  threadId: bigint,
          -  owner: Identity
          -): { cfg: LoopConfig; agentName: string; owner: Identity } {
          -  const threadRow = requireOwnedThread(tx, threadId, owner);
          -
          -  const def = registry.agentDef(threadRow.agentName);
          -  if (!def) {
          -    throwSenderError(`agent.unknown:${threadRow.agentName}`);
          -  }
          -
          -  if (tx.db.threadLock.threadId.find(threadId) != null) {
          -    throwSenderError(`agent.thread_busy:${threadId}`);
          -  }
          -  if (tx.db.agentSecret.singleton.find(true) == null) {
          -    throwSenderError('agent.not_configured');
          -  }
          -
          -  const override = tx.db.agentOverride.agentName.find(threadRow.agentName);
          -  const providerName = override?.provider ?? def.defaultProvider;
          -  const provider = resolveProvider(providerName);
          -
          -  const keyRow = tx.db.apiKey.provider.find(providerName);
          -  if (!keyRow) throwSenderError(`agent.no_api_key:${providerName}`);
          -
          -  return {
          -    cfg: {
          -      provider,
          -      apiKey: keyRow.key,
          -      model: threadRow.modelOverride ?? override?.model ?? def.defaultModel,
          -      systemPrompt:
          -        threadRow.systemPromptOverride ??
          -        override?.systemPrompt ??
          -        def.defaultSystemPrompt,
          -      maxTurns: override?.maxTurns ?? def.defaultMaxTurns,
          -      maxHistoryMessages:
          -        override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages,
          -      maxTokens: override?.maxTokens ?? def.defaultMaxTokens,
          -      retries: override?.retries ?? def.defaultRetries,
          -      responseFormat: def.defaultResponseFormat,
          -    },
          -    agentName: threadRow.agentName,
          -    owner: threadRow.owner,
          -  };
          -}
          -
          -function augmentSystemWithRag(
          -  base: string | undefined,
          -  snippets: string[]
          -): string | undefined {
          -  if (snippets.length === 0) return base;
          -  const b = base ?? '';
          -  return `${b}\n\n## Relevant earlier messages\n${snippets.join('\n---\n')}`.trim();
          -}
          -
          -type ProcLikeCtx = {
          -  http: HttpLike;
          -  withTx: (fn: (tx: WriteCtx) => R) => R;
          -};
          -
          -function threadMessagesAscending(tx: WriteCtx, threadId: bigint) {
          -  const rows = [...tx.db.message.threadId.filter(threadId)];
          -  rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
          -  return rows;
          -}
          -
          -function toLoopMessage(r: {
          -  id: bigint;
          -  threadId: bigint;
          -  role: string;
          -  content: string;
          -  toolCallsJson: string | undefined;
          -  toolCallId: string | undefined;
          -  isError: boolean;
          -  promptTokens: number | undefined;
          -  completionTokens: number | undefined;
          -}): LoopMessage {
          -  return {
          -    id: r.id,
          -    threadId: r.threadId,
          -    role: r.role,
          -    content: r.content,
          -    toolCallsJson: r.toolCallsJson,
          -    toolCallId: r.toolCallId,
          -    isError: r.isError,
          -    promptTokens: r.promptTokens,
          -    completionTokens: r.completionTokens,
          -  };
          -}
          -
          -function maybeEmbedMessage(
          -  ctx: ProcLikeCtx,
          -  threadId: bigint,
          -  messageId: bigint
          -): void {
          -  const job = ctx.withTx(tx => {
          -    if (tx.db.messageEmbedding.messageId.find(messageId) != null) return null;
          -    const msg = tx.db.message.id.find(messageId);
          -    if (!msg) return null;
          -    const threadRow = tx.db.thread.id.find(threadId);
          -    if (!threadRow) return null;
          -    const def = registry.agentDef(threadRow.agentName);
          -    if (!def?.embeddingsProvider || !def.embeddingsModel) return null;
          -    const provider = BUILT_IN_EMBEDDING_PROVIDERS[def.embeddingsProvider];
          -    if (!provider) return null;
          -    const keyRow = tx.db.apiKey.provider.find(def.embeddingsProvider);
          -    if (!keyRow) return null;
          -    return {
          -      provider,
          -      apiKey: keyRow.key,
          -      model: def.embeddingsModel,
          -      content: msg.content,
          -      owner: msg.owner,
          -    };
          -  });
          -  if (!job) return;
          -
          -  const result = job.provider.embed(ctx.http, job.apiKey, job.model, [
          -    job.content,
          -  ]);
          -  if (!result.ok || result.vectors.length === 0) {
          -    console.warn(
          -      `embedding failed: ${result.ok ? 'no vectors' : result.error.kind}`
          -    );
          -    return;
          -  }
          -  ctx.withTx(tx => {
          -    if (tx.db.messageEmbedding.messageId.find(messageId) != null) return;
          -    tx.db.messageEmbedding.insert({
          -      messageId,
          -      threadId,
          -      owner: job.owner,
          -      model: job.model,
          -      vector: result.vectors[0],
          -      createdAt: tx.timestamp,
          -    });
          -  });
          -}
          -
          -function maybeRetrieveRag(ctx: ProcLikeCtx, threadId: bigint): string[] {
          -  return ctx.withTx(tx => {
          -    const threadRow = tx.db.thread.id.find(threadId);
          -    if (!threadRow) return [];
          -    const def = registry.agentDef(threadRow.agentName);
          -    if (!def || def.ragTopK <= 0) return [];
          -
          -    const msgs = threadMessagesAscending(tx, threadId);
          -    let queryMsg = undefined as (typeof msgs)[number] | undefined;
          -    for (let i = msgs.length - 1; i >= 0; i--) {
          -      if (msgs[i].role === 'user') {
          -        queryMsg = msgs[i];
          -        break;
          -      }
          -    }
          -    if (!queryMsg) return [];
          -    const queryEmb = tx.db.messageEmbedding.messageId.find(queryMsg.id);
          -    if (!queryEmb) return [];
          -
          -    const override = tx.db.agentOverride.agentName.find(threadRow.agentName);
          -    const maxHistory =
          -      override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages;
          -    const windowStartIdx = Math.max(0, msgs.length - maxHistory);
          -    const inWindowIds = new Set(msgs.slice(windowStartIdx).map(m => m.id));
          -
          -    const candidates = [
          -      ...tx.db.messageEmbedding.threadId.filter(threadId),
          -    ].filter(
          -      e => !inWindowIds.has(e.messageId) && e.messageId !== queryMsg!.id
          -    );
          -    const top = topKByScore(
          -      candidates,
          -      e => cosineSimilarity(queryEmb.vector, e.vector),
          -      def.ragTopK
          -    ).filter(x => x.score > 0);
          -
          -    const out: string[] = [];
          -    for (const { item } of top) {
          -      const m = tx.db.message.id.find(item.messageId);
          -      if (m) out.push(`[${m.role}] ${m.content}`);
          -    }
          -    return out;
          -  });
          -}
          -
          -function maybeRunSummarization(ctx: ProcLikeCtx, threadId: bigint): void {
          -  const decision = ctx.withTx(tx => {
          -    const threadRow = tx.db.thread.id.find(threadId);
          -    if (!threadRow) return null;
          -    const def = registry.agentDef(threadRow.agentName);
          -    if (!def?.summarizerAgentName) return null;
          -    const sumDef = registry.agentDef(def.summarizerAgentName);
          -    if (!sumDef) return null;
          -
          -    const override = tx.db.agentOverride.agentName.find(threadRow.agentName);
          -    const maxHistory =
          -      override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages;
          -
          -    const rows = threadMessagesAscending(tx, threadId).map(toLoopMessage);
          -
          -    const candidates = pickSummarizationCandidates(
          -      rows,
          -      maxHistory,
          -      threadRow.summarizedThroughId ?? null
          -    );
          -    if (!candidates) return null;
          -
          -    const sumOverride = tx.db.agentOverride.agentName.find(
          -      def.summarizerAgentName
          -    );
          -    const sumProviderName = sumOverride?.provider ?? sumDef.defaultProvider;
          -    const sumProvider = BUILT_IN_PROVIDERS[sumProviderName];
          -    if (!sumProvider) return null;
          -    const keyRow = tx.db.apiKey.provider.find(sumProviderName);
          -    if (!keyRow) return null;
          -
          -    return {
          -      provider: sumProvider,
          -      apiKey: keyRow.key,
          -      sumModel: sumOverride?.model ?? sumDef.defaultModel,
          -      sumSystemPrompt: sumOverride?.systemPrompt ?? sumDef.defaultSystemPrompt,
          -      sumMaxTokens: sumOverride?.maxTokens ?? sumDef.defaultMaxTokens,
          -      sumRetries: sumOverride?.retries ?? sumDef.defaultRetries,
          -      existingSummary: threadRow.summary ?? null,
          -      newDropped: candidates.newDropped,
          -      lastNewId: candidates.lastNewId,
          -    };
          -  });
          -  if (!decision) return;
          -
          -  const userContent = buildSummarizerUserContent(
          -    decision.existingSummary,
          -    decision.newDropped
          -  );
          -  const messages: ChatMessage[] = [{ role: 'user', content: userContent }];
          -  const result = callChat(ctx.http, decision.provider, {
          -    apiKey: decision.apiKey,
          -    model: decision.sumModel,
          -    system: decision.sumSystemPrompt,
          -    messages,
          -    maxTokens: decision.sumMaxTokens,
          -    retries: decision.sumRetries,
          -  });
          -  if (!result.ok || !result.response.text) {
          -    console.warn(
          -      `summarization failed: ${result.ok ? 'no text in response' : result.error.kind}`
          -    );
          -    return;
          -  }
          -
          -  ctx.withTx(tx => {
          -    const threadRow = tx.db.thread.id.find(threadId);
          -    if (!threadRow) return;
          -    tx.db.thread.id.update({
          -      ...threadRow,
          -      summary: result.response.text!,
          -      summarizedThroughId: decision.lastNewId,
          -      updatedAt: tx.timestamp,
          -    });
          -  });
          -}
          -
          -function adaptTx(tx: WriteCtx, agentName: string, owner: Identity): LoopTx {
          -  return {
          -    listMessages(threadId: bigint): LoopMessage[] {
          -      return threadMessagesAscending(tx, threadId).map(toLoopMessage);
          -    },
          -    appendMessage(row) {
          -      tx.db.message.insert({
          -        id: 0n,
          -        threadId: row.threadId,
          -        owner,
          -        role: row.role,
          -        content: row.content,
          -        toolCallsJson: row.toolCallsJson,
          -        toolCallId: row.toolCallId,
          -        isError: row.isError,
          -        promptTokens: row.promptTokens,
          -        completionTokens: row.completionTokens,
          -        createdAt: tx.timestamp,
          -      });
          -    },
          -    bumpThread(threadId: bigint): void {
          -      const r = tx.db.thread.id.find(threadId);
          -      if (r) tx.db.thread.id.update({ ...r, updatedAt: tx.timestamp });
          -    },
          -    invokeTool(name: string, inputJson: string) {
          -      return registry.invoke(agentName, tx, name, inputJson);
          -    },
          -    isCancelRequested(threadId: bigint): boolean {
          -      const lock = tx.db.threadLock.threadId.find(threadId);
          -      return lock != null && lock.cancelRequested;
          -    },
          -  };
          -}
          -
          -function runLockedLoop(
          -  ctx: ProcLikeCtx,
          -  cfg: LoopConfig,
          -  agentName: string,
          -  owner: Identity,
          -  threadId: bigint
          -): void {
          -  try {
          -    maybeRunSummarization(ctx, threadId);
          -    const ragSnippets = maybeRetrieveRag(ctx, threadId);
          -
          -    const finalCfg = ctx.withTx(tx => {
          -      const threadRow = tx.db.thread.id.find(threadId);
          -      if (!threadRow) return cfg;
          -      let systemPrompt = cfg.systemPrompt;
          -      systemPrompt = augmentSystemWithSummary(
          -        systemPrompt,
          -        threadRow.summary ?? null
          -      );
          -      systemPrompt = augmentSystemWithRag(systemPrompt, ragSnippets);
          -      return { ...cfg, systemPrompt };
          -    });
          -
          -    runAgentLoop({
          -      http: ctx.http,
          -      withTx: (fn: (lt: LoopTx) => R): R =>
          -        ctx.withTx(tx => fn(adaptTx(tx, agentName, owner))),
          -      llmToolDefs: registry.llmToolDefsFor(agentName),
          -      cfg: finalCfg,
          -      threadId,
          -    });
          -  } finally {
          -    ctx.withTx(tx => {
          -      const lock = tx.db.threadLock.threadId.find(threadId);
          -      if (lock) tx.db.threadLock.delete(lock);
          -    });
          -  }
          -}
          -
          -export const send_message = spacetimedb.procedure(
          -  { threadId: t.u64(), content: t.string() },
          -  t.unit(),
          -  (ctx, args) => {
          -    if (args.content.length === 0) {
          -      throwSenderError('agent.empty_message');
          -    }
          -    const content =
          -      args.content.length > USER_CONTENT_MAX
          -        ? args.content.slice(0, USER_CONTENT_MAX) + '...[truncated]'
          -        : args.content;
          -
          -    const owner = callerIdentity(ctx);
          -    const {
          -      cfg,
          -      agentName,
          -      owner: threadOwner,
          -      userMessageId,
          -    } = ctx.withTx(tx => {
          -      const loaded = loadLoopConfigOrThrow(tx, args.threadId, owner);
          -      tx.db.threadLock.insert({
          -        threadId: args.threadId,
          -        owner: loaded.owner,
          -        lockedAt: tx.timestamp,
          -        cancelRequested: false,
          -      });
          -      const inserted = tx.db.message.insert({
          -        id: 0n,
          -        threadId: args.threadId,
          -        owner: loaded.owner,
          -        role: 'user',
          -        content,
          -        toolCallsJson: undefined,
          -        toolCallId: undefined,
          -        isError: false,
          -        promptTokens: undefined,
          -        completionTokens: undefined,
          -        createdAt: tx.timestamp,
          -      });
          -      const threadRow = tx.db.thread.id.find(args.threadId);
          -      if (threadRow)
          -        tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp });
          -      return { ...loaded, userMessageId: inserted.id };
          -    });
          -
          -    maybeEmbedMessage(ctx, args.threadId, userMessageId);
          -    runLockedLoop(ctx, cfg, agentName, threadOwner, args.threadId);
          -    return {};
          -  }
          -);
          -
          -export const regenerate_response = spacetimedb.procedure(
          -  { threadId: t.u64() },
          -  t.unit(),
          -  (ctx, { threadId }) => {
          -    const owner = callerIdentity(ctx);
          -    const {
          -      cfg,
          -      agentName,
          -      owner: threadOwner,
          -    } = ctx.withTx(tx => {
          -      const loaded = loadLoopConfigOrThrow(tx, threadId, owner);
          -
          -      const rows = threadMessagesAscending(tx, threadId);
          -      let lastUserMsgId: bigint | undefined;
          -      for (const r of rows) {
          -        if (r.role === 'user') lastUserMsgId = r.id;
          -      }
          -      if (lastUserMsgId === undefined) {
          -        throwSenderError(`agent.regenerate_no_user_message:${threadId}`);
          -      }
          -
          -      for (const r of rows) {
          -        if (r.id > lastUserMsgId!) tx.db.message.delete(r);
          -      }
          -
          -      tx.db.threadLock.insert({
          -        threadId,
          -        owner: loaded.owner,
          -        lockedAt: tx.timestamp,
          -        cancelRequested: false,
          -      });
          -      const threadRow = tx.db.thread.id.find(threadId);
          -      if (threadRow)
          -        tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp });
          -      return loaded;
          -    });
          -
          -    runLockedLoop(ctx, cfg, agentName, threadOwner, threadId);
          -    return {};
          -  }
          -);
          -
          -export const generate_thread_title = spacetimedb.procedure(
          -  { threadId: t.u64() },
          -  t.unit(),
          -  (ctx, { threadId }) => {
          -    const owner = callerIdentity(ctx);
          -    const job = ctx.withTx(tx => {
          -      const threadRow = tx.db.thread.id.find(threadId);
          -      if (!threadRow) return null;
          -      if (!threadRow.owner.isEqual(owner)) {
          -        throwSenderError(`agent.not_thread_owner:${threadId}`);
          -      }
          -      if (threadRow.title != null && threadRow.title.length > 0) return null;
          -
          -      const def = registry.agentDef(threadRow.agentName);
          -      if (!def) return null;
          -      const sumName = def.summarizerAgentName ?? threadRow.agentName;
          -      const sumDef = registry.agentDef(sumName);
          -      if (!sumDef) return null;
          -
          -      const override = tx.db.agentOverride.agentName.find(sumName);
          -      const providerName = override?.provider ?? sumDef.defaultProvider;
          -      const provider = BUILT_IN_PROVIDERS[providerName];
          -      if (!provider) return null;
          -      const keyRow = tx.db.apiKey.provider.find(providerName);
          -      if (!keyRow) return null;
          -
          -      const msgs = threadMessagesAscending(tx, threadId);
          -      const firstUser = msgs.find(m => m.role === 'user');
          -      if (!firstUser) return null;
          -
          -      return {
          -        provider,
          -        apiKey: keyRow.key,
          -        model: override?.model ?? sumDef.defaultModel,
          -        retries: override?.retries ?? sumDef.defaultRetries,
          -        firstMessage: firstUser.content,
          -      };
          -    });
          -    if (!job) return {};
          -
          -    const result = callChat(ctx.http, job.provider, {
          -      apiKey: job.apiKey,
          -      model: job.model,
          -      system:
          -        'You title chat conversations. The user will paste the opening message of ' +
          -        'a chat. You output a 3-5 word title describing the topic. ' +
          -        'CRITICAL: do not answer or respond to the message. Do not greet. ' +
          -        'Output the title and only the title. No quotes, no punctuation at the end.',
          -      messages: [
          -        {
          -          role: 'user',
          -          content: `Title for a chat that starts with this message:\n\n\n${job.firstMessage}\n`,
          -        },
          -      ],
          -      maxTokens: 30,
          -      retries: job.retries,
          -    });
          -    if (!result.ok || !result.response.text) {
          -      console.warn(
          -        `title gen failed: ${result.ok ? 'no text' : result.error.kind}`
          -      );
          -      return {};
          -    }
          -
          -    const cleaned = result.response.text
          -      .trim()
          -      .replace(/^["']|["']$/g, '')
          -      .replace(/[.!?]+$/g, '')
          -      .slice(0, 80);
          -
          -    ctx.withTx(tx => {
          -      const t2 = tx.db.thread.id.find(threadId);
          -      if (!t2 || (t2.title != null && t2.title.length > 0)) return;
          -      tx.db.thread.id.update({
          -        ...t2,
          -        title: cleaned,
          -        updatedAt: tx.timestamp,
          -      });
          -    });
          -    return {};
          -  }
          -);
          -
          -export const thread_lock_sweep = spacetimedb.reducer(
          -  { onSchedule: threadLockSweeperTick },
          -  { arg: threadLockSweeperTick.rowType },
          -  (ctx, _arg) => {
          -    const secret = ctx.db.agentSecret.singleton.find(true);
          -    const thresholdSecs =
          -      secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS;
          -    const thresholdMicros = BigInt(thresholdSecs) * ONE_SECOND_MICROS;
          -
          -    const cutoffMicros = staleLockCutoffMicros(
          -      ctx.timestamp.microsSinceUnixEpoch,
          -      thresholdMicros
          -    );
          -    deleteStaleThreadLocks(
          -      ctx.db.threadLock.lockedAt.filter(
          -        new Range(undefined, {
          -          tag: 'excluded',
          -          value: new Timestamp(cutoffMicros),
          -        })
          -      ),
          -      cutoffMicros,
          -      lock => ctx.db.threadLock.delete(lock)
          -    );
          -  }
          -);
          +export { default } from '../../src/mounted/index';
          +export * from '../../src/mounted/index';
          diff --git a/spacetime-agents-ts/src/mounted/index.ts b/spacetime-agents-ts/src/mounted/index.ts
          new file mode 100644
          index 00000000000..b22d9cee75a
          --- /dev/null
          +++ b/spacetime-agents-ts/src/mounted/index.ts
          @@ -0,0 +1,1027 @@
          +import {
          +  schema,
          +  table,
          +  t,
          +  Range,
          +  SenderError,
          +  type TransactionCtx,
          +  type InferSchema,
          +  type ProcedureCtx,
          +  type ReducerCtx,
          +} from 'spacetimedb/server';
          +import { Timestamp, type Identity } from 'spacetimedb';
          +import { deleteStaleThreadLocks, staleLockCutoffMicros } from '../stale-locks';
          +import { installAgents } from './install';
          +import { agentTool, defineAgent, makeAgentRegistry } from '../kit';
          +import {
          +  callChat,
          +  type ChatMessage,
          +  type Provider,
          +  type HttpLike,
          +} from '../openrouter';
          +import { BUILT_IN_PROVIDERS } from '../providers';
          +import {
          +  BUILT_IN_EMBEDDING_PROVIDERS,
          +  cosineSimilarity,
          +  topKByScore,
          +} from '../embeddings';
          +import {
          +  runAgentLoop,
          +  USER_CONTENT_MAX,
          +  type LoopConfig,
          +  type LoopMessage,
          +  type LoopTx,
          +} from './loop';
          +import {
          +  augmentSystemWithSummary,
          +  buildSummarizerUserContent,
          +  pickSummarizationCandidates,
          +} from './summarize';
          +
          +const ONE_SECOND_MICROS = 1_000_000n;
          +const DEFAULT_STALE_LOCK_THRESHOLD_SECS = 15 * 60;
          +
          +function throwSenderError(msg: string): never {
          +  throw new SenderError(msg);
          +}
          +
          +const echo = agentTool(
          +  'echoes the given message back to the caller',
          +  t.object('EchoArgs', { message: t.string() }),
          +  (_ctx, args) => `echo: ${args.message}`
          +);
          +
          +const getTime = agentTool(
          +  'returns the current server time as an ISO-8601 string',
          +  t.unit(),
          +  ctx => {
          +    const tx = ctx as { timestamp: { microsSinceUnixEpoch: bigint } };
          +    const micros = tx.timestamp.microsSinceUnixEpoch;
          +    return new Date(Number(micros / 1000n)).toISOString();
          +  }
          +);
          +
          +const chatAgent = defineAgent({
          +  defaultModel: 'anthropic/claude-haiku-4.5',
          +  defaultSystemPrompt:
          +    'You are a helpful assistant. Use tools when they make the answer better.',
          +  defaultMaxTurns: 10,
          +  defaultMaxHistoryMessages: 50,
          +  defaultRetries: 2,
          +  summarizerAgentName: 'summarizer',
          +  embeddingsProvider: 'openai',
          +  embeddingsModel: 'text-embedding-3-small',
          +  ragTopK: 4,
          +  tools: {
          +    get_time: getTime,
          +    echo,
          +  },
          +});
          +
          +const summarizerAgent = defineAgent({
          +  defaultModel: 'anthropic/claude-haiku-4.5',
          +  defaultSystemPrompt:
          +    'You produce concise running summaries of chat conversations. ' +
          +    'Capture facts, decisions, names, numbers, and ongoing tasks the ' +
          +    'main assistant must remember. Skip pleasantries. If the user ' +
          +    'provides an existing summary, EXTEND it with the new content. ' +
          +    'Do not restart from scratch and do not duplicate prior facts. ' +
          +    'Reply with the updated summary as plain prose, no preamble.',
          +  defaultMaxTurns: 1,
          +  defaultMaxHistoryMessages: 100,
          +  defaultMaxTokens: 600,
          +  defaultRetries: 2,
          +  tools: {},
          +});
          +
          +const agents = {
          +  chat: chatAgent,
          +  summarizer: summarizerAgent,
          +};
          +
          +import {
          +  apiKey,
          +  agentSecret,
          +  agentAdminIdentity,
          +  agentOverride,
          +  thread,
          +  message,
          +  threadLock,
          +  messageEmbedding,
          +} from './model';
          +
          +const threadLockSweeperTick = table(
          +  { name: 'thread_lock_sweeper_tick' },
          +  {
          +    scheduledId: t.u64().primaryKey().autoInc(),
          +    scheduledAt: t.scheduleAt(),
          +  }
          +);
          +
          +const spacetimedb = schema({
          +  apiKey,
          +  agentSecret,
          +  agentAdminIdentity,
          +  agentOverride,
          +  thread,
          +  message,
          +  threadLock,
          +  threadLockSweeperTick,
          +  messageEmbedding,
          +});
          +export default spacetimedb;
          +
          +type Schema = InferSchema;
          +type WriteCtx = TransactionCtx;
          +
          +const registry = makeAgentRegistry(agents);
          +
          +export const myThreads = spacetimedb.view(
          +  { name: 'my_threads', public: true },
          +  t.array(thread.rowType),
          +  ctx => [...ctx.db.thread.owner.filter(ctx.sender)]
          +);
          +
          +export const myMessages = spacetimedb.view(
          +  { name: 'my_messages', public: true },
          +  t.array(message.rowType),
          +  ctx => [...ctx.db.message.owner.filter(ctx.sender)]
          +);
          +
          +export const myThreadLocks = spacetimedb.view(
          +  { name: 'my_thread_locks', public: true },
          +  t.array(threadLock.rowType),
          +  ctx => [...ctx.db.threadLock.owner.filter(ctx.sender)]
          +);
          +
          +export const myMessageEmbeddings = spacetimedb.view(
          +  { name: 'my_message_embeddings', public: true },
          +  t.array(messageEmbedding.rowType),
          +  ctx => [...ctx.db.messageEmbedding.owner.filter(ctx.sender)]
          +);
          +
          +function requireAdmin(tx: WriteCtx): void {
          +  if (tx.db.agentAdminIdentity.identity.find(tx.sender) == null) {
          +    throwSenderError('agent.not_authorized');
          +  }
          +}
          +
          +type CallerCtx = ProcedureCtx | ReducerCtx;
          +
          +function callerIdentity(ctx: CallerCtx): Identity {
          +  return ctx.sender;
          +}
          +
          +function requireOwnedThread(tx: WriteCtx, threadId: bigint, owner: Identity) {
          +  const row = tx.db.thread.id.find(threadId);
          +  if (!row) throwSenderError(`agent.thread_not_found:${threadId}`);
          +  if (!row.owner.isEqual(owner)) {
          +    throwSenderError(`agent.not_thread_owner:${threadId}`);
          +  }
          +  return row;
          +}
          +
          +export const init = spacetimedb.init(ctx => {
          +  installAgents(ctx);
          +});
          +
          +export const set_agent_secret = spacetimedb.reducer(
          +  { staleLockThresholdSecs: t.option(t.u32()) },
          +  (ctx, args) => {
          +    const staleLockThresholdSecs =
          +      args.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS;
          +    if (staleLockThresholdSecs === 0) {
          +      throwSenderError('agent.invalid_stale_lock_threshold:must be > 0');
          +    }
          +
          +    const tx = ctx;
          +    requireAdmin(tx);
          +
          +    const existing = tx.db.agentSecret.singleton.find(true);
          +    const row = {
          +      singleton: true,
          +      staleLockThresholdSecs,
          +      updatedAt: tx.timestamp,
          +    };
          +    if (existing) {
          +      tx.db.agentSecret.singleton.update(row);
          +    } else {
          +      tx.db.agentSecret.insert(row);
          +    }
          +  }
          +);
          +
          +export const set_api_key = spacetimedb.reducer(
          +  { provider: t.string(), key: t.string() },
          +  (ctx, args) => {
          +    if (args.provider.length === 0)
          +      throwSenderError('agent.invalid_provider:empty');
          +    if (args.key.length === 0) throwSenderError('agent.invalid_api_key:empty');
          +    if (!Object.hasOwn(BUILT_IN_PROVIDERS, args.provider)) {
          +      throwSenderError(`agent.unknown_provider:${args.provider}`);
          +    }
          +    const tx = ctx;
          +    requireAdmin(tx);
          +    const existing = tx.db.apiKey.provider.find(args.provider);
          +    const row = {
          +      provider: args.provider,
          +      key: args.key,
          +      updatedAt: tx.timestamp,
          +    };
          +    if (existing) {
          +      tx.db.apiKey.provider.update(row);
          +    } else {
          +      tx.db.apiKey.insert(row);
          +    }
          +  }
          +);
          +
          +export const clear_api_key = spacetimedb.reducer(
          +  { provider: t.string() },
          +  (ctx, { provider }) => {
          +    const tx = ctx;
          +    requireAdmin(tx);
          +    const existing = tx.db.apiKey.provider.find(provider);
          +    if (existing) tx.db.apiKey.delete(existing);
          +  }
          +);
          +
          +export const set_agent_override = spacetimedb.reducer(
          +  {
          +    agentName: t.string(),
          +    provider: t.option(t.string()),
          +    model: t.option(t.string()),
          +    systemPrompt: t.option(t.string()),
          +    maxTurns: t.option(t.u32()),
          +    maxHistoryMessages: t.option(t.u32()),
          +    maxTokens: t.option(t.u32()),
          +    retries: t.option(t.u32()),
          +  },
          +  (ctx, args) => {
          +    if (!registry.has(args.agentName)) {
          +      throwSenderError(`agent.unknown:${args.agentName}`);
          +    }
          +    if (
          +      args.provider !== undefined &&
          +      !Object.hasOwn(BUILT_IN_PROVIDERS, args.provider)
          +    ) {
          +      throwSenderError(`agent.unknown_provider:${args.provider}`);
          +    }
          +    if (args.maxTurns !== undefined && args.maxTurns === 0) {
          +      throwSenderError('agent.invalid_max_turns:must be > 0');
          +    }
          +    if (
          +      args.maxHistoryMessages !== undefined &&
          +      args.maxHistoryMessages === 0
          +    ) {
          +      throwSenderError('agent.invalid_max_history:must be > 0');
          +    }
          +
          +    const tx = ctx;
          +    requireAdmin(tx);
          +    const existing = tx.db.agentOverride.agentName.find(args.agentName);
          +    const row = {
          +      agentName: args.agentName,
          +      provider: args.provider,
          +      model: args.model,
          +      systemPrompt: args.systemPrompt,
          +      maxTurns: args.maxTurns,
          +      maxHistoryMessages: args.maxHistoryMessages,
          +      maxTokens: args.maxTokens,
          +      retries: args.retries,
          +      updatedAt: tx.timestamp,
          +    };
          +    if (existing) {
          +      tx.db.agentOverride.agentName.update(row);
          +    } else {
          +      tx.db.agentOverride.insert(row);
          +    }
          +  }
          +);
          +
          +export const clear_agent_override = spacetimedb.reducer(
          +  { agentName: t.string() },
          +  (ctx, { agentName }) => {
          +    const tx = ctx;
          +    requireAdmin(tx);
          +    const existing = tx.db.agentOverride.agentName.find(agentName);
          +    if (existing) tx.db.agentOverride.delete(existing);
          +  }
          +);
          +
          +export const add_agent_admin_identity = spacetimedb.reducer(
          +  { identity: t.identity() },
          +  (ctx, { identity }) => {
          +    const tx = ctx;
          +    requireAdmin(tx);
          +    if (tx.db.agentAdminIdentity.identity.find(identity) == null) {
          +      tx.db.agentAdminIdentity.insert({
          +        identity,
          +        addedAtMicros: ctx.timestamp.microsSinceUnixEpoch,
          +      });
          +    }
          +  }
          +);
          +
          +export const remove_agent_admin_identity = spacetimedb.reducer(
          +  { identity: t.identity() },
          +  (ctx, { identity }) => {
          +    const tx = ctx;
          +    requireAdmin(tx);
          +    const existing = tx.db.agentAdminIdentity.identity.find(identity);
          +    if (!existing) return;
          +    if (tx.db.agentAdminIdentity.count() <= 1n) {
          +      throwSenderError('agent.cannot_remove_last_admin');
          +    }
          +    tx.db.agentAdminIdentity.delete(existing);
          +  }
          +);
          +
          +export const get_agent_config_status = spacetimedb.procedure(
          +  {},
          +  t.object('AgentConfigStatus', {
          +    isConfigured: t.bool(),
          +    staleLockThresholdSecs: t.u32(),
          +    agents: t.array(
          +      t.object('AgentInfo', {
          +        name: t.string(),
          +        defaultProvider: t.string(),
          +        defaultModel: t.string(),
          +      })
          +    ),
          +    configuredProviders: t.array(t.string()),
          +  }),
          +  ctx =>
          +    ctx.withTx(tx => {
          +      const secret = tx.db.agentSecret.singleton.find(true);
          +      const configuredProviders = [...tx.db.apiKey.iter()]
          +        .map(r => r.provider)
          +        .sort();
          +      const agentInfos = registry.names().map(name => {
          +        const def = registry.agentDef(name)!;
          +        return {
          +          name,
          +          defaultProvider: def.defaultProvider,
          +          defaultModel: def.defaultModel,
          +        };
          +      });
          +      return {
          +        isConfigured: secret != null,
          +        staleLockThresholdSecs:
          +          secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS,
          +        agents: agentInfos,
          +        configuredProviders,
          +      };
          +    })
          +);
          +
          +export const start_thread = spacetimedb.procedure(
          +  {
          +    agentName: t.string(),
          +    title: t.option(t.string()),
          +    systemPromptOverride: t.option(t.string()),
          +    metadata: t.option(t.string()),
          +  },
          +  t.u64(),
          +  (ctx, args) => {
          +    const owner = callerIdentity(ctx);
          +    if (!registry.has(args.agentName)) {
          +      throwSenderError(`agent.unknown:${args.agentName}`);
          +    }
          +    return ctx.withTx(tx => {
          +      const inserted = tx.db.thread.insert({
          +        id: 0n,
          +        owner,
          +        agentName: args.agentName,
          +        title: args.title,
          +        systemPromptOverride: args.systemPromptOverride,
          +        modelOverride: undefined,
          +        metadata: args.metadata,
          +        summary: undefined,
          +        summarizedThroughId: undefined,
          +        createdAt: tx.timestamp,
          +        updatedAt: tx.timestamp,
          +      });
          +      return inserted.id;
          +    });
          +  }
          +);
          +
          +export const update_thread = spacetimedb.reducer(
          +  {
          +    threadId: t.u64(),
          +    title: t.option(t.string()),
          +    systemPromptOverride: t.option(t.string()),
          +    modelOverride: t.option(t.string()),
          +    metadata: t.option(t.string()),
          +    clearTitle: t.bool(),
          +    clearSystemPromptOverride: t.bool(),
          +    clearModelOverride: t.bool(),
          +    clearMetadata: t.bool(),
          +  },
          +  (ctx, args) => {
          +    const owner = callerIdentity(ctx);
          +    const tx = ctx;
          +    const row = requireOwnedThread(tx, args.threadId, owner);
          +    tx.db.thread.id.update({
          +      ...row,
          +      title: args.clearTitle ? undefined : (args.title ?? row.title),
          +      systemPromptOverride: args.clearSystemPromptOverride
          +        ? undefined
          +        : (args.systemPromptOverride ?? row.systemPromptOverride),
          +      modelOverride: args.clearModelOverride
          +        ? undefined
          +        : (args.modelOverride ?? row.modelOverride),
          +      metadata: args.clearMetadata
          +        ? undefined
          +        : (args.metadata ?? row.metadata),
          +      updatedAt: tx.timestamp,
          +    });
          +  }
          +);
          +
          +export const delete_thread = spacetimedb.reducer(
          +  { threadId: t.u64() },
          +  (ctx, { threadId }) => {
          +    const owner = callerIdentity(ctx);
          +    const tx = ctx;
          +    const row = requireOwnedThread(tx, threadId, owner);
          +    if (tx.db.threadLock.threadId.find(threadId) != null) {
          +      throwSenderError(`agent.thread_busy:${threadId}`);
          +    }
          +    for (const e of [...tx.db.messageEmbedding.threadId.filter(threadId)]) {
          +      tx.db.messageEmbedding.delete(e);
          +    }
          +    for (const m of [...tx.db.message.threadId.filter(threadId)]) {
          +      tx.db.message.delete(m);
          +    }
          +    tx.db.thread.delete(row);
          +  }
          +);
          +
          +// Admin-gated and bypasses ownership, to clear a wedged lock.
          +export const clear_thread_lock = spacetimedb.reducer(
          +  { threadId: t.u64() },
          +  (ctx, { threadId }) => {
          +    const tx = ctx;
          +    requireAdmin(tx);
          +    const lock = tx.db.threadLock.threadId.find(threadId);
          +    if (lock) tx.db.threadLock.delete(lock);
          +  }
          +);
          +
          +export const request_cancel = spacetimedb.reducer(
          +  { threadId: t.u64() },
          +  (ctx, { threadId }) => {
          +    const owner = callerIdentity(ctx);
          +    const tx = ctx;
          +    requireOwnedThread(tx, threadId, owner);
          +    const lock = tx.db.threadLock.threadId.find(threadId);
          +    if (!lock) throwSenderError(`agent.thread_not_running:${threadId}`);
          +    if (lock.cancelRequested) return;
          +    tx.db.threadLock.threadId.update({ ...lock, cancelRequested: true });
          +  }
          +);
          +
          +function resolveProvider(name: string): Provider {
          +  const p = BUILT_IN_PROVIDERS[name];
          +  if (!p) throwSenderError(`agent.unknown_provider:${name}`);
          +  return p;
          +}
          +
          +function loadLoopConfigOrThrow(
          +  tx: WriteCtx,
          +  threadId: bigint,
          +  owner: Identity
          +): { cfg: LoopConfig; agentName: string; owner: Identity } {
          +  const threadRow = requireOwnedThread(tx, threadId, owner);
          +
          +  const def = registry.agentDef(threadRow.agentName);
          +  if (!def) {
          +    throwSenderError(`agent.unknown:${threadRow.agentName}`);
          +  }
          +
          +  if (tx.db.threadLock.threadId.find(threadId) != null) {
          +    throwSenderError(`agent.thread_busy:${threadId}`);
          +  }
          +  if (tx.db.agentSecret.singleton.find(true) == null) {
          +    throwSenderError('agent.not_configured');
          +  }
          +
          +  const override = tx.db.agentOverride.agentName.find(threadRow.agentName);
          +  const providerName = override?.provider ?? def.defaultProvider;
          +  const provider = resolveProvider(providerName);
          +
          +  const keyRow = tx.db.apiKey.provider.find(providerName);
          +  if (!keyRow) throwSenderError(`agent.no_api_key:${providerName}`);
          +
          +  return {
          +    cfg: {
          +      provider,
          +      apiKey: keyRow.key,
          +      model: threadRow.modelOverride ?? override?.model ?? def.defaultModel,
          +      systemPrompt:
          +        threadRow.systemPromptOverride ??
          +        override?.systemPrompt ??
          +        def.defaultSystemPrompt,
          +      maxTurns: override?.maxTurns ?? def.defaultMaxTurns,
          +      maxHistoryMessages:
          +        override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages,
          +      maxTokens: override?.maxTokens ?? def.defaultMaxTokens,
          +      retries: override?.retries ?? def.defaultRetries,
          +      responseFormat: def.defaultResponseFormat,
          +    },
          +    agentName: threadRow.agentName,
          +    owner: threadRow.owner,
          +  };
          +}
          +
          +function augmentSystemWithRag(
          +  base: string | undefined,
          +  snippets: string[]
          +): string | undefined {
          +  if (snippets.length === 0) return base;
          +  const b = base ?? '';
          +  return `${b}\n\n## Relevant earlier messages\n${snippets.join('\n---\n')}`.trim();
          +}
          +
          +type ProcLikeCtx = {
          +  http: HttpLike;
          +  withTx: (fn: (tx: WriteCtx) => R) => R;
          +};
          +
          +function threadMessagesAscending(tx: WriteCtx, threadId: bigint) {
          +  const rows = [...tx.db.message.threadId.filter(threadId)];
          +  rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
          +  return rows;
          +}
          +
          +function toLoopMessage(r: {
          +  id: bigint;
          +  threadId: bigint;
          +  role: string;
          +  content: string;
          +  toolCallsJson: string | undefined;
          +  toolCallId: string | undefined;
          +  isError: boolean;
          +  promptTokens: number | undefined;
          +  completionTokens: number | undefined;
          +}): LoopMessage {
          +  return {
          +    id: r.id,
          +    threadId: r.threadId,
          +    role: r.role,
          +    content: r.content,
          +    toolCallsJson: r.toolCallsJson,
          +    toolCallId: r.toolCallId,
          +    isError: r.isError,
          +    promptTokens: r.promptTokens,
          +    completionTokens: r.completionTokens,
          +  };
          +}
          +
          +function maybeEmbedMessage(
          +  ctx: ProcLikeCtx,
          +  threadId: bigint,
          +  messageId: bigint
          +): void {
          +  const job = ctx.withTx(tx => {
          +    if (tx.db.messageEmbedding.messageId.find(messageId) != null) return null;
          +    const msg = tx.db.message.id.find(messageId);
          +    if (!msg) return null;
          +    const threadRow = tx.db.thread.id.find(threadId);
          +    if (!threadRow) return null;
          +    const def = registry.agentDef(threadRow.agentName);
          +    if (!def?.embeddingsProvider || !def.embeddingsModel) return null;
          +    const provider = BUILT_IN_EMBEDDING_PROVIDERS[def.embeddingsProvider];
          +    if (!provider) return null;
          +    const keyRow = tx.db.apiKey.provider.find(def.embeddingsProvider);
          +    if (!keyRow) return null;
          +    return {
          +      provider,
          +      apiKey: keyRow.key,
          +      model: def.embeddingsModel,
          +      content: msg.content,
          +      owner: msg.owner,
          +    };
          +  });
          +  if (!job) return;
          +
          +  const result = job.provider.embed(ctx.http, job.apiKey, job.model, [
          +    job.content,
          +  ]);
          +  if (!result.ok || result.vectors.length === 0) {
          +    console.warn(
          +      `embedding failed: ${result.ok ? 'no vectors' : result.error.kind}`
          +    );
          +    return;
          +  }
          +  ctx.withTx(tx => {
          +    if (tx.db.messageEmbedding.messageId.find(messageId) != null) return;
          +    tx.db.messageEmbedding.insert({
          +      messageId,
          +      threadId,
          +      owner: job.owner,
          +      model: job.model,
          +      vector: result.vectors[0],
          +      createdAt: tx.timestamp,
          +    });
          +  });
          +}
          +
          +function maybeRetrieveRag(ctx: ProcLikeCtx, threadId: bigint): string[] {
          +  return ctx.withTx(tx => {
          +    const threadRow = tx.db.thread.id.find(threadId);
          +    if (!threadRow) return [];
          +    const def = registry.agentDef(threadRow.agentName);
          +    if (!def || def.ragTopK <= 0) return [];
          +
          +    const msgs = threadMessagesAscending(tx, threadId);
          +    let queryMsg = undefined as (typeof msgs)[number] | undefined;
          +    for (let i = msgs.length - 1; i >= 0; i--) {
          +      if (msgs[i].role === 'user') {
          +        queryMsg = msgs[i];
          +        break;
          +      }
          +    }
          +    if (!queryMsg) return [];
          +    const queryEmb = tx.db.messageEmbedding.messageId.find(queryMsg.id);
          +    if (!queryEmb) return [];
          +
          +    const override = tx.db.agentOverride.agentName.find(threadRow.agentName);
          +    const maxHistory =
          +      override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages;
          +    const windowStartIdx = Math.max(0, msgs.length - maxHistory);
          +    const inWindowIds = new Set(msgs.slice(windowStartIdx).map(m => m.id));
          +
          +    const candidates = [
          +      ...tx.db.messageEmbedding.threadId.filter(threadId),
          +    ].filter(
          +      e => !inWindowIds.has(e.messageId) && e.messageId !== queryMsg!.id
          +    );
          +    const top = topKByScore(
          +      candidates,
          +      e => cosineSimilarity(queryEmb.vector, e.vector),
          +      def.ragTopK
          +    ).filter(x => x.score > 0);
          +
          +    const out: string[] = [];
          +    for (const { item } of top) {
          +      const m = tx.db.message.id.find(item.messageId);
          +      if (m) out.push(`[${m.role}] ${m.content}`);
          +    }
          +    return out;
          +  });
          +}
          +
          +function maybeRunSummarization(ctx: ProcLikeCtx, threadId: bigint): void {
          +  const decision = ctx.withTx(tx => {
          +    const threadRow = tx.db.thread.id.find(threadId);
          +    if (!threadRow) return null;
          +    const def = registry.agentDef(threadRow.agentName);
          +    if (!def?.summarizerAgentName) return null;
          +    const sumDef = registry.agentDef(def.summarizerAgentName);
          +    if (!sumDef) return null;
          +
          +    const override = tx.db.agentOverride.agentName.find(threadRow.agentName);
          +    const maxHistory =
          +      override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages;
          +
          +    const rows = threadMessagesAscending(tx, threadId).map(toLoopMessage);
          +
          +    const candidates = pickSummarizationCandidates(
          +      rows,
          +      maxHistory,
          +      threadRow.summarizedThroughId ?? null
          +    );
          +    if (!candidates) return null;
          +
          +    const sumOverride = tx.db.agentOverride.agentName.find(
          +      def.summarizerAgentName
          +    );
          +    const sumProviderName = sumOverride?.provider ?? sumDef.defaultProvider;
          +    const sumProvider = BUILT_IN_PROVIDERS[sumProviderName];
          +    if (!sumProvider) return null;
          +    const keyRow = tx.db.apiKey.provider.find(sumProviderName);
          +    if (!keyRow) return null;
          +
          +    return {
          +      provider: sumProvider,
          +      apiKey: keyRow.key,
          +      sumModel: sumOverride?.model ?? sumDef.defaultModel,
          +      sumSystemPrompt: sumOverride?.systemPrompt ?? sumDef.defaultSystemPrompt,
          +      sumMaxTokens: sumOverride?.maxTokens ?? sumDef.defaultMaxTokens,
          +      sumRetries: sumOverride?.retries ?? sumDef.defaultRetries,
          +      existingSummary: threadRow.summary ?? null,
          +      newDropped: candidates.newDropped,
          +      lastNewId: candidates.lastNewId,
          +    };
          +  });
          +  if (!decision) return;
          +
          +  const userContent = buildSummarizerUserContent(
          +    decision.existingSummary,
          +    decision.newDropped
          +  );
          +  const messages: ChatMessage[] = [{ role: 'user', content: userContent }];
          +  const result = callChat(ctx.http, decision.provider, {
          +    apiKey: decision.apiKey,
          +    model: decision.sumModel,
          +    system: decision.sumSystemPrompt,
          +    messages,
          +    maxTokens: decision.sumMaxTokens,
          +    retries: decision.sumRetries,
          +  });
          +  if (!result.ok || !result.response.text) {
          +    console.warn(
          +      `summarization failed: ${result.ok ? 'no text in response' : result.error.kind}`
          +    );
          +    return;
          +  }
          +
          +  ctx.withTx(tx => {
          +    const threadRow = tx.db.thread.id.find(threadId);
          +    if (!threadRow) return;
          +    tx.db.thread.id.update({
          +      ...threadRow,
          +      summary: result.response.text!,
          +      summarizedThroughId: decision.lastNewId,
          +      updatedAt: tx.timestamp,
          +    });
          +  });
          +}
          +
          +function adaptTx(tx: WriteCtx, agentName: string, owner: Identity): LoopTx {
          +  return {
          +    listMessages(threadId: bigint): LoopMessage[] {
          +      return threadMessagesAscending(tx, threadId).map(toLoopMessage);
          +    },
          +    appendMessage(row) {
          +      tx.db.message.insert({
          +        id: 0n,
          +        threadId: row.threadId,
          +        owner,
          +        role: row.role,
          +        content: row.content,
          +        toolCallsJson: row.toolCallsJson,
          +        toolCallId: row.toolCallId,
          +        isError: row.isError,
          +        promptTokens: row.promptTokens,
          +        completionTokens: row.completionTokens,
          +        createdAt: tx.timestamp,
          +      });
          +    },
          +    bumpThread(threadId: bigint): void {
          +      const r = tx.db.thread.id.find(threadId);
          +      if (r) tx.db.thread.id.update({ ...r, updatedAt: tx.timestamp });
          +    },
          +    invokeTool(name: string, inputJson: string) {
          +      return registry.invoke(agentName, tx, name, inputJson);
          +    },
          +    isCancelRequested(threadId: bigint): boolean {
          +      const lock = tx.db.threadLock.threadId.find(threadId);
          +      return lock != null && lock.cancelRequested;
          +    },
          +  };
          +}
          +
          +function runLockedLoop(
          +  ctx: ProcLikeCtx,
          +  cfg: LoopConfig,
          +  agentName: string,
          +  owner: Identity,
          +  threadId: bigint
          +): void {
          +  try {
          +    maybeRunSummarization(ctx, threadId);
          +    const ragSnippets = maybeRetrieveRag(ctx, threadId);
          +
          +    const finalCfg = ctx.withTx(tx => {
          +      const threadRow = tx.db.thread.id.find(threadId);
          +      if (!threadRow) return cfg;
          +      let systemPrompt = cfg.systemPrompt;
          +      systemPrompt = augmentSystemWithSummary(
          +        systemPrompt,
          +        threadRow.summary ?? null
          +      );
          +      systemPrompt = augmentSystemWithRag(systemPrompt, ragSnippets);
          +      return { ...cfg, systemPrompt };
          +    });
          +
          +    runAgentLoop({
          +      http: ctx.http,
          +      withTx: (fn: (lt: LoopTx) => R): R =>
          +        ctx.withTx(tx => fn(adaptTx(tx, agentName, owner))),
          +      llmToolDefs: registry.llmToolDefsFor(agentName),
          +      cfg: finalCfg,
          +      threadId,
          +    });
          +  } finally {
          +    ctx.withTx(tx => {
          +      const lock = tx.db.threadLock.threadId.find(threadId);
          +      if (lock) tx.db.threadLock.delete(lock);
          +    });
          +  }
          +}
          +
          +export const send_message = spacetimedb.procedure(
          +  { threadId: t.u64(), content: t.string() },
          +  t.unit(),
          +  (ctx, args) => {
          +    if (args.content.length === 0) {
          +      throwSenderError('agent.empty_message');
          +    }
          +    const content =
          +      args.content.length > USER_CONTENT_MAX
          +        ? args.content.slice(0, USER_CONTENT_MAX) + '...[truncated]'
          +        : args.content;
          +
          +    const owner = callerIdentity(ctx);
          +    const {
          +      cfg,
          +      agentName,
          +      owner: threadOwner,
          +      userMessageId,
          +    } = ctx.withTx(tx => {
          +      const loaded = loadLoopConfigOrThrow(tx, args.threadId, owner);
          +      tx.db.threadLock.insert({
          +        threadId: args.threadId,
          +        owner: loaded.owner,
          +        lockedAt: tx.timestamp,
          +        cancelRequested: false,
          +      });
          +      const inserted = tx.db.message.insert({
          +        id: 0n,
          +        threadId: args.threadId,
          +        owner: loaded.owner,
          +        role: 'user',
          +        content,
          +        toolCallsJson: undefined,
          +        toolCallId: undefined,
          +        isError: false,
          +        promptTokens: undefined,
          +        completionTokens: undefined,
          +        createdAt: tx.timestamp,
          +      });
          +      const threadRow = tx.db.thread.id.find(args.threadId);
          +      if (threadRow)
          +        tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp });
          +      return { ...loaded, userMessageId: inserted.id };
          +    });
          +
          +    maybeEmbedMessage(ctx, args.threadId, userMessageId);
          +    runLockedLoop(ctx, cfg, agentName, threadOwner, args.threadId);
          +    return {};
          +  }
          +);
          +
          +export const regenerate_response = spacetimedb.procedure(
          +  { threadId: t.u64() },
          +  t.unit(),
          +  (ctx, { threadId }) => {
          +    const owner = callerIdentity(ctx);
          +    const {
          +      cfg,
          +      agentName,
          +      owner: threadOwner,
          +    } = ctx.withTx(tx => {
          +      const loaded = loadLoopConfigOrThrow(tx, threadId, owner);
          +
          +      const rows = threadMessagesAscending(tx, threadId);
          +      let lastUserMsgId: bigint | undefined;
          +      for (const r of rows) {
          +        if (r.role === 'user') lastUserMsgId = r.id;
          +      }
          +      if (lastUserMsgId === undefined) {
          +        throwSenderError(`agent.regenerate_no_user_message:${threadId}`);
          +      }
          +
          +      for (const r of rows) {
          +        if (r.id > lastUserMsgId!) tx.db.message.delete(r);
          +      }
          +
          +      tx.db.threadLock.insert({
          +        threadId,
          +        owner: loaded.owner,
          +        lockedAt: tx.timestamp,
          +        cancelRequested: false,
          +      });
          +      const threadRow = tx.db.thread.id.find(threadId);
          +      if (threadRow)
          +        tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp });
          +      return loaded;
          +    });
          +
          +    runLockedLoop(ctx, cfg, agentName, threadOwner, threadId);
          +    return {};
          +  }
          +);
          +
          +export const generate_thread_title = spacetimedb.procedure(
          +  { threadId: t.u64() },
          +  t.unit(),
          +  (ctx, { threadId }) => {
          +    const owner = callerIdentity(ctx);
          +    const job = ctx.withTx(tx => {
          +      const threadRow = tx.db.thread.id.find(threadId);
          +      if (!threadRow) return null;
          +      if (!threadRow.owner.isEqual(owner)) {
          +        throwSenderError(`agent.not_thread_owner:${threadId}`);
          +      }
          +      if (threadRow.title != null && threadRow.title.length > 0) return null;
          +
          +      const def = registry.agentDef(threadRow.agentName);
          +      if (!def) return null;
          +      const sumName = def.summarizerAgentName ?? threadRow.agentName;
          +      const sumDef = registry.agentDef(sumName);
          +      if (!sumDef) return null;
          +
          +      const override = tx.db.agentOverride.agentName.find(sumName);
          +      const providerName = override?.provider ?? sumDef.defaultProvider;
          +      const provider = BUILT_IN_PROVIDERS[providerName];
          +      if (!provider) return null;
          +      const keyRow = tx.db.apiKey.provider.find(providerName);
          +      if (!keyRow) return null;
          +
          +      const msgs = threadMessagesAscending(tx, threadId);
          +      const firstUser = msgs.find(m => m.role === 'user');
          +      if (!firstUser) return null;
          +
          +      return {
          +        provider,
          +        apiKey: keyRow.key,
          +        model: override?.model ?? sumDef.defaultModel,
          +        retries: override?.retries ?? sumDef.defaultRetries,
          +        firstMessage: firstUser.content,
          +      };
          +    });
          +    if (!job) return {};
          +
          +    const result = callChat(ctx.http, job.provider, {
          +      apiKey: job.apiKey,
          +      model: job.model,
          +      system:
          +        'You title chat conversations. The user will paste the opening message of ' +
          +        'a chat. You output a 3-5 word title describing the topic. ' +
          +        'CRITICAL: do not answer or respond to the message. Do not greet. ' +
          +        'Output the title and only the title. No quotes, no punctuation at the end.',
          +      messages: [
          +        {
          +          role: 'user',
          +          content: `Title for a chat that starts with this message:\n\n\n${job.firstMessage}\n`,
          +        },
          +      ],
          +      maxTokens: 30,
          +      retries: job.retries,
          +    });
          +    if (!result.ok || !result.response.text) {
          +      console.warn(
          +        `title gen failed: ${result.ok ? 'no text' : result.error.kind}`
          +      );
          +      return {};
          +    }
          +
          +    const cleaned = result.response.text
          +      .trim()
          +      .replace(/^["']|["']$/g, '')
          +      .replace(/[.!?]+$/g, '')
          +      .slice(0, 80);
          +
          +    ctx.withTx(tx => {
          +      const t2 = tx.db.thread.id.find(threadId);
          +      if (!t2 || (t2.title != null && t2.title.length > 0)) return;
          +      tx.db.thread.id.update({
          +        ...t2,
          +        title: cleaned,
          +        updatedAt: tx.timestamp,
          +      });
          +    });
          +    return {};
          +  }
          +);
          +
          +export const thread_lock_sweep = spacetimedb.reducer(
          +  { onSchedule: threadLockSweeperTick },
          +  { arg: threadLockSweeperTick.rowType },
          +  (ctx, _arg) => {
          +    const secret = ctx.db.agentSecret.singleton.find(true);
          +    const thresholdSecs =
          +      secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS;
          +    const thresholdMicros = BigInt(thresholdSecs) * ONE_SECOND_MICROS;
          +
          +    const cutoffMicros = staleLockCutoffMicros(
          +      ctx.timestamp.microsSinceUnixEpoch,
          +      thresholdMicros
          +    );
          +    deleteStaleThreadLocks(
          +      ctx.db.threadLock.lockedAt.filter(
          +        new Range(undefined, {
          +          tag: 'excluded',
          +          value: new Timestamp(cutoffMicros),
          +        })
          +      ),
          +      cutoffMicros,
          +      lock => ctx.db.threadLock.delete(lock)
          +    );
          +  }
          +);
          diff --git a/spacetime-agents-ts/spacetimedb/src/install.ts b/spacetime-agents-ts/src/mounted/install.ts
          similarity index 100%
          rename from spacetime-agents-ts/spacetimedb/src/install.ts
          rename to spacetime-agents-ts/src/mounted/install.ts
          diff --git a/spacetime-agents-ts/spacetimedb/src/loop.ts b/spacetime-agents-ts/src/mounted/loop.ts
          similarity index 99%
          rename from spacetime-agents-ts/spacetimedb/src/loop.ts
          rename to spacetime-agents-ts/src/mounted/loop.ts
          index 57c776243c0..217429c9f96 100644
          --- a/spacetime-agents-ts/spacetimedb/src/loop.ts
          +++ b/spacetime-agents-ts/src/mounted/loop.ts
          @@ -6,7 +6,7 @@ import {
             type ResponseFormat,
             type ToolCall,
             type ToolDefinition,
          -} from '@spacetimedb/agents/openrouter';
          +} from '../openrouter';
           
           export const USER_CONTENT_MAX = 32_000;
           export const TOOL_RESULT_MAX = 64_000;
          diff --git a/spacetime-agents-ts/spacetimedb/src/model.ts b/spacetime-agents-ts/src/mounted/model.ts
          similarity index 100%
          rename from spacetime-agents-ts/spacetimedb/src/model.ts
          rename to spacetime-agents-ts/src/mounted/model.ts
          diff --git a/spacetime-agents-ts/spacetimedb/src/summarize.ts b/spacetime-agents-ts/src/mounted/summarize.ts
          similarity index 100%
          rename from spacetime-agents-ts/spacetimedb/src/summarize.ts
          rename to spacetime-agents-ts/src/mounted/summarize.ts
          diff --git a/spacetime-agents-ts/spacetimedb/src/submodule.ts b/spacetime-agents-ts/src/submodule.ts
          similarity index 78%
          rename from spacetime-agents-ts/spacetimedb/src/submodule.ts
          rename to spacetime-agents-ts/src/submodule.ts
          index 9e36d7593ed..243d54d1ab9 100644
          --- a/spacetime-agents-ts/spacetimedb/src/submodule.ts
          +++ b/spacetime-agents-ts/src/submodule.ts
          @@ -1,5 +1,5 @@
          -export { default } from './index';
          -export { installAgents } from './install';
          +export { default } from './mounted/index';
          +export { installAgents } from './mounted/install';
           export {
             add_agent_admin_identity,
             clear_agent_override,
          @@ -22,4 +22,4 @@ export {
             start_thread,
             thread_lock_sweep,
             update_thread,
          -} from './index';
          +} from './mounted/index';
          diff --git a/spacetime-api-keys-ts/example/spacetimedb/src/index.ts b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts
          index 0ee634831db..53634d58288 100644
          --- a/spacetime-api-keys-ts/example/spacetimedb/src/index.ts
          +++ b/spacetime-api-keys-ts/example/spacetimedb/src/index.ts
          @@ -40,12 +40,6 @@ import {
             safeJson,
           } from './http';
           
          -// A small colony you build (terraform / build / plant) and share by handing
          -// out scoped API keys. The api-keys submodule grants
          -// scoped, revocable access to your colony. Internally the colony container
          -// tables stay named world / world_event; everything user-facing (routes,
          -// scopes, kinds) is colony-themed.
          -
           const COLONY_WIDTH = 12;
           const COLONY_HEIGHT = 8;
           const EVENT_RETAIN = 120;
          @@ -136,7 +130,6 @@ function ensureWorldTx(tx: Tx, ownerSubject: string) {
               updatedAt: tx.timestamp,
             });
           
          -  // A small starter patch: a meadow, a pond, some rock.
             const seedCells = [
               [2, 1, 'grass'],
               [3, 1, 'grass'],
          @@ -158,7 +151,6 @@ function ensureWorldTx(tx: Tx, ownerSubject: string) {
               });
             }
           
          -  // One starter dome to anchor the colony.
             tx.db.grid.gridEntity.insert({
               id: 0n,
               gridId: grid.id,
          @@ -558,11 +550,6 @@ function handleAuthedWorldAction(
             }
           }
           
          -// Native reducers called by the SpacetimeDB client with the caller's
          -// identity. ensure_world creates the caller's colony; the edit reducers only
          -// touch your own colony (keyed by your subject), so no share key is needed
          -// to build your own world.
          -
           export const ensure_world = spacetimedb.procedure(
             {},
             t.object('EnsureWorldResult', { ownerSubject: t.string(), gridId: t.u64() }),
          @@ -766,8 +753,6 @@ export const myAccessKeys = spacetimedb.view(
             }
           );
           
          -// Share keys
          -
           export const create_access_key = spacetimedb.procedure(
             {
               name: t.string(),
          diff --git a/spacetime-api-keys-ts/example/src/app.ts b/spacetime-api-keys-ts/example/src/app.ts
          index 44be9bcad6d..f6296730a3f 100644
          --- a/spacetime-api-keys-ts/example/src/app.ts
          +++ b/spacetime-api-keys-ts/example/src/app.ts
          @@ -58,7 +58,6 @@ let conn: DbConnection | null = null;
           let config: ServerConfig | null = null;
           let identityHex = '';
           
          -// mode + resolved colony
           let mode: AccessMode = 'owner';
           let holderKey = '';
           let colonyId = '';
          @@ -89,7 +88,6 @@ let isPainting = false;
           let paintRemove = false;
           let lastPaintTile: { x: number; y: number } | null = null;
           
          -// presence
           let myName = '';
           let myColor = '';
           let lastBeatAt = 0;
          @@ -171,7 +169,6 @@ function toolAllowed(tool: Tool): boolean {
             return toolAllowedFor(mode, myScopes, tool);
           }
           
          -// Colors and names
           function loadName(): string {
             const stored = localStorage.getItem(NAME_KEY);
             if (stored) return stored;
          @@ -296,8 +293,6 @@ function subscribeAll(): void {
             c.db.presenceEntry.onDelete(() => renderPresence());
           }
           
          -// Viewport geometry, pan, and zoom
          -
           function terrainFor(x: number, y: number): string {
             return cells().find(c => c.x === x && c.y === y)?.terrain ?? 'regolith';
           }
          @@ -352,7 +347,6 @@ function zoomViewport(
             applyViewportTransform();
           }
           
          -// Screen point to fractional tile coordinates, accounting for pan + zoom.
           function pointerToTile(
             clientX: number,
             clientY: number
          @@ -396,8 +390,6 @@ function flashTile(
             }
           }
           
          -// World rendering. Presence is rendered separately.
          -
           // A road renders exactly the arms in its own stored mask. Connections are
           // written to both roads when they are drawn/dragged together, so there is no
           // mirror guessing: a road only links where you explicitly drew a link.
          @@ -571,7 +563,6 @@ function renderWorld(): void {
           
           function renderPresence(): void {
             const people = presenceRows();
          -  // roster
             const rosterHtml = people.length
               ? people
                   .map(p => {
          @@ -603,8 +594,6 @@ function renderPresence(): void {
             layer.innerHTML = html;
           }
           
          -// Tool application
          -
           async function applyTool(x: number, y: number): Promise {
             const tool = TOOLS.find(t => t.id === selectedTool);
             if (!tool) return;
          @@ -665,8 +654,6 @@ async function applyTool(x: number, y: number): Promise {
             }
           }
           
          -// Remove whatever is on a tile: an object (structure or nature), or if empty,
          -// reset the surface to bare. Used by the Remove tool and by ctrl/cmd-click.
           async function removeAt(x: number, y: number): Promise {
             lastRoad = null;
             const ent = entityAt(x, y);
          @@ -751,8 +738,6 @@ async function colonyRequest(path: string, body?: unknown): Promise {
             return data;
           }
           
          -// Owner share keys
          -
           let selectedRole = 'collaborator';
           
           function renderRoleGrid(): void {
          @@ -843,8 +828,6 @@ async function revokeKey(keyId: string): Promise {
             toast('Access revoked');
           }
           
          -// Holder access removal state
          -
           function showAccessRemoved(reason: string): void {
             const overlay = $('accessOverlay');
             const expired = /expired/i.test(reason);
          @@ -859,8 +842,6 @@ function showAccessRemoved(reason: string): void {
             }
           }
           
          -// Presence heartbeats
          -
           function sendBeat(): void {
             if (!conn || !colonyId) return;
             lastBeatAt = Date.now();
          @@ -909,7 +890,6 @@ function startPresence(): void {
             });
           }
           
          -// Confirm holder access and show the overlay after revocation.
           async function reverify(): Promise {
             if (mode !== 'holder') return;
             try {
          @@ -919,8 +899,6 @@ async function reverify(): Promise {
             }
           }
           
          -// Pointer-driven placement. Dragging paints across tiles.
          -
           function tileFromEvent(
             clientX: number,
             clientY: number
          @@ -936,7 +914,6 @@ function applyAtTile(x: number, y: number, remove: boolean): void {
           
           // Fill in every tile along a drag so a fast drag never leaves gaps (and roads
           // chain tile-by-tile). Walks orthogonally so each step is adjacent to the last.
          -// HUD controls
           
           function toggle(id: string, others: string[]): void {
             const panel = $(id);
          @@ -1037,7 +1014,6 @@ function wireControls(): void {
                   lastPaintTile = tile;
                 }
               }
          -    // cursor presence
               const t = pointerToTile(event.clientX, event.clientY);
               cursor = { cx: t.cx, cy: t.cy, onGrid: t.onGrid };
               queueBeat();
          @@ -1050,7 +1026,6 @@ function wireControls(): void {
               if (isPanning) {
                 isPanning = false;
                 viewport.classList.remove('panning');
          -      // A touch tap that did not pan places a single tile.
                 if (event.pointerType === 'touch' && !panMoved) {
                   const tile = tileFromEvent(event.clientX, event.clientY);
                   if (tile) applyAtTile(tile.x, tile.y, false);
          @@ -1130,7 +1105,6 @@ function wireControls(): void {
           }
           
           function applyModeChrome(): void {
          -  // Show sharing and colony administration controls to the owner.
             $('shareBtn').hidden = mode !== 'owner';
             $('drawerFoot').style.display = mode === 'owner' ? '' : 'none';
             if (mode === 'holder') {
          @@ -1179,7 +1153,6 @@ async function run(): Promise {
             if (mode === 'holder' && !toolAllowed(TOOLS[0]) && !canBuild() && !canPlant())
               selectedTool = 'regolith';
             else if (mode === 'holder') {
          -    // Default to a tool allowed by this key.
               if (canTerraform()) selectedTool = 'soil';
               else if (canBuild()) selectedTool = 'dome';
               else if (canPlant()) selectedTool = 'tree';
          diff --git a/spacetime-auth-ts/example/public/ui.js b/spacetime-auth-ts/example/public/ui.js
          index 86d92c54af2..a918e5689e7 100644
          --- a/spacetime-auth-ts/example/public/ui.js
          +++ b/spacetime-auth-ts/example/public/ui.js
          @@ -45,7 +45,6 @@ function showToast(kind, msg, dur = 4500) {
             }
           })();
           
          -// Connection pill
           window.addEventListener('auth:conn', e => {
             const pill = $('conn-pill');
             const text = $('conn-text');
          @@ -116,7 +115,6 @@ window.addEventListener('auth:state', e => {
             }
           });
           
          -// Avatar dropdown
           const avatarBtn = $('avatar-btn');
           const avatarMenu = $('avatar-menu');
           avatarBtn.addEventListener('click', e => {
          @@ -230,7 +228,6 @@ window.addEventListener('auth:notes', e => {
             }
           });
           
          -// Edit modal
           let editingId = null;
           let editOriginal = { title: '', body: '' };
           const editBackdrop = $('edit-backdrop');
          @@ -289,7 +286,6 @@ document.addEventListener('keydown', e => {
               closeEdit();
           });
           
          -// Compose-card expand/collapse
           const compose = $('compose-card');
           const ntBody = $('nt-body');
           const ntTitle = $('nt-title');
          @@ -305,7 +301,6 @@ function collapseCompose() {
           ntBody.addEventListener('focus', expandCompose);
           ntTitle.addEventListener('focus', expandCompose);
           ntBody.addEventListener('input', () => {
          -  // auto-grow / shrink textarea
             ntBody.style.height = 'auto';
             ntBody.style.height = ntBody.scrollHeight + 'px';
           });
          @@ -420,7 +415,6 @@ $('em-btn').addEventListener('click', () =>
             })
           );
           
          -// Detect reset-password landing
           (function checkResetToken() {
             if (window.location.pathname === '/auth/password/reset') {
               const params = new URLSearchParams(window.location.search);
          @@ -431,7 +425,6 @@ $('em-btn').addEventListener('click', () =>
               }
             }
           })();
          -// Detect verify-success redirect from STDB module
           (function checkVerifyOk() {
             const params = new URLSearchParams(window.location.search);
             if (params.get('verified') === '1') {
          diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts
          index cd81d0edebb..f6960e34eac 100644
          --- a/spacetime-auth-ts/example/server.ts
          +++ b/spacetime-auth-ts/example/server.ts
          @@ -1,6 +1,3 @@
          -// Serves the frontend and proxies /auth/* to the STDB module's HTTP routes
          -// so cookies are same-origin. Browser connects to STDB over WS directly.
          -
           import path from 'node:path';
           import { fileURLToPath } from 'node:url';
           import { spawnSync } from 'node:child_process';
          @@ -99,13 +96,11 @@ function configureAuthFromEnv(): void {
           const app = express();
           app.use(express.json({ limit: '256kb' }));
           
          -// Reset-password email link. Serve the SPA so the frontend can read ?token=...
          -// and show the reset form. Must be registered BEFORE the /auth proxy below.
          +// Register this before the /auth proxy so reset links reach the SPA.
           app.get('/auth/password/reset', (_req: Request, res: Response) => {
             res.sendFile(path.join(__dirname, 'public', 'index.html'));
           });
           
          -// Proxy /auth/* to STDB module HTTP handlers. Cookie + Set-Cookie pass through.
           // Using app.use as middleware since Express 4's `app.all('/auth/*', ...)` does
           // not match nested paths reliably.
           app.use('/auth', async (req, res) => {
          diff --git a/spacetime-auth-ts/example/spacetimedb/src/index.ts b/spacetime-auth-ts/example/spacetimedb/src/index.ts
          index 2ce7964869e..e30e48b4a5d 100644
          --- a/spacetime-auth-ts/example/spacetimedb/src/index.ts
          +++ b/spacetime-auth-ts/example/spacetimedb/src/index.ts
          @@ -28,7 +28,6 @@ import {
             type MailParams,
           } from '@spacetimedb/auth/submodule';
           
          -// Development mailer that logs messages.
           const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => {
             console.log(
               `[mail] to=${params.to} subject=${params.subject}\n${params.text}`
          diff --git a/spacetime-auth-ts/example/src/app.ts b/spacetime-auth-ts/example/src/app.ts
          index bd4e66c2d02..06a4fc3334a 100644
          --- a/spacetime-auth-ts/example/src/app.ts
          +++ b/spacetime-auth-ts/example/src/app.ts
          @@ -1,5 +1,3 @@
          -// STDB connection + auth flow. Exposes window.auth for the inline UI.
          -
           import {
             DbConnection,
             tables,
          @@ -156,7 +154,7 @@ function saveStdbToken(token: string): void {
             }
           }
           
          -function connectStdb(): Promise {
          +function connect(): Promise {
             if (!serverCfg) throw new Error('missing_server_config');
             const config = serverCfg;
             return new Promise((resolve, reject) => {
          @@ -211,8 +209,9 @@ async function bindSession(token: string, user: AuthMe['user'], exp: number) {
             if (!conn) {
               broadcastConn('connecting');
               try {
          -      conn = await connectStdb();
          -      wireSubscriptions(conn);
          +      conn = await connect();
          +      registerRowCallbacks(conn);
          +      subscribeToTables(conn);
                 broadcastConn('connected');
               } catch (err) {
                 broadcastConn('error', (err as Error).message);
          @@ -242,12 +241,14 @@ function syncUserFromRow(row: AuthUserRow) {
             broadcastAuth();
           }
           
          -function wireSubscriptions(c: DbConnection) {
          +function subscribeToTables(c: DbConnection): void {
             c.subscriptionBuilder()
               .onApplied(() => broadcastNotes())
               .onError((ctx: ErrorContext) => console.error('sub error', ctx.event))
               .subscribe([tables.myNotes, tables.myAuthUser]);
          +}
           
          +function registerRowCallbacks(c: DbConnection): void {
             c.db.myNotes.onInsert(() => broadcastNotes());
             c.db.myNotes.onUpdate(() => broadcastNotes());
             c.db.myNotes.onDelete(() => broadcastNotes());
          diff --git a/spacetime-auth-ts/src/caller.ts b/spacetime-auth-ts/src/caller.ts
          index b4adc6741c5..c5be333db22 100644
          --- a/spacetime-auth-ts/src/caller.ts
          +++ b/spacetime-auth-ts/src/caller.ts
          @@ -1,4 +1,4 @@
          -// Caller identity helpers. Browser must call link_connection after STDB connect.
          +// Browser clients must call link_connection after connecting to SpacetimeDB.
           
           import { SenderError } from 'spacetimedb/server';
           import type {
          diff --git a/spacetime-auth-ts/src/keys.ts b/spacetime-auth-ts/src/keys.ts
          index ab118469522..e3285975f7d 100644
          --- a/spacetime-auth-ts/src/keys.ts
          +++ b/spacetime-auth-ts/src/keys.ts
          @@ -1,5 +1,3 @@
          -// ES256 (P-256) keypair generation. PEM (SPKI + PKCS#8) and JWK encoders.
          -
           import { p256 } from '@noble/curves/nist.js';
           import { sha256 } from '@noble/hashes/sha2';
           
          diff --git a/spacetime-auth-ts/src/procedures.ts b/spacetime-auth-ts/src/procedures.ts
          index bf0c7883149..5950eb57ca4 100644
          --- a/spacetime-auth-ts/src/procedures.ts
          +++ b/spacetime-auth-ts/src/procedures.ts
          @@ -1,4 +1,3 @@
          -// Each impl supports both reducer ctx and procedure ctx.
           import type { Timestamp } from 'spacetimedb';
           import {
             Range,
          diff --git a/spacetime-cron-ts/example/server.ts b/spacetime-cron-ts/example/server.ts
          index c4ff6259fee..6fb8320a7e1 100644
          --- a/spacetime-cron-ts/example/server.ts
          +++ b/spacetime-cron-ts/example/server.ts
          @@ -1,6 +1,3 @@
          -// Serves the browser bundle and connection settings. The browser connects
          -// directly to SpacetimeDB.
          -
           import path from 'node:path';
           import { fileURLToPath } from 'node:url';
           import { existsSync, readFileSync } from 'node:fs';
          diff --git a/spacetime-cron-ts/example/spacetimedb/src/index.ts b/spacetime-cron-ts/example/spacetimedb/src/index.ts
          index aa161e11c7e..574fd6ae53f 100644
          --- a/spacetime-cron-ts/example/spacetimedb/src/index.ts
          +++ b/spacetime-cron-ts/example/spacetimedb/src/index.ts
          @@ -1,5 +1,3 @@
          -// Example consumer of @spacetimedb/cron: two statically declared jobs plus
          -// client-facing management reducers so the UI can reschedule them at runtime.
           import {
             schema,
             table,
          diff --git a/spacetime-cron-ts/example/src/app.ts b/spacetime-cron-ts/example/src/app.ts
          index 9f86702b19c..6ad186e648c 100644
          --- a/spacetime-cron-ts/example/src/app.ts
          +++ b/spacetime-cron-ts/example/src/app.ts
          @@ -385,7 +385,7 @@ function wireForm(): void {
             updateScheduleFields();
           }
           
          -function watchTables(current: DbConnection): void {
          +function registerRowCallbacks(current: DbConnection): void {
             current.db.cronJobs.onInsert(renderJobs);
             current.db.cronJobs.onDelete(renderJobs);
             current.db.cronJobs.onUpdate(renderJobs);
          @@ -413,7 +413,7 @@ async function main(): Promise {
               .onConnect((current: DbConnection) => {
                 connection = current;
                 setConnection('connected', config.appDatabase);
          -      watchTables(current);
          +      registerRowCallbacks(current);
                 current
                   .subscriptionBuilder()
                   .onApplied(renderAll)
          diff --git a/spacetime-cron-ts/spacetimedb/src/index.ts b/spacetime-cron-ts/spacetimedb/src/index.ts
          index ed43828cdfd..397e76ea4e2 100644
          --- a/spacetime-cron-ts/spacetimedb/src/index.ts
          +++ b/spacetime-cron-ts/spacetimedb/src/index.ts
          @@ -1,5 +1,3 @@
          -// Canonical @spacetimedb/cron demo and integration fixture. It covers typed
          -// jobs, reducer rollback, procedure failures, recovery, and runtime management.
           import {
             schema,
             table,
          diff --git a/spacetime-crypto-ts/src/hmac.ts b/spacetime-crypto-ts/src/hmac.ts
          index f4cb22e1f99..f37d752e0ec 100644
          --- a/spacetime-crypto-ts/src/hmac.ts
          +++ b/spacetime-crypto-ts/src/hmac.ts
          @@ -1,5 +1,3 @@
          -// HMAC-SHA256 backed by @noble/hashes.
          -
           import { hmac } from '@noble/hashes/hmac.js';
           import { sha256 } from '@noble/hashes/sha2.js';
           
          diff --git a/spacetime-crypto-ts/src/index.ts b/spacetime-crypto-ts/src/index.ts
          index f6a4c4b2409..bbd42c96545 100644
          --- a/spacetime-crypto-ts/src/index.ts
          +++ b/spacetime-crypto-ts/src/index.ts
          @@ -1,5 +1,3 @@
          -// Hashing, encoding, constant-time comparison, and webhook verification.
          -
           export { sha256, SHA256_BYTES } from './sha256.ts';
           export { hmacSha256 } from './hmac.ts';
           export {
          diff --git a/spacetime-crypto-ts/src/sha256.ts b/spacetime-crypto-ts/src/sha256.ts
          index 4130ffe24f7..6c248908d44 100644
          --- a/spacetime-crypto-ts/src/sha256.ts
          +++ b/spacetime-crypto-ts/src/sha256.ts
          @@ -1,5 +1,3 @@
          -// SHA-256 backed by @noble/hashes and exposed through the package API.
          -
           import { sha256 as nobleSha256 } from '@noble/hashes/sha2.js';
           
           export function sha256(data: Uint8Array): Uint8Array {
          diff --git a/spacetime-crypto-ts/src/timing.ts b/spacetime-crypto-ts/src/timing.ts
          index 83f2d020da2..ab1d604afe1 100644
          --- a/spacetime-crypto-ts/src/timing.ts
          +++ b/spacetime-crypto-ts/src/timing.ts
          @@ -1,5 +1,3 @@
          -// Constant-time byte comparison + small hex/base64 helpers.
          -
           /**
            * Compare two byte arrays in time independent of their content. Returns true
            * iff both have the same length AND identical bytes.
          diff --git a/spacetime-crypto-ts/src/vendors.ts b/spacetime-crypto-ts/src/vendors.ts
          index 01fefe12dd4..f1299f88fbe 100644
          --- a/spacetime-crypto-ts/src/vendors.ts
          +++ b/spacetime-crypto-ts/src/vendors.ts
          @@ -1,6 +1,3 @@
          -// Vendor-specific webhook signature verifiers. Each wraps hmacSha256 +
          -// timingSafeEqual with the per-vendor framing.
          -//
           // Reference docs:
           //   Stripe:  https://docs.stripe.com/webhooks/signatures
           //   Resend (svix): https://docs.svix.com/receiving/verifying-payloads/how-manual
          diff --git a/spacetime-files-ts/example/src/app.ts b/spacetime-files-ts/example/src/app.ts
          index 2d5b12a2635..bafb5743a73 100644
          --- a/spacetime-files-ts/example/src/app.ts
          +++ b/spacetime-files-ts/example/src/app.ts
          @@ -1,4 +1,3 @@
          -// SpacetimeDB connection and file-manager UI composition.
           import { DbConnection, tables, type ErrorContext } from './module_bindings/app';
           import type { FileSummary, Folder } from './module_bindings/app/types';
           import {
          @@ -77,7 +76,6 @@ function connect(config: ServerConfig): Promise {
             });
           }
           
          -// The bridge the UI talks to; also exposed as window.vault for console tinkering.
           const vault = {
             createFolder: (path: string) => conn!.reducers.createFolder({ path }),
             deleteFolder: (path: string) => conn!.reducers.deleteFolder({ path }),
          @@ -121,12 +119,9 @@ function requireVault(): typeof vault | null {
             return vault;
           }
           
          -// UI state
          -
           const $ = (id: string): T =>
             document.getElementById(id) as T;
           
          -// Persisted view preferences
           const PREFS_KEY = 'vault:prefs';
           interface Prefs {
             viewMode?: 'list' | 'grid';
          @@ -164,7 +159,6 @@ let dragDepth = 0;
           let sortKey: SortKey = prefs.sortKey ?? 'name';
           let sortDir: 1 | -1 = prefs.sortDir ?? 1;
           let viewMode: 'list' | 'grid' = prefs.viewMode ?? 'list';
          -// Normalize stored tile-size aliases to pixels.
           let tileSize: number =
             typeof prefs.tileSize === 'number'
               ? prefs.tileSize
          @@ -194,7 +188,6 @@ const {
             focusPath: selection.focusPath,
           }));
           
          -// Returns the candidate path when available, otherwise adds a numeric suffix.
           function freeName(candidate: string): string {
             const taken = (p: string) =>
               files.some(f => f.path === p) || folders.some(f => f.path === p);
          @@ -284,8 +277,6 @@ function getFileBlob(row: FileSummary): Promise {
             return loadFileBlob(row, downloadServices);
           }
           
          -// Thumbnails (grid view + details panel)
          -
           const thumbCache = new Map();
           let thumbGeneration = 0;
           // Object-URL cache keyed by path@mtime; older revisions revoked on refresh.
          @@ -324,8 +315,6 @@ async function loadThumbs(): Promise {
             }
           }
           
          -// Rendering
          -
           function renderCrumbs(): void {
             if (searchQuery) {
               $('crumbs').innerHTML =
          @@ -403,7 +392,6 @@ function renderHead(): void {
             const { fs } = visibleEntries();
             const all = fs.length > 0 && fs.every(f => selected.has(f.path));
             $('select-all').checked = all;
          -  // View controls
             $('view-toggle').innerHTML = icon(viewMode === 'grid' ? 'list' : 'grid');
             $('view-toggle').title = viewMode === 'grid' ? 'List view' : 'Grid view';
             $('zoom-ctl').hidden = viewMode !== 'grid';
          @@ -446,7 +434,6 @@ function renderStorage(): void {
               : 'No files stored yet';
           }
           
          -// Details panel
           function wireDetailsNav(scope: HTMLElement): void {
             scope.querySelectorAll('[data-goto]').forEach(btn =>
               btn.addEventListener('click', () => {
          @@ -527,8 +514,6 @@ function render(): void {
             renderDetails();
           }
           
          -// Selection & focus
          -
           function setFocus(path: string): void {
             // No re-render if already focused: dblclick's second click must hit the same node.
             if (selection.focus(path)) render();
          @@ -598,8 +583,6 @@ function confirmDeleteFolder(path: string): void {
             );
           }
           
          -// Internal drag-to-move + OS-file drop onto folders
          -
           function wireFolderDropTarget(el: HTMLElement, folderPath: string): void {
             wireDropTarget(el, folderPath, {
               currentPath: () => currentPath,
          @@ -681,8 +664,6 @@ function openMove(paths: string[]): void {
             );
           }
           
          -// Copy link (visibility-aware)
          -
           async function writeClipboard(text: string): Promise {
             await navigator.clipboard.writeText(text);
           }
          @@ -713,8 +694,6 @@ function copyLink(path: string): void {
             );
           }
           
          -// Actions
          -
           async function runAction(
             okMessage: string,
             fn: () => Promise
          @@ -752,8 +731,6 @@ async function duplicateFile(path: string): Promise {
             }
           }
           
          -// Upload (files, folders, conflicts)
          -
           async function downloadFile(row: FileSummary): Promise {
             return saveDownloadedFile(row, downloadServices);
           }
          @@ -818,16 +795,12 @@ function closeViewer(): void {
             viewer.close();
           }
           
          -// Search
          -
           function clearSearch(): void {
             if (!searchQuery) return;
             searchQuery = '';
             $('search').value = '';
           }
           
          -// Bulk actions
          -
           // Continue-on-error loop: one summary toast, one toast per failure.
           async function bulkOp(
             paths: string[],
          @@ -900,8 +873,6 @@ function handleListKeys(e: KeyboardEvent): void {
             });
           }
           
          -// One-time wiring
          -
           function endFileDrag(): void {
             dragDepth = 0;
             document.body.classList.remove('dragging-files');
          @@ -944,7 +915,6 @@ function wireUi(): void {
               })();
             });
           
          -  // Search
             $('search').addEventListener('input', () => {
               searchQuery = $('search').value.trim();
               render();
          @@ -956,7 +926,6 @@ function wireUi(): void {
               }
             });
           
          -  // Sorting + view controls
             $('list-head')
               .querySelectorAll('[data-sort]')
               .forEach(btn => {
          @@ -986,7 +955,6 @@ function wireUi(): void {
               savePrefs();
               render();
             });
          -  // Live-resize tiles while dragging by updating the CSS variable.
             $('tile-slider').addEventListener('input', () => {
               tileSize = Number($('tile-slider').value);
               $('list').style.setProperty('--tile', `${tileSize}px`);
          @@ -1000,7 +968,6 @@ function wireUi(): void {
               render();
             });
           
          -  // Bulk actions
             $('bulk-clear').addEventListener('click', () => {
               selected.clear();
               render();
          @@ -1032,13 +999,11 @@ function wireUi(): void {
               }
             });
           
          -  // Context menu dismissal
             document.addEventListener('click', e => {
               if (!(e.target as HTMLElement).closest('#ctx')) closeCtxMenu();
             });
             document.addEventListener('scroll', closeCtxMenu, true);
           
          -  // Viewer controls
             $('lb-prev').addEventListener('click', () => vStep(-1));
             $('lb-next').addEventListener('click', () => vStep(1));
             $('lb-in').addEventListener('click', () => viewer.zoom(1.25));
          @@ -1063,7 +1028,6 @@ function wireUi(): void {
               { passive: false }
             );
           
          -  // Dialog controls + keyboard
             $('dialog-ok').addEventListener('click', () => void commitDialog());
             $('dialog-cancel').addEventListener('click', closeDialog);
             $('dialog').addEventListener('click', e => {
          @@ -1123,8 +1087,6 @@ function wireUi(): void {
             });
           }
           
          -// Data flow: subscriptions -> state -> render
          -
           function refreshData(): void {
             if (!conn) return;
             folders = [...conn.db.myFolders.iter()];
          diff --git a/spacetime-files-ts/example/src/utils.ts b/spacetime-files-ts/example/src/utils.ts
          index f1087d3bcdd..9fa499e3298 100644
          --- a/spacetime-files-ts/example/src/utils.ts
          +++ b/spacetime-files-ts/example/src/utils.ts
          @@ -7,8 +7,6 @@ export interface ServerConfig {
             appDatabase: string;
           }
           
          -// Connection + token persistence
          -
           // Persisted token = same identity (and files) across reloads.
           export const TOKEN_KEY = 'vault:auth-token';
           
          @@ -36,8 +34,6 @@ export function clearToken(): void {
             }
           }
           
          -// Path + formatting helpers
          -
           export function normalizePath(
             path: string,
             kind: 'file' | 'folder' = 'folder'
          @@ -115,8 +111,6 @@ export function kindClass(mime: string | undefined): {
             return { cls: 'generic', ico: 'file' };
           }
           
          -// Error mapping: turn server codes into human sentences
          -
           // Errors are `:`. Parse only the code because detail can contain user paths.
           export const ERROR_MESSAGES: Record = {
             'vault.folder_not_empty':
          diff --git a/spacetime-files-ts/example/src/zip.ts b/spacetime-files-ts/example/src/zip.ts
          index 8c7e8a4dc2d..e4827ec7de2 100644
          --- a/spacetime-files-ts/example/src/zip.ts
          +++ b/spacetime-files-ts/example/src/zip.ts
          @@ -122,7 +122,6 @@ export function buildZip(entries: ZipEntry[]): Blob {
             // BlobPart requires an ArrayBuffer-backed byte view under TS 5.7.
             return new Blob(chunks as unknown as BlobPart[], { type: 'application/zip' });
           }
          -// Timestamp in the archive name so repeat downloads don't collide.
           export function zipStamp(): string {
             const d = new Date();
             const p = (n: number) => String(n).padStart(2, '0');
          diff --git a/spacetime-grid-ts/example/public/ui.js b/spacetime-grid-ts/example/public/ui.js
          index 18798b33465..67a62c79bd4 100644
          --- a/spacetime-grid-ts/example/public/ui.js
          +++ b/spacetime-grid-ts/example/public/ui.js
          @@ -27,7 +27,6 @@ function toast(kind, msg) {
             }, 3000);
           }
           
          -// Authentication view
           let authMode = 'login';
           function setAuthMode(m) {
             authMode = m;
          @@ -128,7 +127,6 @@ $('oauth-github').addEventListener('click', () => {
           });
           $('btn-logout').addEventListener('click', () => window.auth?.logout());
           
          -// State and view routing
           let state = null;
           let selectedUnitId = null;
           let reachableCache = null; // { entityId, cells: Set<"q,r"> } for Dijkstra reachability
          @@ -151,17 +149,14 @@ function scheduleAnimFrame() {
             requestAnimationFrame(() => {
               rafScheduled = false;
               const now = performance.now();
          -    // Prune finished move animations.
               for (const [id, a] of animatingUnits) {
                 if (now >= a.startMs + a.durationMs) animatingUnits.delete(id);
               }
          -    // Prune expired attack flashes.
               for (let i = attackFlashes.length - 1; i >= 0; i--) {
                 if (now >= attackFlashes[i].startMs + attackFlashes[i].durationMs) {
                   attackFlashes.splice(i, 1);
                 }
               }
          -    // Prune expired damage numbers.
               for (let i = damageNumbers.length - 1; i >= 0; i--) {
                 if (now >= damageNumbers[i].startMs + damageNumbers[i].durationMs) {
                   damageNumbers.splice(i, 1);
          @@ -220,7 +215,6 @@ const damageNumbers = []; // [{ x, y, dmg, killed, startMs, durationMs }]
           const FLASH_MS = 220;
           const DAMAGE_FLOAT_MS = 950;
           
          -// Spawn a floating "-N" damage indicator at (px, py). Drifts up + fades.
           function spawnDamageNumber(px, py, dmg, killed, atMs) {
             damageNumbers.push({
               x: px,
          @@ -255,8 +249,6 @@ function flashAttack(attackerId, target, dmg, killed) {
               startMs,
               durationMs: FLASH_MS,
             });
          -  // Damage number pops at the midpoint of the flash so it reads as
          -  // "the hit landed, here's how much it cost you."
             const { cx, cy } = hexCenter(target.x, target.y);
             spawnDamageNumber(cx, cy, dmg, killed, startMs + FLASH_MS / 2);
             scheduleAnimFrame();
          @@ -277,7 +269,6 @@ window.addEventListener('grid:ai-events', e => {
             for (const ev of events) {
               let moveEndMs = cursorMs;
           
          -    // 1. Move animation (if any).
               if (Array.isArray(ev.movePath) && ev.movePath.length >= 2) {
                 const pathPx = ev.movePath.map(c => {
                   const { cx, cy } = hexCenter(c.x, c.y);
          @@ -294,14 +285,11 @@ window.addEventListener('grid:ai-events', e => {
                 cursorMs = moveEndMs + PAUSE_MS;
               }
           
          -    // 2. Attack (if any). Preserve the target's pre-attack visual until
          -    //    the flash fires; if killed, ghost-render the target so it stays
          -    //    on screen even though state has already deleted it.
          +    // Preserve the target's pre-attack visual until the flash fires. A defeated
          +    // target may already be absent from state, so render it as a temporary ghost.
               if (ev.attack) {
                 const a = ev.attack;
          -      // Lock the target's visible HP to its pre-attack value until the flash.
                 visualHp.set(a.targetId, a.targetPreHp);
          -      // Render a ghost for a defeated target during its removal animation.
                 if (a.killed) {
                   ghostUnits.set(a.targetId, {
                     entityId: a.targetId,
          @@ -313,7 +301,6 @@ window.addEventListener('grid:ai-events', e => {
                   });
                 }
                 const attackStartMs = cursorMs;
          -      // Schedule the flash + release of the visual overrides.
                 attackFlashes.push({
                   attackerId: ev.entityId,
                   targetX: a.targetX,
          @@ -321,7 +308,6 @@ window.addEventListener('grid:ai-events', e => {
                   startMs: attackStartMs,
                   durationMs: FLASH_MS,
                 });
          -      // Floating "-N" damage number, scheduled to pop mid-flash.
                 const { cx, cy } = hexCenter(a.targetX, a.targetY);
                 spawnDamageNumber(
                   cx,
          @@ -330,7 +316,6 @@ window.addEventListener('grid:ai-events', e => {
                   a.killed,
                   attackStartMs + FLASH_MS / 2
                 );
          -      // When the flash fires, release HP override + ghost.
                 setTimeout(
                   () => {
                     visualHp.delete(a.targetId);
          @@ -353,10 +338,8 @@ window.addEventListener('grid:ai-events', e => {
             }
           });
           
          -// Default to auth view until grid:state arrives.
           showAuth();
           
          -// Rendering
           function renderAll() {
             if (!state) return;
             renderLobby();
          @@ -368,13 +351,10 @@ function renderAll() {
             }
           }
           
          -// Tag → lowercase string for CSS class names and display text.
          -// (Enum tags from the bindings are PascalCase: 'Waiting', 'Active', etc.)
           function statusKey(s) {
             return s?.tag ? s.tag.toLowerCase() : 'unknown';
           }
           
          -// Resolve seats from match_participant rows.
           function seatsForMatch(matchId) {
             const seats = {};
             for (const p of state.participants ?? []) {
          @@ -428,7 +408,6 @@ function renderLobby() {
             }
             list.replaceChildren();
           
          -  // Open matches anyone can join.
             for (const o of openOther) {
               const row = document.createElement('div');
               row.className = 'match-row';
          @@ -574,7 +553,6 @@ function renderMatch() {
           
             $('btn-end-turn').disabled = !myTurn;
           
          -  // Sidebar unit lists
             const my0 = [];
             const en = [];
             for (const u of state.units) {
          @@ -603,7 +581,6 @@ function renderMatch() {
             if (my0.length === 0) myList.appendChild(hint('no units'));
             if (en.length === 0) enList.appendChild(hint('no units'));
           
          -  // Selected-unit info
             const selUnit =
               selectedUnitId !== null
                 ? state.units.find(u => u.entityId === selectedUnitId)
          @@ -638,10 +615,8 @@ function renderMatch() {
               sel.replaceChildren(hint('click one of your units to select'));
             }
           
          -  // Canvas
             drawBoard(grid, state.entities, state.cells, state.units, m, my);
           
          -  // End-modal
             if (m.status.tag === 'Ended') {
               $('end-title').textContent = m.winnerUserId === my ? 'Victory!' : 'Defeat';
               $('end-title').className = m.winnerUserId === my ? 'win' : 'lose';
          @@ -697,7 +672,6 @@ $('btn-end-turn').addEventListener('click', async () => {
             }
           });
           
          -// Canvas drawing
           function drawBoard(grid, entities, cells, units, match, myUserId) {
             const canvas = $('board-canvas');
             // Canvas only needs to hold the hex-shape bounding box (computed at
          @@ -707,12 +681,10 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
             canvas.width = W;
             canvas.height = H;
             const ctx = canvas.getContext('2d');
          -  // Match the surrounding panel with a solid STDB shade7 background.
             ctx.fillStyle =
               getComputedStyle(document.body).getPropertyValue('--color-shade7').trim() ||
               '#0b1114';
             ctx.fillRect(0, 0, W, H);
          -  // A deterministic pale-blue starfield supports the alien-planet view.
             const starSeed = (grid.width * 31 + grid.height) | 0;
             let s = starSeed;
             for (let i = 0; i < 60; i++) {
          @@ -729,10 +701,6 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
             const cellMap = new Map(cells.map(c => [cellKey(c.x, c.y), c]));
             const reachable = reachableCache?.cells ?? null;
           
          -  // 1. Draw all hex cells (STDB-palette only)
          -  //   regolith = default dark teal plain      (shade5/shade4)
          -  //   crater   = burned, impassable obstacle  (shade7 + dim red edge)
          -  //   void     = outside the hex play area and omitted from rendering
             for (let y = 0; y < grid.height; y++) {
               for (let x = 0; x < grid.width; x++) {
                 if (!isInHexShape(x, y)) continue;
          @@ -755,13 +723,11 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
                 ctx.fillStyle = fill;
                 ctx.fill();
                 if (isAttackable) {
          -        // STDB red at 28% marks hostile movement range.
                   ctx.fillStyle = 'rgba(255, 76, 76, 0.28)';
                   ctx.fill();
                   ctx.strokeStyle = '#ff4c4c';
                   ctx.lineWidth = 1.5;
                 } else if (isReachable) {
          -        // STDB blue at 20% marks friendly movement range.
                   ctx.fillStyle = 'rgba(2, 190, 250, 0.20)';
                   ctx.fill();
                   ctx.strokeStyle = '#02befa';
          @@ -774,12 +740,9 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
               }
             }
           
          -  // 2. Draw units (data state + ghost-rendered killed units pending flash).
          -  //    The helper draws live units and ghosts with identical logic.
             const drawUnit = (u, posX, posY, hpForBar) => {
               const type = state.unitTypes.find(t => t.typeId === u.typeId);
               const isMine = u.ownerUserId === myUserId;
          -    // Use STDB blue for the player's landing party and green for the xeno hive.
               const color = isMine ? '#02befa' : '#4cf490';
               ctx.beginPath();
               ctx.arc(posX, posY, HEX_SIZE * 0.55, 0, Math.PI * 2);
          @@ -788,13 +751,11 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
               ctx.strokeStyle = u.entityId === selectedUnitId ? '#fbdc8e' : '#000a';
               ctx.lineWidth = u.entityId === selectedUnitId ? 3 : 2;
               ctx.stroke();
          -    // Glyph
               ctx.fillStyle = '#060606';
               ctx.font = 'bold 14px "Source Code Pro", monospace';
               ctx.textAlign = 'center';
               ctx.textBaseline = 'middle';
               ctx.fillText(type?.glyph ?? '?', posX, posY);
          -    // HP bar
               const hpPct = type ? hpForBar / type.hp : 0;
               const barW = HEX_SIZE * 0.9;
               ctx.fillStyle = '#000a';
          @@ -802,7 +763,6 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
               ctx.fillStyle =
                 hpPct > 0.5 ? '#4cf490' : hpPct > 0.25 ? '#fbdc8e' : '#ff4c4c';
               ctx.fillRect(posX - barW / 2, posY + HEX_SIZE * 0.6, barW * hpPct, 4);
          -    // Greyed out if hasMoved + hasAttacked (turn done)
               if (isMine && u.hasMoved && u.hasAttacked) {
                 ctx.fillStyle = 'rgba(0,0,0,0.4)';
                 ctx.beginPath();
          @@ -841,7 +801,6 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
               drawUnit(u, cx, cy, hp);
             }
           
          -  // 3. Ghost-render defeated AI targets until their attack flash runs.
             for (const g of ghostUnits.values()) {
               const { cx, cy } = hexCenter(g.x, g.y);
               drawUnit(
          @@ -858,12 +817,10 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
               );
             }
           
          -  // 4. Draw a bright red attack beam from attacker to target.
             for (const f of attackFlashes) {
               const now = performance.now();
               const t = Math.min(1, Math.max(0, (now - f.startMs) / f.durationMs));
               if (t <= 0 || t >= 1) continue;
          -    // Attacker pixel position: animated if mid-move, else data.
               let fx, fy;
               const aAnim = animatingUnits.get(f.attackerId);
               if (aAnim) {
          @@ -879,7 +836,6 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
                 fy = p.cy;
               }
               const tEnd = hexCenter(f.targetX, f.targetY);
          -    // Pulse: stroke fades out across the flash duration.
               const alpha = 0.9 * (1 - t);
               ctx.strokeStyle = `rgba(255, 76, 76, ${alpha})`;
               ctx.lineWidth = 3;
          @@ -887,7 +843,6 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
               ctx.moveTo(fx, fy);
               ctx.lineTo(tEnd.cx, tEnd.cy);
               ctx.stroke();
          -    // Impact ring at target.
               ctx.strokeStyle = `rgba(255, 156, 61, ${alpha})`;
               ctx.lineWidth = 2;
               ctx.beginPath();
          @@ -895,19 +850,15 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
               ctx.stroke();
             }
           
          -  // 5. Draw floating damage numbers with upward drift and fade above the
          -  //    flash and impact ring.
             const nowMs = performance.now();
             for (const d of damageNumbers) {
               const t = (nowMs - d.startMs) / d.durationMs;
               if (t < 0 || t > 1) continue;
          -    // Fade in 0..0.15, hold to 0.7, fade out to 1.0.
               let alpha;
               if (t < 0.15) alpha = t / 0.15;
               else if (t > 0.7) alpha = (1 - t) / 0.3;
               else alpha = 1;
               alpha = Math.max(0, Math.min(1, alpha));
          -    // Ease-out drift upward.
               const drift = HEX_SIZE * 1.1 * (1 - Math.pow(1 - t, 2));
               const px = d.x;
               const py = d.y - drift;
          @@ -928,7 +879,6 @@ function drawBoard(grid, entities, cells, units, match, myUserId) {
             }
           }
           
          -// Click handlers
           $('board-canvas').addEventListener('click', async e => {
             if (!state?.activeMatch || !state.activeGrid) return;
             const m = state.activeMatch;
          @@ -951,13 +901,9 @@ $('board-canvas').addEventListener('click', async e => {
               ? state.units.find(u => u.entityId === entityAtHex.id)
               : null;
           
          -  // Click on my own unit -> select + compute movement + INFLUENCE overlays.
          -  // Influence is the union of cells within attackRange of every
          -  // movement-reachable cell, including the origin.
             if (unitAtHex && unitAtHex.ownerUserId === state.myUserId) {
               selectedUnitId = unitAtHex.entityId;
               const type = state.unitTypes.find(t => t.typeId === unitAtHex.typeId);
          -    // Reachable cells from server (Dijkstra, honors terrain + entity blocking).
               let reachCells = [{ x: hex.x, y: hex.y, cost: 0 }];
               if (type && !unitAtHex.hasMoved) {
                 try {
          @@ -1001,7 +947,6 @@ $('board-canvas').addEventListener('click', async e => {
               return;
             }
           
          -  // Click on enemy unit while one of mine is selected -> auto-move + attack
             if (unitAtHex && selectedUnitId !== null) {
               const attacker = state.units.find(u => u.entityId === selectedUnitId);
               const attackerEnt = attacker
          @@ -1021,7 +966,6 @@ $('board-canvas').addEventListener('click', async e => {
               const targetX = hex.x,
                 targetY = hex.y;
           
          -    // Attack from the current position when the target is in range.
               const fromHere = axialHexDistance(
                 attackerEnt.x,
                 attackerEnt.y,
          @@ -1093,7 +1037,6 @@ $('board-canvas').addEventListener('click', async e => {
                 return;
               }
           
          -    // Drop highlights immediately. Then move (animated), then attack.
               selectedUnitId = null;
               reachableCache = null;
               attackableCache = null;
          @@ -1150,21 +1093,18 @@ $('board-canvas').addEventListener('click', async e => {
               return;
             }
           
          -  // Click empty cell while a unit is selected + cell is reachable -> move
             if (
               selectedUnitId !== null &&
               reachableCache?.entityId === selectedUnitId &&
               reachableCache.cells.has(cellKey(hex.x, hex.y))
             ) {
               const movingId = selectedUnitId;
          -    // Drop highlights immediately so the player sees the action commit.
               selectedUnitId = null;
               reachableCache = null;
               attackableCache = null;
               renderMatch();
               try {
                 const { path } = await window.grid.moveUnit(movingId, hex.x, hex.y);
          -      // Convert the axial path into pixel centers and lerp through them.
                 const pathPx = path.map(c => {
                   const { cx, cy } = hexCenter(c.x, c.y);
                   return { x: cx, y: cy };
          diff --git a/spacetime-grid-ts/example/server.ts b/spacetime-grid-ts/example/server.ts
          index c7383b793c5..1060b4001cf 100644
          --- a/spacetime-grid-ts/example/server.ts
          +++ b/spacetime-grid-ts/example/server.ts
          @@ -1,7 +1,3 @@
          -// Express + static. Browser connects to STDB directly via WebSocket; this
          -// server serves the SPA, /api/config, and proxies /auth/* to the STDB
          -// module's HTTP handlers so auth cookies stay same-origin.
          -
           import path from 'node:path';
           import { fileURLToPath } from 'node:url';
           import { spawnSync } from 'node:child_process';
          @@ -100,12 +96,11 @@ function configureAuthFromEnv(): void {
           const app = express();
           app.use(express.json({ limit: '256kb' }));
           
          -// Reset-password email link serves the SPA so the frontend can read ?token=...
          +// Register this before the /auth proxy so reset links reach the SPA.
           app.get('/auth/password/reset', (_req: Request, res: Response) => {
             res.sendFile(path.join(__dirname, 'public', 'index.html'));
           });
           
          -// Proxy /auth/* to STDB module HTTP handlers. Cookies pass through both ways.
           app.use('/auth', async (req, res) => {
             const fullPath = `/auth${req.url}`;
             const qIdx = fullPath.indexOf('?');
          diff --git a/spacetime-grid-ts/example/spacetimedb/src/index.ts b/spacetime-grid-ts/example/spacetimedb/src/index.ts
          index 0b80dc40364..4ac0b4ffee6 100644
          --- a/spacetime-grid-ts/example/spacetimedb/src/index.ts
          +++ b/spacetime-grid-ts/example/spacetimedb/src/index.ts
          @@ -1,7 +1,3 @@
          -// spacetime-grid-example module. Wires auth-ts + grid-ts plus
          -// game-specific tables (match, unit_type, player_unit) for an Advance-Wars-lite
          -// turn-based strategy demo on a hex grid.
          -
           import { t, SenderError, type ProcedureCtx } from 'spacetimedb/server';
           import { Timestamp, type Identity } from 'spacetimedb';
           import * as auth from '@spacetimedb/auth/submodule';
          @@ -16,8 +12,6 @@ import {
           } from '@spacetimedb/grid';
           import { distance } from '@spacetimedb/grid/math';
           
          -// Dev mailer
          -
           import {
             MatchStatus,
             AI_BOT_USER_ID,
          @@ -29,8 +23,6 @@ import {
           export { default } from './schema';
           export * from './auth-adapter';
           
          -// Helpers
          -
           function throwSenderError(msg: string): never {
             throw new SenderError(msg);
           }
          @@ -41,16 +33,12 @@ function requireUserId(ctx: ProcedureCtx): string {
             return userId;
           }
           
          -// Views (caller-scoped)
          -
           export * from './views';
           
           export const init = spacetimedb.init(ctx => {
             auth.installAuth(ctx.as.auth);
             gridSubmodule.installGrid(ctx.as.grid);
           
          -  // Seed canonical unit types. Theme: stranded survey crew on a hostile alien
          -  // planet. Unit stats support distinct movement and combat roles.
             const types = [
               {
                 typeId: 'marine',
          @@ -95,8 +83,6 @@ export const init = spacetimedb.init(ctx => {
             }
           });
           
          -// Whoami (debug helper)
          -
           export const whoami = spacetimedb.procedure(
             {},
             t.object('WhoAmI', {
          @@ -112,8 +98,6 @@ export const whoami = spacetimedb.procedure(
             }
           );
           
          -// Game: match flow
          -
           // The playable area is a HEXAGON of radius R centered at axial (R, R).
           // The grid submodule allocates a (2R+1) x (2R+1) rectangle because its bounds
           // checker uses rectangular coordinates. Cells outside the playable hex are
          @@ -122,7 +106,6 @@ const GRID_RADIUS = 5;
           const GRID_DIAMETER = 2 * GRID_RADIUS + 1; // 11
           const DEFAULT_COST = 1;
           
          -// Hex distance from (R, R). <= R means the cell is inside the play hex.
           function isInHexShape(q: number, r: number): boolean {
             const cx = GRID_RADIUS,
               cy = GRID_RADIUS;
          @@ -132,7 +115,6 @@ function isInHexShape(q: number, r: number): boolean {
             );
           }
           
          -// Top corner of the hex; bottom corner is mirror across (R, R).
           const PLAYER_SPAWNS = [
             { x: GRID_RADIUS, y: 0, typeId: 'marine' },
             { x: GRID_RADIUS - 1, y: 1, typeId: 'titan' },
          @@ -209,7 +191,6 @@ export const create_match = spacetimedb.procedure(
                         terrain: 'crater',
                       });
                     }
          -          // else: regolith plains (default cost, no row needed)
                   }
                 }
           
          @@ -226,7 +207,6 @@ export const create_match = spacetimedb.procedure(
                   updatedAt: ctx.timestamp,
                 });
           
          -      // 4. Seat the caller at seat 0 (team 0) and drop their landing party.
                 insertParticipant(tx, matchInserted.matchId, userId, 0, 0, ctx.timestamp);
                 placeStartingUnits(
                   tx,
          @@ -237,7 +217,6 @@ export const create_match = spacetimedb.procedure(
                   PLAYER_SPAWNS
                 );
           
          -      // 5. If vs-AI, seat the bot at seat 1 (team 1) and drop the xeno garrison.
                 if (args.vsAi) {
                   insertParticipant(
                     tx,
          @@ -333,8 +312,6 @@ export const end_turn = spacetimedb.procedure(
             }
           );
           
          -// Participant helpers
          -
           function insertParticipant(
             tx: WriteCtx,
             matchId: bigint,
          @@ -379,7 +356,6 @@ function participantTeams(tx: WriteCtx, matchId: bigint): Map {
             return teams;
           }
           
          -// Helper used by create_match + join_match to drop starting units.
           function placeStartingUnits(
             tx: WriteCtx,
             timestamp: Timestamp,
          @@ -416,8 +392,6 @@ function placeStartingUnits(
             }
           }
           
          -// Game: per-unit movement and attack procedures.
          -
           export const move_unit = spacetimedb.procedure(
             { entityId: t.u64(), toX: t.i32(), toY: t.i32() },
             t.object('MoveUnitResult', {
          @@ -460,7 +434,6 @@ export const move_unit = spacetimedb.procedure(
                 typeMovement = type.movement;
               });
           
          -    // Run A* pathfinding in a read-only transaction.
               const path = computePathImpl(
                 ctx.as.grid,
                 {
          @@ -550,11 +523,9 @@ export const attack_unit = spacetimedb.procedure(
                 tx.db.playerUnit.entityId.update({ ...attacker, hasAttacked: true });
           
                 if (newHp <= 0) {
          -        // Unit dies: remove both the playerUnit row and the gridEntity.
                   tx.db.playerUnit.delete(target);
                   tx.db.grid.gridEntity.delete(targetEntity);
           
          -        // Win check: are there any units left on any other team?
                   const teamByUser = participantTeams(tx, attacker.matchId);
                   const myTeam = teamByUser.get(userId);
                   const remaining = [
          @@ -658,7 +629,6 @@ export const ai_take_turn = spacetimedb.procedure(
               };
               const events: TurnEvent[] = [];
           
          -    // Snapshot: read match + unit positions/HP + unit-type catalog.
               let gridId = 0n;
               const aiUnits: AiUnit[] = [];
               const enemyUnits: EnemyUnit[] = [];
          @@ -792,14 +762,12 @@ export const ai_take_turn = spacetimedb.procedure(
                 const type = typeIdx.get(aiUnit.typeId);
                 if (!type) continue;
           
          -      // Buffer per-unit events until movement and combat complete.
                 const evt: TurnEvent = {
                   entityId: aiUnit.entityId,
                   movePath: undefined,
                   attack: undefined,
                 };
           
          -      // 1. Shoot from the current position when a target is in range.
                 if (!aiUnit.hasAttacked) {
                   const target = pickTarget(aiUnit, type.attackRange);
                   if (target) {
          @@ -814,7 +782,6 @@ export const ai_take_turn = spacetimedb.procedure(
                   }
                 }
           
          -      // 2. Otherwise close the distance toward the nearest enemy.
                 if (!aiUnit.hasMoved && enemyUnits.length > 0) {
                   const cells = (
                     cellsInRangeImpl(
          @@ -896,7 +863,6 @@ export const ai_take_turn = spacetimedb.procedure(
                             { x: best.x, y: best.y },
                           ];
           
          -          // After the move, attack if newly in range.
                     if (!aiUnit.hasAttacked) {
                       const target = pickTarget(aiUnit, type.attackRange);
                       if (target) {
          @@ -911,11 +877,9 @@ export const ai_take_turn = spacetimedb.procedure(
                   }
                 }
           
          -      // Emit the event if this unit did anything (move or attack).
                 if (evt.movePath || evt.attack) events.push(evt);
               }
           
          -    // End the AI's turn: flip back to the human and reset their per-turn flags.
               ctx.withTx(tx => {
                 const m = tx.db.match.matchId.find(args.matchId);
                 if (!m || m.status.tag !== 'Active') return;
          diff --git a/spacetime-grid-ts/example/spacetimedb/src/schema.ts b/spacetime-grid-ts/example/spacetimedb/src/schema.ts
          index 33c0500be00..8545a0abdc4 100644
          --- a/spacetime-grid-ts/example/spacetimedb/src/schema.ts
          +++ b/spacetime-grid-ts/example/spacetimedb/src/schema.ts
          @@ -1,7 +1,3 @@
          -// spacetime-grid-example module. Wires auth-ts + grid-ts plus
          -// game-specific tables (match, unit_type, player_unit) for an Advance-Wars-lite
          -// turn-based strategy demo on a hex grid.
          -
           import {
             schema,
             t,
          @@ -13,8 +9,6 @@ import * as auth from '@spacetimedb/auth/submodule';
           import * as gridSubmodule from '@spacetimedb/grid/submodule';
           import { type SendMailFn, type MailParams } from '@spacetimedb/auth/submodule';
           
          -// Dev mailer
          -
           export const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => {
             console.log(
               `[mail] to=${params.to} subject=${params.subject}\n${params.text}`
          @@ -31,8 +25,6 @@ export const authUserViewRow = t.object('GridAuthUser', {
             updatedAt: t.timestamp(),
           });
           
          -// Game tables
          -
           // Static unit catalog. Seeded once in init.
           export const unitType = table(
             { name: 'unit_type', public: true },
          @@ -123,8 +115,6 @@ export const playerUnit = table(
             }
           );
           
          -// Schema
          -
           export const spacetimedb = schema({
             auth,
             grid: gridSubmodule,
          diff --git a/spacetime-grid-ts/example/spacetimedb/src/views.ts b/spacetime-grid-ts/example/spacetimedb/src/views.ts
          index 0f459920a58..04e87d5270d 100644
          --- a/spacetime-grid-ts/example/spacetimedb/src/views.ts
          +++ b/spacetime-grid-ts/example/spacetimedb/src/views.ts
          @@ -1,12 +1,6 @@
          -// spacetime-grid-example module. Wires auth-ts + grid-ts plus
          -// game-specific tables (match, unit_type, player_unit) for an Advance-Wars-lite
          -// turn-based strategy demo on a hex grid.
          -
           import { t, type ViewCtx } from 'spacetimedb/server';
           import * as gridSubmodule from '@spacetimedb/grid/submodule';
           
          -// Dev mailer
          -
           import {
             authUserViewRow,
             MatchStatus,
          @@ -18,8 +12,6 @@ import {
           } from './schema';
           export { default } from './schema';
           
          -// Helpers
          -
           export const myAuthUser = spacetimedb.view(
             { name: 'my_auth_user', public: true },
             t.array(authUserViewRow),
          diff --git a/spacetime-grid-ts/example/src/app.ts b/spacetime-grid-ts/example/src/app.ts
          index 1510b8b316f..41902418258 100644
          --- a/spacetime-grid-ts/example/src/app.ts
          +++ b/spacetime-grid-ts/example/src/app.ts
          @@ -1,14 +1,3 @@
          -// STDB connection + game procedures + auth flow.
          -//
          -// Two facades on window:
          -//   - window.auth: signup / login / logout / oauth / session lifecycle.
          -//   - window.grid: game ops (create_match, join_match, move_unit, attack_unit,
          -//                  end_turn, getCellsInRange, setActiveMatch).
          -//
          -// Page load attempts a cookie-based session refresh, connects to SpacetimeDB,
          -// and calls link_connection so visibility filters return the user's data.
          -// Anonymous visitors see the login card.
          -
           import {
             DbConnection,
             tables,
          @@ -73,8 +62,6 @@ declare global {
           
           type ConnState = 'idle' | 'connecting' | 'connected' | 'error';
           
          -// Module state
          -
           let currentConn: DbConnection | null = null;
           let globalSub: SubscriptionHandle | null = null;
           let matchSub: SubscriptionHandle | null = null;
          @@ -97,7 +84,6 @@ let reconnectAttempt = 0;
           let reconnectTimer: ReturnType | null = null;
           const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000];
           
          -// Browser event bus
           function dispatch(name: string, detail: unknown): void {
             window.dispatchEvent(new CustomEvent(name, { detail }));
           }
          @@ -169,7 +155,6 @@ function requireConn(): DbConnection {
             return currentConn;
           }
           
          -// Authentication requests
           async function callJson(path: string, body?: unknown): Promise {
             const r = await fetch(path, {
               method: body !== undefined ? 'POST' : 'GET',
          @@ -201,12 +186,11 @@ async function loadServerConfig(): Promise {
             return cfg;
           }
           
          -// SpacetimeDB connection
          -function buildConnection(uri: string, db: string): Promise {
          +function connect(uri: string, databaseName: string): Promise {
             return new Promise((resolve, reject) => {
               DbConnection.builder()
                 .withUri(uri)
          -      .withDatabaseName(db)
          +      .withDatabaseName(databaseName)
                 .onConnect(c => resolve(c))
                 .onDisconnect((_ctx, err) => {
                   broadcastConn('error', err?.message ?? 'disconnected');
          @@ -288,7 +272,7 @@ function setActiveMatch(matchId: bigint | null): void {
               ]);
           }
           
          -function wireRowHandlers(conn: DbConnection): void {
          +function registerRowCallbacks(conn: DbConnection): void {
             const tableAccessors = [
               conn.db.myMatches,
               conn.db.myMatchParticipants,
          @@ -320,7 +304,7 @@ async function bindSession(
             if (!currentConn) {
               broadcastConn('connecting');
               try {
          -      const conn = await buildConnection(
          +      const conn = await connect(
                   serverCfg.stdbUri,
                   serverCfg.appDatabase
                 );
          @@ -330,7 +314,7 @@ async function bindSession(
           
                 broadcastState();
           
          -      wireRowHandlers(conn);
          +      registerRowCallbacks(conn);
           
                 globalSub = conn
                   .subscriptionBuilder()
          @@ -381,7 +365,6 @@ async function restoreSession(): Promise {
             }
           }
           
          -// Authentication flows
           async function signup(args: {
             email: string;
             password: string;
          @@ -429,7 +412,6 @@ async function requestEmailVerify(): Promise {
             await callJson('/auth/email/verify-request', {});
           }
           
          -// Application startup
           async function main(): Promise {
             window.auth = {
               signup,
          diff --git a/spacetime-grid-ts/src/math/coords.ts b/spacetime-grid-ts/src/math/coords.ts
          index ada38594d08..5abc161c753 100644
          --- a/spacetime-grid-ts/src/math/coords.ts
          +++ b/spacetime-grid-ts/src/math/coords.ts
          @@ -1,5 +1,3 @@
          -// Pure coordinate types. No STDB. Square (x,y) and hex axial (q=x, r=y).
          -
           export type Coord = { x: number; y: number };
           export type GridKind = 'square' | 'hex';
           export type HexOrientation = 'flat' | 'pointy';
          diff --git a/spacetime-grid-ts/src/math/distance.ts b/spacetime-grid-ts/src/math/distance.ts
          index dc9c01cc04b..c96137d27ec 100644
          --- a/spacetime-grid-ts/src/math/distance.ts
          +++ b/spacetime-grid-ts/src/math/distance.ts
          @@ -1,8 +1,3 @@
          -// Grid distance functions. Choose based on connectivity:
          -//   square 4-connected: manhattan
          -//   square 8-connected: chebyshev
          -//   hex (any orientation): hexDistance
          -
           import type { Coord, GridKind, Connectivity } from './coords.ts';
           
           export function manhattan(a: Coord, b: Coord): number {
          diff --git a/spacetime-grid-ts/src/math/neighbors.ts b/spacetime-grid-ts/src/math/neighbors.ts
          index 0773a310ee9..f8fe20c2ec2 100644
          --- a/spacetime-grid-ts/src/math/neighbors.ts
          +++ b/spacetime-grid-ts/src/math/neighbors.ts
          @@ -1,5 +1,3 @@
          -// Neighbor offsets per grid kind. Hex axial is orientation-agnostic.
          -
           import type { Coord, Connectivity, GridKind } from './coords.ts';
           
           const SQUARE_4: ReadonlyArray = [
          diff --git a/spacetime-posthog-ts/example/catalog/catalog.ts b/spacetime-posthog-ts/example/catalog/catalog.ts
          index 41ba9b6ee5e..b0ae2159bbd 100644
          --- a/spacetime-posthog-ts/example/catalog/catalog.ts
          +++ b/spacetime-posthog-ts/example/catalog/catalog.ts
          @@ -1,5 +1,3 @@
          -// Seed catalog. The server passes these to the sync_catalog reducer at startup.
          -
           export interface VariantSeed {
             variantId: string;
             productId: string;
          diff --git a/spacetime-posthog-ts/example/src/app.ts b/spacetime-posthog-ts/example/src/app.ts
          index f2863c0d386..526188c4e88 100644
          --- a/spacetime-posthog-ts/example/src/app.ts
          +++ b/spacetime-posthog-ts/example/src/app.ts
          @@ -297,8 +297,6 @@ function currentMetrics(): MetricsRow {
             );
           }
           
          -// Rendering
          -
           function renderKpis(): void {
             const m = currentMetrics();
             const e = currentEcon();
          @@ -779,8 +777,6 @@ function syncRunButton(): void {
             if (label) label.textContent = running ? 'Pause' : 'Run';
           }
           
          -// Simulation controls
          -
           function restartTimer(): void {
             if (simTimer) clearInterval(simTimer);
             simTimer = null;
          @@ -807,8 +803,6 @@ async function tickSimulation(): Promise {
             });
           }
           
          -// UI wiring
          -
           function wireUi(): void {
             $('menuGrid').addEventListener('click', event => {
               const card = (event.target as HTMLElement).closest(
          diff --git a/spacetime-presence-ts/example/src/app.ts b/spacetime-presence-ts/example/src/app.ts
          index 82d9008ae49..dc76064605a 100644
          --- a/spacetime-presence-ts/example/src/app.ts
          +++ b/spacetime-presence-ts/example/src/app.ts
          @@ -270,7 +270,7 @@ async function callJson(path: string, body?: unknown): Promise {
             return data as T;
           }
           
          -async function loadConfig(): Promise {
          +async function loadServerConfig(): Promise {
             const r = await fetch('/api/config');
             if (!r.ok) throw new Error(`/api/config returned ${r.status}`);
             return (await r.json()) as ServerConfig;
          @@ -310,7 +310,7 @@ function scheduleReconnect(): void {
             reconnectTimer = setTimeout(() => {
               reconnectTimer = null;
               reconnectAttempt++;
          -    run().catch(err => {
          +    main().catch(err => {
                 emitConn('error', normalizeError(err));
                 scheduleReconnect();
               });
          @@ -352,7 +352,7 @@ function clearAuthToken(): void {
             }
           }
           
          -function connectStdb(cfg: ServerConfig): Promise {
          +function connect(cfg: ServerConfig): Promise {
             return new Promise((resolve, reject) => {
               const priorToken = loadStdbToken();
               DbConnection.builder()
          @@ -374,7 +374,7 @@ function connectStdb(cfg: ServerConfig): Promise {
             });
           }
           
          -function wireSubscriptions(c: DbConnection): void {
          +function subscribeToTables(c: DbConnection): void {
             c.subscriptionBuilder()
               .onApplied(() => emitData())
               .onError((ctx: ErrorContext) =>
          @@ -396,7 +396,9 @@ function wireSubscriptions(c: DbConnection): void {
                 tables.myAuthUser,
                 tables.myRateLimitStatus,
               ]);
          +}
           
          +function registerRowCallbacks(c: DbConnection): void {
             const reRender = () => emitData();
             const tableAccessors = [
               c.db.myChatUsers,
          @@ -728,22 +730,23 @@ async function initializeIdentity(c: DbConnection): Promise {
             emitAuth();
           }
           
          -async function run(): Promise {
          +async function main(): Promise {
             emitConn('connecting');
          -  if (!config) config = await loadConfig();
          +  if (!config) config = await loadServerConfig();
           
          -  const c = await connectStdb(config);
          +  const c = await connect(config);
             conn = c;
             reconnectAttempt = 0;
             emitConn('connected');
          -  wireSubscriptions(c);
          +  registerRowCallbacks(c);
          +  subscribeToTables(c);
             installApi();
             await initializeIdentity(c);
             await restoreSession();
             window.dispatchEvent(new CustomEvent('chat:ready'));
           }
           
          -run().catch(err => {
          +main().catch(err => {
             emitConn('error', normalizeError(err));
             scheduleReconnect();
           });
          diff --git a/spacetime-resend-ts/example/server.ts b/spacetime-resend-ts/example/server.ts
          index 525afd7a197..fa93b393e52 100644
          --- a/spacetime-resend-ts/example/server.ts
          +++ b/spacetime-resend-ts/example/server.ts
          @@ -1,7 +1,3 @@
          -// Node process: serves the static UI and seeds Resend config on startup. It does
          -// NOT process webhooks - POST /webhook/resend is a thin passthrough to the module's
          -// own native HTTP route, which verifies (in-module, via crypto-ts) and ingests.
          -
           import path from 'node:path';
           import { fileURLToPath } from 'node:url';
           import { existsSync, readFileSync } from 'node:fs';
          @@ -142,8 +138,6 @@ app.get('/api/config', (_req: Request, res: Response) => {
             });
           });
           
          -// Forward the raw body and Svix headers to the module's native route for
          -// signature verification and ingestion.
           async function handleResendWebhook(req: Request, res: Response): Promise {
             const rawBody =
               req.body instanceof Buffer ? req.body : Buffer.from(String(req.body ?? ''));
          diff --git a/spacetime-resend-ts/example/spacetimedb/src/index.ts b/spacetime-resend-ts/example/spacetimedb/src/index.ts
          index 48c65f8719f..9dabbf7cbf2 100644
          --- a/spacetime-resend-ts/example/spacetimedb/src/index.ts
          +++ b/spacetime-resend-ts/example/spacetimedb/src/index.ts
          @@ -1,5 +1,3 @@
          -// Dispatch: a thin host module over the Resend submodule.
          -//
           // The browser never talks to Resend directly and is never granted admin. It calls
           // host procedures that forward to the submodule through `ctx.as.resend`. Resend's
           // base tables stay private; caller-scoped views below expose only the current
          @@ -227,7 +225,6 @@ export const delete_dispatch = spacetimedb.procedure(
             }
           );
           
          -// Clear the whole log.
           export const clear_dispatches = spacetimedb.procedure(
             {},
             dispatchDeleteResult,
          diff --git a/spacetime-resend-ts/src/submodule/webhooks.ts b/spacetime-resend-ts/src/submodule/webhooks.ts
          index beebf4edfd2..db00308e334 100644
          --- a/spacetime-resend-ts/src/submodule/webhooks.ts
          +++ b/spacetime-resend-ts/src/submodule/webhooks.ts
          @@ -244,7 +244,6 @@ const MAX_WEBHOOK_METADATA_LENGTH = 255;
           
           // Verify the Svix signature in-module, then store and apply the event. The
           // reducer and native HTTP route share this single ingest path and receive an
          -// HTTP-shaped result.
           function ingestResendWebhook(
             ctx: WriteCtx,
             args: ResendWebhookIngestArgs
          diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts
          index 8f71d6fbe4e..aa30bb18637 100644
          --- a/spacetime-stripe-ts/example/server.ts
          +++ b/spacetime-stripe-ts/example/server.ts
          @@ -1,5 +1,3 @@
          -// Example server: static files plus a narrow provider-action boundary.
          -
           import path from 'node:path';
           import { fileURLToPath } from 'node:url';
           import express, { type Request, type Response } from 'express';
          diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts
          index a59e099bd86..86f2102eddd 100644
          --- a/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts
          +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts
          @@ -1,5 +1,3 @@
          -// HTTP handlers for the store module.
          -
           import { Router, SyncResponse } from 'spacetimedb/server';
           import { handle_stripe_webhook } from '@spacetimedb/stripe/submodule';
           import { spacetimedb } from './schema';
          diff --git a/spacetime-stripe-ts/src/submodule/operations/billing.ts b/spacetime-stripe-ts/src/submodule/operations/billing.ts
          index 63a8495cafa..5079f0bc065 100644
          --- a/spacetime-stripe-ts/src/submodule/operations/billing.ts
          +++ b/spacetime-stripe-ts/src/submodule/operations/billing.ts
          @@ -97,7 +97,6 @@ export const validate_stripe_price = spacetimedb.procedure(
             }
           );
           
          -// Fetch a Stripe checkout session by id.
           export const get_remote_checkout_session = spacetimedb.procedure(
             { sessionId: t.string() },
             t.object('RemoteCheckoutSessionResult', {
          
          From dfaeeb82c8c8d5d764e14ee634437076b5080342 Mon Sep 17 00:00:00 2001
          From: bradleyshep 
          Date: Wed, 26 Aug 2026 09:51:09 -0400
          Subject: [PATCH 19/33] consistency
          
          ---
           .prettierignore                               |  3 +
           spacetime-agents-ts/README.md                 |  2 +-
           spacetime-agents-ts/example/server.ts         |  6 +-
           spacetime-agents-ts/example/src/app.ts        | 92 ++++++++++---------
           spacetime-agents-ts/spacetimedb/src/index.ts  |  4 +-
           spacetime-agents-ts/src/submodule.ts          |  6 +-
           .../src/{mounted => submodule}/index.ts       |  0
           .../src/{mounted => submodule}/install.ts     |  0
           .../src/{mounted => submodule}/loop.ts        |  0
           .../src/{mounted => submodule}/model.ts       |  0
           .../src/{mounted => submodule}/summarize.ts   |  0
           spacetime-api-keys-ts/example/server.ts       |  6 +-
           spacetime-api-keys-ts/example/src/app.ts      | 85 +++++++++--------
           spacetime-api-keys-ts/example/src/model.ts    |  5 +-
           spacetime-auth-ts/example/server.ts           |  6 +-
           spacetime-auth-ts/example/src/app.ts          | 37 ++++----
           spacetime-cron-ts/example/server.ts           |  4 +-
           spacetime-cron-ts/example/src/app.ts          | 60 +++++++-----
           spacetime-files-ts/README.md                  |  4 +-
           spacetime-files-ts/example/server.ts          |  4 +-
           spacetime-files-ts/example/src/app.ts         | 73 ++++++++-------
           spacetime-files-ts/example/src/drop-target.ts |  2 +-
           .../example/src/list-actions.ts               |  4 +-
           spacetime-files-ts/example/src/utils.ts       |  4 +-
           spacetime-grid-ts/README.md                   |  4 +-
           spacetime-grid-ts/example/server.ts           |  6 +-
           spacetime-grid-ts/example/src/app.ts          | 58 ++++++------
           spacetime-lobby-ts/README.md                  | 16 ++--
           spacetime-lobby-ts/example/server.ts          |  4 +-
           spacetime-lobby-ts/example/src/app.ts         | 37 ++++----
           spacetime-lobby-ts/example/src/model.ts       |  4 +-
           spacetime-posthog-ts/example/server.ts        | 10 +-
           spacetime-posthog-ts/example/src/app.ts       | 77 ++++++++--------
           spacetime-presence-ts/README.md               |  4 +-
           spacetime-presence-ts/example/server.ts       |  6 +-
           spacetime-presence-ts/example/src/app.ts      | 69 +++++++-------
           spacetime-rate-limit-ts/example/server.ts     |  4 +-
           spacetime-rate-limit-ts/example/src/app.ts    | 53 +++++++----
           spacetime-resend-ts/example/server.ts         | 10 +-
           spacetime-resend-ts/example/src/app.ts        | 25 ++---
           spacetime-stripe-ts/example/server.ts         | 10 +-
           spacetime-stripe-ts/example/src/app.ts        | 86 +++++++++--------
           42 files changed, 490 insertions(+), 400 deletions(-)
           rename spacetime-agents-ts/src/{mounted => submodule}/index.ts (100%)
           rename spacetime-agents-ts/src/{mounted => submodule}/install.ts (100%)
           rename spacetime-agents-ts/src/{mounted => submodule}/loop.ts (100%)
           rename spacetime-agents-ts/src/{mounted => submodule}/model.ts (100%)
           rename spacetime-agents-ts/src/{mounted => submodule}/summarize.ts (100%)
          
          diff --git a/.prettierignore b/.prettierignore
          index 41800b56b39..6b4b24fe735 100644
          --- a/.prettierignore
          +++ b/.prettierignore
          @@ -4,3 +4,6 @@ dist
           target
           .github
           coverage
          +**/public/app.js
          +**/public/app.js.map
          +**/src/module_bindings/**
          diff --git a/spacetime-agents-ts/README.md b/spacetime-agents-ts/README.md
          index a63e4964518..fae2f8d1f97 100644
          --- a/spacetime-agents-ts/README.md
          +++ b/spacetime-agents-ts/README.md
          @@ -29,7 +29,7 @@ const spacetimedb = schema({ agents });
           export default spacetimedb;
           
           export const init = spacetimedb.init(ctx => {
          -  agents.installAgents(ctx);
          +  agents.installAgents(ctx.as.agents);
           });
           ```
           
          diff --git a/spacetime-agents-ts/example/server.ts b/spacetime-agents-ts/example/server.ts
          index c97ab593a88..c7a2d73f8e6 100644
          --- a/spacetime-agents-ts/example/server.ts
          +++ b/spacetime-agents-ts/example/server.ts
          @@ -213,13 +213,13 @@ app.use('/files', proxyStdbRoute('/files'));
           app.use(express.static(path.join(__dirname, 'public')));
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, app: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
             res.json({
          -    stdbUri: STDB_URI,
          -    appDatabase: DB_NAME,
          +    spacetimeUri: STDB_URI,
          +    databaseName: DB_NAME,
               auth: {
                 issuerUrl: AUTH_ISSUER_URL,
                 baseUrl: AUTH_BASE_URL,
          diff --git a/spacetime-agents-ts/example/src/app.ts b/spacetime-agents-ts/example/src/app.ts
          index 9be1cd0a7e4..2e5e8d5cb1b 100644
          --- a/spacetime-agents-ts/example/src/app.ts
          +++ b/spacetime-agents-ts/example/src/app.ts
          @@ -112,7 +112,7 @@ let currentConn: DbConnection | null = null;
           let globalSub: SubscriptionHandle | null = null;
           let messageSub: SubscriptionHandle | null = null;
           let activeThreadId: bigint | null = null;
          -let serverCfg: { stdbUri: string; appDatabase: string } | null = null;
          +let serverCfg: { spacetimeUri: string; databaseName: string } | null = null;
           
           let currentUser: AuthUser | null = null;
           let currentExp: number | undefined;
          @@ -223,8 +223,8 @@ async function callJson(path: string, body?: unknown): Promise {
           }
           
           async function loadServerConfig(): Promise<{
          -  stdbUri: string;
          -  appDatabase: string;
          +  spacetimeUri: string;
          +  databaseName: string;
           }> {
             const res = await fetch('/api/config', { credentials: 'same-origin' });
             if (!res.ok) throw new Error(`/api/config returned ${res.status}`);
          @@ -254,9 +254,9 @@ function connect(uri: string, databaseName: string): Promise {
                 .withUri(uri)
                 .withDatabaseName(databaseName)
                 .withToken(loadStdbToken())
          -      .onConnect((c, _identity, token) => {
          +      .onConnect((connection, _identity, token) => {
                   if (token) saveStdbToken(token);
          -        resolve(c);
          +        resolve(connection);
                 })
                 .onDisconnect((_ctx, err) => {
                   broadcastConn('error', err?.message ?? 'disconnected');
          @@ -322,34 +322,34 @@ function setActiveThread(threadId: bigint | null): void {
               .subscribe([tables.myMessages.where(row => row.threadId.eq(threadId))]);
           }
           
          -function registerRowCallbacks(conn: DbConnection): void {
          -  conn.db.myThreads.onInsert(() => broadcastThreads());
          -  conn.db.myThreads.onUpdate(() => broadcastThreads());
          -  conn.db.myThreads.onDelete(() => broadcastThreads());
          +function registerRowCallbacks(connection: DbConnection): void {
          +  connection.db.myThreads.onInsert(() => broadcastThreads());
          +  connection.db.myThreads.onUpdate(() => broadcastThreads());
          +  connection.db.myThreads.onDelete(() => broadcastThreads());
           
          -  conn.db.myMessages.onInsert(() => broadcastMessages());
          -  conn.db.myMessages.onUpdate(() => broadcastMessages());
          -  conn.db.myMessages.onDelete(() => broadcastMessages());
          +  connection.db.myMessages.onInsert(() => broadcastMessages());
          +  connection.db.myMessages.onUpdate(() => broadcastMessages());
          +  connection.db.myMessages.onDelete(() => broadcastMessages());
           
          -  conn.db.myFiles.onInsert(() => broadcastMessages());
          -  conn.db.myFiles.onUpdate(() => broadcastMessages());
          -  conn.db.myFiles.onDelete(() => broadcastMessages());
          +  connection.db.myFiles.onInsert(() => broadcastMessages());
          +  connection.db.myFiles.onUpdate(() => broadcastMessages());
          +  connection.db.myFiles.onDelete(() => broadcastMessages());
           
          -  conn.db.myThreadLocks.onInsert(() => broadcastLocks());
          -  conn.db.myThreadLocks.onUpdate(() => broadcastLocks());
          -  conn.db.myThreadLocks.onDelete(() => broadcastLocks());
          +  connection.db.myThreadLocks.onInsert(() => broadcastLocks());
          +  connection.db.myThreadLocks.onUpdate(() => broadcastLocks());
          +  connection.db.myThreadLocks.onDelete(() => broadcastLocks());
           
          -  conn.db.agentOverride.onInsert(() => broadcastOverrides());
          -  conn.db.agentOverride.onUpdate(() => broadcastOverrides());
          -  conn.db.agentOverride.onDelete(() => broadcastOverrides());
          +  connection.db.agentOverride.onInsert(() => broadcastOverrides());
          +  connection.db.agentOverride.onUpdate(() => broadcastOverrides());
          +  connection.db.agentOverride.onDelete(() => broadcastOverrides());
           
          -  conn.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) =>
          +  connection.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) =>
               syncUserFromRow(row)
             );
          -  conn.db.myAuthUser.onUpdate(
          +  connection.db.myAuthUser.onUpdate(
               (_ctx: EventContext, _o: AuthUserRow, n: AuthUserRow) => syncUserFromRow(n)
             );
          -  conn.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => {
          +  connection.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => {
               if (!currentUser || row.userId !== currentUser.userId) return;
               currentUser = null;
               currentExp = undefined;
          @@ -357,6 +357,27 @@ function registerRowCallbacks(conn: DbConnection): void {
             });
           }
           
          +function subscribeToTables(connection: DbConnection): SubscriptionHandle {
          +  return connection
          +    .subscriptionBuilder()
          +    .onApplied(() => {
          +      broadcastThreads();
          +      broadcastLocks();
          +      broadcastOverrides();
          +      broadcastMessages();
          +    })
          +    .onError((ctx: ErrorContext) =>
          +      console.error('global sub error', ctx.event)
          +    )
          +    .subscribe([
          +      tables.myThreads,
          +      tables.myThreadLocks,
          +      tables.agentOverride,
          +      tables.myFiles,
          +      tables.myAuthUser,
          +    ]);
          +}
          +
           async function refreshConfigStatus(): Promise {
             const status = await requireConn().procedures.getAgentConfigStatus({});
             configState = status.isConfigured
          @@ -380,8 +401,8 @@ async function bindSession(
               broadcastConn('connecting');
               try {
                 const conn = await connect(
          -        serverCfg.stdbUri,
          -        serverCfg.appDatabase
          +        serverCfg.spacetimeUri,
          +        serverCfg.databaseName
                 );
                 currentConn = conn;
                 reconnectAttempt = 0;
          @@ -407,24 +428,7 @@ async function bindSession(
             }
           
             if (!globalSub) {
          -    globalSub = currentConn
          -      .subscriptionBuilder()
          -      .onApplied(() => {
          -        broadcastThreads();
          -        broadcastLocks();
          -        broadcastOverrides();
          -        broadcastMessages();
          -      })
          -      .onError((ctx: ErrorContext) =>
          -        console.error('global sub error', ctx.event)
          -      )
          -      .subscribe([
          -        tables.myThreads,
          -        tables.myThreadLocks,
          -        tables.agentOverride,
          -        tables.myFiles,
          -        tables.myAuthUser,
          -      ]);
          +    globalSub = subscribeToTables(currentConn);
           
               const previousActive = activeThreadId;
               activeThreadId = null;
          diff --git a/spacetime-agents-ts/spacetimedb/src/index.ts b/spacetime-agents-ts/spacetimedb/src/index.ts
          index ddd02a77d65..160a0b54719 100644
          --- a/spacetime-agents-ts/spacetimedb/src/index.ts
          +++ b/spacetime-agents-ts/spacetimedb/src/index.ts
          @@ -1,2 +1,2 @@
          -export { default } from '../../src/mounted/index';
          -export * from '../../src/mounted/index';
          +export { default } from '../../src/submodule/index';
          +export * from '../../src/submodule/index';
          diff --git a/spacetime-agents-ts/src/submodule.ts b/spacetime-agents-ts/src/submodule.ts
          index 243d54d1ab9..9bcd2213661 100644
          --- a/spacetime-agents-ts/src/submodule.ts
          +++ b/spacetime-agents-ts/src/submodule.ts
          @@ -1,5 +1,5 @@
          -export { default } from './mounted/index';
          -export { installAgents } from './mounted/install';
          +export { default } from './submodule/index';
          +export { installAgents } from './submodule/install';
           export {
             add_agent_admin_identity,
             clear_agent_override,
          @@ -22,4 +22,4 @@ export {
             start_thread,
             thread_lock_sweep,
             update_thread,
          -} from './mounted/index';
          +} from './submodule/index';
          diff --git a/spacetime-agents-ts/src/mounted/index.ts b/spacetime-agents-ts/src/submodule/index.ts
          similarity index 100%
          rename from spacetime-agents-ts/src/mounted/index.ts
          rename to spacetime-agents-ts/src/submodule/index.ts
          diff --git a/spacetime-agents-ts/src/mounted/install.ts b/spacetime-agents-ts/src/submodule/install.ts
          similarity index 100%
          rename from spacetime-agents-ts/src/mounted/install.ts
          rename to spacetime-agents-ts/src/submodule/install.ts
          diff --git a/spacetime-agents-ts/src/mounted/loop.ts b/spacetime-agents-ts/src/submodule/loop.ts
          similarity index 100%
          rename from spacetime-agents-ts/src/mounted/loop.ts
          rename to spacetime-agents-ts/src/submodule/loop.ts
          diff --git a/spacetime-agents-ts/src/mounted/model.ts b/spacetime-agents-ts/src/submodule/model.ts
          similarity index 100%
          rename from spacetime-agents-ts/src/mounted/model.ts
          rename to spacetime-agents-ts/src/submodule/model.ts
          diff --git a/spacetime-agents-ts/src/mounted/summarize.ts b/spacetime-agents-ts/src/submodule/summarize.ts
          similarity index 100%
          rename from spacetime-agents-ts/src/mounted/summarize.ts
          rename to spacetime-agents-ts/src/submodule/summarize.ts
          diff --git a/spacetime-api-keys-ts/example/server.ts b/spacetime-api-keys-ts/example/server.ts
          index f779f84b52c..1c5c84c62c5 100644
          --- a/spacetime-api-keys-ts/example/server.ts
          +++ b/spacetime-api-keys-ts/example/server.ts
          @@ -33,13 +33,13 @@ app.use(express.json({ limit: '256kb' }));
           
           app.get('/api/config', (_req: Request, res: Response) => {
             res.json({
          -    stdbUri: STDB_URI,
          -    database: DB_NAME,
          +    spacetimeUri: STDB_URI,
          +    databaseName: DB_NAME,
             });
           });
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, database: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.use('/api/colony', async (req: Request, res: Response) => {
          diff --git a/spacetime-api-keys-ts/example/src/app.ts b/spacetime-api-keys-ts/example/src/app.ts
          index f6296730a3f..1776853a874 100644
          --- a/spacetime-api-keys-ts/example/src/app.ts
          +++ b/spacetime-api-keys-ts/example/src/app.ts
          @@ -117,7 +117,7 @@ function toast(message: string, kind: 'ok' | 'error' = 'ok'): void {
           }
           
           function tokenKey(): string {
          -  return `${TOKEN_PREFIX}.${config?.stdbUri ?? 'unknown'}.${config?.database ?? 'unknown'}`;
          +  return `${TOKEN_PREFIX}.${config?.spacetimeUri ?? 'unknown'}.${config?.databaseName ?? 'unknown'}`;
           }
           
           function requireConn(): DbConnection {
          @@ -183,7 +183,7 @@ function loadColor(): string {
             );
           }
           
          -async function loadConfig(): Promise {
          +async function loadServerConfig(): Promise {
             const res = await fetch('/api/config');
             if (!res.ok) throw new Error(`/api/config returned ${res.status}`);
             return (await res.json()) as ServerConfig;
          @@ -194,18 +194,18 @@ function connect(cfg: ServerConfig): Promise {
               let retriedWithoutToken = false;
               const start = (token: string | undefined) => {
                 let builder = DbConnection.builder()
          -        .withUri(cfg.stdbUri)
          -        .withDatabaseName(cfg.database);
          +        .withUri(cfg.spacetimeUri)
          +        .withDatabaseName(cfg.databaseName);
                 if (token) builder = builder.withToken(token);
                 builder
          -        .onConnect((c, identity, nextToken) => {
          -          conn = c;
          +        .onConnect((connection, identity, nextToken) => {
          +          conn = connection;
                     identityHex =
                       typeof identity.toHexString === 'function'
                         ? identity.toHexString()
                         : String(identity);
                     if (nextToken) localStorage.setItem(tokenKey(), nextToken);
          -          resolve(c);
          +          resolve(connection);
                   })
                   .onDisconnect((_ctx, err) => {
                     conn = null;
          @@ -240,7 +240,7 @@ function scheduleReconnect(): void {
             if (reconnectTimer) return;
             reconnectTimer = window.setTimeout(() => {
               reconnectTimer = null;
          -    run().catch(err => {
          +    main().catch(err => {
                 console.error(err);
                 setStatus(err instanceof Error ? err.message : String(err));
                 scheduleReconnect();
          @@ -251,11 +251,12 @@ function scheduleReconnect(): void {
           // Subscriptions. Owner and holder subscribe to the same colony by id;
           // only the id differs. Reads are public-by-colony; writes are gated.
           
          -function subscribeAll(): void {
          +function subscribeToTables(): void {
             if (subscribed) return;
             subscribed = true;
          -  const c = requireConn();
          -  c.subscriptionBuilder()
          +  const connection = requireConn();
          +  connection
          +    .subscriptionBuilder()
               .onApplied(() => renderWorld())
               .onError((ctx: ErrorContext) =>
                 console.error('subscription error', ctx.event)
          @@ -269,28 +270,31 @@ function subscribeAll(): void {
                 tables.presenceEntry.where(row => row.scope.eq(colonyId)),
                 ...(mode === 'owner' ? [tables.myAccessKeys] : []),
               ]);
          +}
           
          -  c.db.world.onInsert(() => renderWorld());
          -  c.db.world.onUpdate(() => renderWorld());
          -  c.db.world.onDelete(() => renderWorld());
          -  c.db.colonyGrid.onInsert(() => renderWorld());
          -  c.db.colonyGrid.onUpdate(() => renderWorld());
          -  c.db.colonyGrid.onDelete(() => renderWorld());
          -  c.db.colonyCells.onInsert(() => renderWorld());
          -  c.db.colonyCells.onUpdate(() => renderWorld());
          -  c.db.colonyCells.onDelete(() => renderWorld());
          -  c.db.colonyEntities.onInsert(() => renderWorld());
          -  c.db.colonyEntities.onUpdate(() => renderWorld());
          -  c.db.colonyEntities.onDelete(() => renderWorld());
          -  c.db.worldEvent.onInsert(() => renderWorld());
          -  c.db.worldEvent.onUpdate(() => renderWorld());
          -  c.db.worldEvent.onDelete(() => renderWorld());
          -  c.db.myAccessKeys.onInsert(() => renderWorld());
          -  c.db.myAccessKeys.onUpdate(() => renderWorld());
          -  c.db.myAccessKeys.onDelete(() => renderWorld());
          -  c.db.presenceEntry.onInsert(() => renderPresence());
          -  c.db.presenceEntry.onUpdate(() => renderPresence());
          -  c.db.presenceEntry.onDelete(() => renderPresence());
          +function registerRowCallbacks(): void {
          +  const connection = requireConn();
          +  connection.db.world.onInsert(() => renderWorld());
          +  connection.db.world.onUpdate(() => renderWorld());
          +  connection.db.world.onDelete(() => renderWorld());
          +  connection.db.colonyGrid.onInsert(() => renderWorld());
          +  connection.db.colonyGrid.onUpdate(() => renderWorld());
          +  connection.db.colonyGrid.onDelete(() => renderWorld());
          +  connection.db.colonyCells.onInsert(() => renderWorld());
          +  connection.db.colonyCells.onUpdate(() => renderWorld());
          +  connection.db.colonyCells.onDelete(() => renderWorld());
          +  connection.db.colonyEntities.onInsert(() => renderWorld());
          +  connection.db.colonyEntities.onUpdate(() => renderWorld());
          +  connection.db.colonyEntities.onDelete(() => renderWorld());
          +  connection.db.worldEvent.onInsert(() => renderWorld());
          +  connection.db.worldEvent.onUpdate(() => renderWorld());
          +  connection.db.worldEvent.onDelete(() => renderWorld());
          +  connection.db.myAccessKeys.onInsert(() => renderWorld());
          +  connection.db.myAccessKeys.onUpdate(() => renderWorld());
          +  connection.db.myAccessKeys.onDelete(() => renderWorld());
          +  connection.db.presenceEntry.onInsert(() => renderPresence());
          +  connection.db.presenceEntry.onUpdate(() => renderPresence());
          +  connection.db.presenceEntry.onDelete(() => renderPresence());
           }
           
           function terrainFor(x: number, y: number): string {
          @@ -937,7 +941,7 @@ function joinColony(): void {
             location.href = shareLink(key);
           }
           
          -function wireControls(): void {
          +function registerUiHandlers(): void {
             if (controlsWired) return;
             controlsWired = true;
           
          @@ -1112,11 +1116,11 @@ function applyModeChrome(): void {
             }
           }
           
          -async function run(): Promise {
          +async function main(): Promise {
             setStatus('Connecting');
          -  config = await loadConfig();
          -  const c = await connect(config);
          -  conn = c;
          +  config = await loadServerConfig();
          +  const connection = await connect(config);
          +  conn = connection;
             myName = loadName();
             myColor = loadColor();
           
          @@ -1160,14 +1164,15 @@ async function run(): Promise {
           
             setStatus('Connected');
             applyModeChrome();
          -  subscribeAll();
          -  wireControls();
          +  registerRowCallbacks();
          +  subscribeToTables();
          +  registerUiHandlers();
             renderRoleGrid();
             renderWorld();
             startPresence();
           }
           
          -run().catch(err => {
          +main().catch(err => {
             console.error(err);
             setStatus(err instanceof Error ? err.message : String(err));
             toast(err instanceof Error ? err.message : String(err), 'error');
          diff --git a/spacetime-api-keys-ts/example/src/model.ts b/spacetime-api-keys-ts/example/src/model.ts
          index bc20d2bd976..355eaac64a7 100644
          --- a/spacetime-api-keys-ts/example/src/model.ts
          +++ b/spacetime-api-keys-ts/example/src/model.ts
          @@ -1,7 +1,10 @@
           export type TimestampLike = { microsSinceUnixEpoch: bigint };
           export type EnumTag = { tag: T };
           
          -export type ServerConfig = { stdbUri: string; database: string };
          +export type ServerConfig = {
          +  spacetimeUri: string;
          +  databaseName: string;
          +};
           
           export type World = {
             ownerSubject: string;
          diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts
          index f6960e34eac..546ed9087e6 100644
          --- a/spacetime-auth-ts/example/server.ts
          +++ b/spacetime-auth-ts/example/server.ts
          @@ -150,8 +150,8 @@ app.use('/auth', async (req, res) => {
           
           app.get('/api/config', (_req: Request, res: Response) => {
             res.json({
          -    stdbUri: STDB_URI,
          -    appDatabase: DB_NAME,
          +    spacetimeUri: STDB_URI,
          +    databaseName: DB_NAME,
               auth: {
                 issuerUrl: AUTH_ISSUER_URL,
                 baseUrl: AUTH_BASE_URL,
          @@ -169,7 +169,7 @@ app.get('/api/config', (_req: Request, res: Response) => {
           });
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, app: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.use(express.static(path.join(__dirname, 'public')));
          diff --git a/spacetime-auth-ts/example/src/app.ts b/spacetime-auth-ts/example/src/app.ts
          index 06a4fc3334a..7bcbc521887 100644
          --- a/spacetime-auth-ts/example/src/app.ts
          +++ b/spacetime-auth-ts/example/src/app.ts
          @@ -59,8 +59,8 @@ interface AuthMe {
           }
           
           interface ServerConfig {
          -  stdbUri: string;
          -  appDatabase: string;
          +  spacetimeUri: string;
          +  databaseName: string;
             oauth?: {
               google?: boolean;
               github?: boolean;
          @@ -159,12 +159,12 @@ function connect(): Promise {
             const config = serverCfg;
             return new Promise((resolve, reject) => {
               DbConnection.builder()
          -      .withUri(config.stdbUri)
          -      .withDatabaseName(config.appDatabase)
          +      .withUri(config.spacetimeUri)
          +      .withDatabaseName(config.databaseName)
                 .withToken(loadStdbToken())
          -      .onConnect((c, _identity, token) => {
          +      .onConnect((connection, _identity, token) => {
                   if (token) saveStdbToken(token);
          -        resolve(c);
          +        resolve(connection);
                 })
                 .onDisconnect((_ctx, err) => {
                   broadcastConn('error', err?.message ?? 'disconnected');
          @@ -241,25 +241,26 @@ function syncUserFromRow(row: AuthUserRow) {
             broadcastAuth();
           }
           
          -function subscribeToTables(c: DbConnection): void {
          -  c.subscriptionBuilder()
          +function subscribeToTables(connection: DbConnection): void {
          +  connection
          +    .subscriptionBuilder()
               .onApplied(() => broadcastNotes())
               .onError((ctx: ErrorContext) => console.error('sub error', ctx.event))
               .subscribe([tables.myNotes, tables.myAuthUser]);
           }
           
          -function registerRowCallbacks(c: DbConnection): void {
          -  c.db.myNotes.onInsert(() => broadcastNotes());
          -  c.db.myNotes.onUpdate(() => broadcastNotes());
          -  c.db.myNotes.onDelete(() => broadcastNotes());
          +function registerRowCallbacks(connection: DbConnection): void {
          +  connection.db.myNotes.onInsert(() => broadcastNotes());
          +  connection.db.myNotes.onUpdate(() => broadcastNotes());
          +  connection.db.myNotes.onDelete(() => broadcastNotes());
           
          -  c.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) =>
          +  connection.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) =>
               syncUserFromRow(row)
             );
          -  c.db.myAuthUser.onUpdate(
          +  connection.db.myAuthUser.onUpdate(
               (_ctx: EventContext, _o: AuthUserRow, n: AuthUserRow) => syncUserFromRow(n)
             );
          -  c.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => {
          +  connection.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => {
               if (!currentUser || row.userId !== currentUser.userId) return;
               currentUser = null;
               currentExp = undefined;
          @@ -384,7 +385,7 @@ window.auth = {
             setProfile,
           };
           
          -(async () => {
          +async function main(): Promise {
             broadcastConn('idle');
             try {
               serverCfg = await loadServerConfig();
          @@ -394,4 +395,6 @@ window.auth = {
             }
             await restoreSession();
             dispatch('auth:ready', {});
          -})();
          +}
          +
          +void main();
          diff --git a/spacetime-cron-ts/example/server.ts b/spacetime-cron-ts/example/server.ts
          index 6fb8320a7e1..d911fdbe4a5 100644
          --- a/spacetime-cron-ts/example/server.ts
          +++ b/spacetime-cron-ts/example/server.ts
          @@ -37,11 +37,11 @@ app.use(express.json({ limit: '256kb' }));
           app.use(express.static(path.join(__dirname, 'public')));
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, app: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
          -  res.json({ stdbUri: STDB_URI, appDatabase: DB_NAME });
          +  res.json({ spacetimeUri: STDB_URI, databaseName: DB_NAME });
           });
           
           app.listen(PORT, HOST, () => {
          diff --git a/spacetime-cron-ts/example/src/app.ts b/spacetime-cron-ts/example/src/app.ts
          index 6ad186e648c..38b944d0dd6 100644
          --- a/spacetime-cron-ts/example/src/app.ts
          +++ b/spacetime-cron-ts/example/src/app.ts
          @@ -2,8 +2,8 @@ import { DbConnection, tables, type ErrorContext } from './module_bindings/app';
           import type { CronSchedule } from './module_bindings/app/types';
           
           interface ServerConfig {
          -  stdbUri: string;
          -  appDatabase: string;
          +  spacetimeUri: string;
          +  databaseName: string;
           }
           
           type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error';
          @@ -305,7 +305,7 @@ function applyPreset(preset: string): void {
             expression.focus();
           }
           
          -function wireForm(): void {
          +function registerUiHandlers(): void {
             const form = byId('schedule-form');
             const kind = byId('spec-kind');
             const expression = byId('spec-expr');
          @@ -385,39 +385,45 @@ function wireForm(): void {
             updateScheduleFields();
           }
           
          -function registerRowCallbacks(current: DbConnection): void {
          -  current.db.cronJobs.onInsert(renderJobs);
          -  current.db.cronJobs.onDelete(renderJobs);
          -  current.db.cronJobs.onUpdate(renderJobs);
          -  current.db.cronRun.onInsert(renderRuns);
          -  current.db.cronRun.onDelete(renderRuns);
          -  current.db.cronRun.onUpdate(renderRuns);
          -  current.db.activityLog.onInsert(renderActivity);
          -  current.db.activityLog.onDelete(renderActivity);
          -  current.db.activityLog.onUpdate(renderActivity);
          +function registerRowCallbacks(connection: DbConnection): void {
          +  connection.db.cronJobs.onInsert(renderJobs);
          +  connection.db.cronJobs.onUpdate(renderJobs);
          +  connection.db.cronJobs.onDelete(renderJobs);
          +  connection.db.cronRun.onInsert(renderRuns);
          +  connection.db.cronRun.onUpdate(renderRuns);
          +  connection.db.cronRun.onDelete(renderRuns);
          +  connection.db.activityLog.onInsert(renderActivity);
          +  connection.db.activityLog.onUpdate(renderActivity);
          +  connection.db.activityLog.onDelete(renderActivity);
           }
           
          -async function main(): Promise {
          -  wireForm();
          -  setConnection('connecting', 'Connecting');
          +function subscribeToTables(connection: DbConnection): void {
          +  connection
          +    .subscriptionBuilder()
          +    .onApplied(renderAll)
          +    .onError((ctx: ErrorContext) =>
          +      console.error('subscription error', ctx.event)
          +    )
          +    .subscribe([tables.cronJobs, tables.cronRun, tables.activityLog]);
          +}
           
          +async function loadServerConfig(): Promise {
             const response = await fetch('/api/config');
             if (!response.ok) {
               throw new Error(`Config request failed: ${response.status}`);
             }
          -  const config = (await response.json()) as ServerConfig;
          +  return (await response.json()) as ServerConfig;
          +}
           
          +function connect(config: ServerConfig): void {
             DbConnection.builder()
          -    .withUri(config.stdbUri)
          -    .withDatabaseName(config.appDatabase)
          +    .withUri(config.spacetimeUri)
          +    .withDatabaseName(config.databaseName)
               .onConnect((current: DbConnection) => {
                 connection = current;
          -      setConnection('connected', config.appDatabase);
          +      setConnection('connected', config.databaseName);
                 registerRowCallbacks(current);
          -      current
          -        .subscriptionBuilder()
          -        .onApplied(renderAll)
          -        .subscribe([tables.cronJobs, tables.cronRun, tables.activityLog]);
          +      subscribeToTables(current);
               })
               .onConnectError((_ctx: ErrorContext, error: Error) => {
                 setConnection('error', 'Connection failed');
          @@ -431,6 +437,12 @@ async function main(): Promise {
               .build();
           }
           
          +async function main(): Promise {
          +  registerUiHandlers();
          +  setConnection('connecting', 'Connecting');
          +  connect(await loadServerConfig());
          +}
          +
           void main().catch(error => {
             setConnection('error', 'Configuration failed');
             setFormStatus(errorMessage(error), 'error');
          diff --git a/spacetime-files-ts/README.md b/spacetime-files-ts/README.md
          index 8d74298e89f..121a2306324 100644
          --- a/spacetime-files-ts/README.md
          +++ b/spacetime-files-ts/README.md
          @@ -89,6 +89,8 @@ After generating bindings, upload through the host wrapper and subscribe to a
           host view that returns file summaries:
           
           ```ts
          +import { tables } from './module_bindings';
          +
           const fileId = await conn.procedures.uploadFile({
             path: '/avatars/me.png',
             mimeType: 'image/png',
          @@ -96,7 +98,7 @@ const fileId = await conn.procedures.uploadFile({
             visibility: 'owner',
           });
           
          -conn.subscriptionBuilder().subscribe(['SELECT * FROM my_file_summaries']);
          +conn.subscriptionBuilder().subscribe([tables.myFileSummaries]);
           ```
           
           ## API
          diff --git a/spacetime-files-ts/example/server.ts b/spacetime-files-ts/example/server.ts
          index 686958beb9e..39a6ac9d943 100644
          --- a/spacetime-files-ts/example/server.ts
          +++ b/spacetime-files-ts/example/server.ts
          @@ -89,11 +89,11 @@ app.use('/files', proxyStdbRoute('/files'));
           app.use(express.static(path.join(__dirname, 'public')));
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, app: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
          -  res.json({ stdbUri: STDB_URI, appDatabase: DB_NAME });
          +  res.json({ spacetimeUri: STDB_URI, databaseName: DB_NAME });
           });
           
           app.listen(PORT, HOST, () => {
          diff --git a/spacetime-files-ts/example/src/app.ts b/spacetime-files-ts/example/src/app.ts
          index bafb5743a73..b356f13a60c 100644
          --- a/spacetime-files-ts/example/src/app.ts
          +++ b/spacetime-files-ts/example/src/app.ts
          @@ -28,10 +28,7 @@ import { collectDropped, UploadController } from './uploads';
           import { ContextMenu } from './context-menu';
           import { bindListActions as bindListInteractions } from './list-actions';
           import { handleListKey } from './keyboard';
          -import {
          -  uploadDropped,
          -  wireFolderDropTarget as wireDropTarget,
          -} from './drop-target';
          +import { uploadDropped, registerFolderDropTarget } from './drop-target';
           import {
             createVaultRendering,
             fileDetailsHtml,
          @@ -45,7 +42,7 @@ import { VaultSelection } from './selection';
           let conn: DbConnection | null = null;
           let authToken: string | undefined = loadToken();
           
          -async function loadConfig(): Promise {
          +async function loadServerConfig(): Promise {
             const res = await fetch('/api/config');
             if (!res.ok) throw new Error(`/api/config returned ${res.status}`);
             return (await res.json()) as ServerConfig;
          @@ -54,13 +51,13 @@ async function loadConfig(): Promise {
           function connect(config: ServerConfig): Promise {
             return new Promise((resolve, reject) => {
               DbConnection.builder()
          -      .withUri(config.stdbUri)
          -      .withDatabaseName(config.appDatabase)
          +      .withUri(config.spacetimeUri)
          +      .withDatabaseName(config.databaseName)
                 .withToken(authToken)
          -      .onConnect((c, _identity, token) => {
          +      .onConnect((connection, _identity, token) => {
                   authToken = token;
                   saveToken(token);
          -        resolve(c);
          +        resolve(connection);
                 })
                 .onDisconnect((_ctx, err) => {
                   conn = null;
          @@ -375,7 +372,7 @@ function renderTree(): void {
                 btn.addEventListener('contextmenu', e =>
                   openCtxMenu(e, { type: 'folder', path: btn.dataset.path! })
                 );
          -      wireFolderDropTarget(btn, btn.dataset.path!);
          +      registerFolderDropCallbacks(btn, btn.dataset.path!);
               });
           }
           function renderHead(): void {
          @@ -434,7 +431,7 @@ function renderStorage(): void {
               : 'No files stored yet';
           }
           
          -function wireDetailsNav(scope: HTMLElement): void {
          +function registerDetailsNavigationHandlers(scope: HTMLElement): void {
             scope.querySelectorAll('[data-goto]').forEach(btn =>
               btn.addEventListener('click', () => {
                 currentPath = btn.dataset.goto!;
          @@ -464,7 +461,7 @@ async function loadDetailsThumb(row: FileSummary): Promise {
           function renderFileDetails(body: HTMLElement, row: FileSummary): void {
             const isImage = (row.mimeType || '').startsWith('image/');
             body.innerHTML = fileDetailsHtml(row);
          -  wireDetailsNav(body);
          +  registerDetailsNavigationHandlers(body);
             if (isImage) void loadDetailsThumb(row);
           }
           function renderFolderDetails(body: HTMLElement, folderPath: string): void {
          @@ -475,7 +472,7 @@ function renderFolderDetails(body: HTMLElement, folderPath: string): void {
               row ?? undefined,
               subtreeStats(folderPath)
             );
          -  wireDetailsNav(body);
          +  registerDetailsNavigationHandlers(body);
           }
           function renderDetails(): void {
             document
          @@ -553,7 +550,7 @@ function bindListActions(): void {
               moveFile: path => openMove([path]),
               deleteFile: confirmDeleteFile,
               deleteFolder: confirmDeleteFolder,
          -    wireFolderDropTarget,
          +    registerFolderDropCallbacks,
             });
           }
           
          @@ -583,8 +580,11 @@ function confirmDeleteFolder(path: string): void {
             );
           }
           
          -function wireFolderDropTarget(el: HTMLElement, folderPath: string): void {
          -  wireDropTarget(el, folderPath, {
          +function registerFolderDropCallbacks(
          +  el: HTMLElement,
          +  folderPath: string
          +): void {
          +  registerFolderDropTarget(el, folderPath, {
               currentPath: () => currentPath,
               endFileDrag,
               upload: (dataTransfer, path) =>
          @@ -878,7 +878,7 @@ function endFileDrag(): void {
             document.body.classList.remove('dragging-files');
           }
           
          -function wireUi(): void {
          +function registerUiHandlers(): void {
             $('new-folder').addEventListener('click', openNewFolder);
             $('upload').addEventListener('click', () => {
               if (!uploading) $('file-input').click();
          @@ -1113,12 +1113,31 @@ function scheduleRefresh(): void {
             });
           }
           
          +function registerRowCallbacks(connection: DbConnection): void {
          +  connection.db.myFolders.onInsert(scheduleRefresh);
          +  connection.db.myFolders.onUpdate(scheduleRefresh);
          +  connection.db.myFolders.onDelete(scheduleRefresh);
          +  connection.db.myFileSummaries.onInsert(scheduleRefresh);
          +  connection.db.myFileSummaries.onUpdate(scheduleRefresh);
          +  connection.db.myFileSummaries.onDelete(scheduleRefresh);
          +}
          +
          +function subscribeToTables(connection: DbConnection): void {
          +  connection
          +    .subscriptionBuilder()
          +    .onApplied(() => refreshData())
          +    .onError((ctx: ErrorContext) =>
          +      console.error('subscription error', ctx.event)
          +    )
          +    .subscribe([tables.myFolders, tables.myFileSummaries]);
          +}
          +
           async function main(): Promise {
          -  wireUi();
          +  registerUiHandlers();
             render();
             let config: ServerConfig;
             try {
          -    config = await loadConfig();
          +    config = await loadServerConfig();
               try {
                 conn = await connect(config);
               } catch (err) {
          @@ -1133,20 +1152,8 @@ async function main(): Promise {
               return;
             }
           
          -  conn
          -    .subscriptionBuilder()
          -    .onApplied(() => refreshData())
          -    .onError((ctx: ErrorContext) =>
          -      console.error('subscription error', ctx.event)
          -    )
          -    .subscribe([tables.myFolders, tables.myFileSummaries]);
          -
          -  conn.db.myFolders.onInsert(scheduleRefresh);
          -  conn.db.myFolders.onUpdate(scheduleRefresh);
          -  conn.db.myFolders.onDelete(scheduleRefresh);
          -  conn.db.myFileSummaries.onInsert(scheduleRefresh);
          -  conn.db.myFileSummaries.onUpdate(scheduleRefresh);
          -  conn.db.myFileSummaries.onDelete(scheduleRefresh);
          +  registerRowCallbacks(conn);
          +  subscribeToTables(conn);
           
             window.vault = vault;
           }
          diff --git a/spacetime-files-ts/example/src/drop-target.ts b/spacetime-files-ts/example/src/drop-target.ts
          index 847c1acdc41..07c4dba514c 100644
          --- a/spacetime-files-ts/example/src/drop-target.ts
          +++ b/spacetime-files-ts/example/src/drop-target.ts
          @@ -7,7 +7,7 @@ export interface FolderDropServices {
             move(paths: string[], folderPath: string): Promise;
           }
           
          -export function wireFolderDropTarget(
          +export function registerFolderDropTarget(
             element: HTMLElement,
             folderPath: string,
             services: FolderDropServices
          diff --git a/spacetime-files-ts/example/src/list-actions.ts b/spacetime-files-ts/example/src/list-actions.ts
          index 02805ba0ce1..3e5889d4372 100644
          --- a/spacetime-files-ts/example/src/list-actions.ts
          +++ b/spacetime-files-ts/example/src/list-actions.ts
          @@ -21,7 +21,7 @@ export interface ListActionServices {
             moveFile(path: string): void;
             deleteFile(path: string): void;
             deleteFolder(path: string): void;
          -  wireFolderDropTarget(element: HTMLElement, path: string): void;
          +  registerFolderDropCallbacks(element: HTMLElement, path: string): void;
           }
           
           export function bindListActions(
          @@ -152,6 +152,6 @@ export function bindListActions(
             list
               .querySelectorAll('[data-drop-folder]')
               .forEach(row =>
          -      services.wireFolderDropTarget(row, row.dataset.dropFolder!)
          +      services.registerFolderDropCallbacks(row, row.dataset.dropFolder!)
               );
           }
          diff --git a/spacetime-files-ts/example/src/utils.ts b/spacetime-files-ts/example/src/utils.ts
          index 9fa499e3298..beab836ad4a 100644
          --- a/spacetime-files-ts/example/src/utils.ts
          +++ b/spacetime-files-ts/example/src/utils.ts
          @@ -3,8 +3,8 @@ import type { Timestamp } from 'spacetimedb';
           export type Visibility = 'owner' | 'public';
           
           export interface ServerConfig {
          -  stdbUri: string;
          -  appDatabase: string;
          +  spacetimeUri: string;
          +  databaseName: string;
           }
           
           // Persisted token = same identity (and files) across reloads.
          diff --git a/spacetime-grid-ts/README.md b/spacetime-grid-ts/README.md
          index 4a813431d78..457ddff719e 100644
          --- a/spacetime-grid-ts/README.md
          +++ b/spacetime-grid-ts/README.md
          @@ -57,6 +57,8 @@ The generated client calls the host procedure, then subscribes to the host's
           caller-scoped grid views:
           
           ```ts
          +import { tables } from './module_bindings';
          +
           const gridId = await conn.procedures.createPlayerGrid({
             name: 'Arena',
             kind: 'square',
          @@ -68,7 +70,7 @@ const gridId = await conn.procedures.createPlayerGrid({
             mode: 'owner',
           });
           
          -conn.subscriptionBuilder().subscribe(['SELECT * FROM my_grids']);
          +conn.subscriptionBuilder().subscribe([tables.myGrids]);
           ```
           
           ### Standalone table builders
          diff --git a/spacetime-grid-ts/example/server.ts b/spacetime-grid-ts/example/server.ts
          index 1060b4001cf..3c66f83c6bb 100644
          --- a/spacetime-grid-ts/example/server.ts
          +++ b/spacetime-grid-ts/example/server.ts
          @@ -146,13 +146,13 @@ app.use('/auth', async (req, res) => {
           app.use(express.static(path.join(__dirname, 'public')));
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, app: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
             res.json({
          -    stdbUri: STDB_URI,
          -    appDatabase: DB_NAME,
          +    spacetimeUri: STDB_URI,
          +    databaseName: DB_NAME,
               auth: {
                 issuerUrl: AUTH_ISSUER_URL,
                 baseUrl: AUTH_BASE_URL,
          diff --git a/spacetime-grid-ts/example/src/app.ts b/spacetime-grid-ts/example/src/app.ts
          index 41902418258..999cfdf741b 100644
          --- a/spacetime-grid-ts/example/src/app.ts
          +++ b/spacetime-grid-ts/example/src/app.ts
          @@ -67,8 +67,8 @@ let globalSub: SubscriptionHandle | null = null;
           let matchSub: SubscriptionHandle | null = null;
           let activeMatchId: bigint | null = null;
           type ServerConfig = {
          -  stdbUri: string;
          -  appDatabase: string;
          +  spacetimeUri: string;
          +  databaseName: string;
             oauth?: {
               google?: boolean;
               github?: boolean;
          @@ -272,17 +272,17 @@ function setActiveMatch(matchId: bigint | null): void {
               ]);
           }
           
          -function registerRowCallbacks(conn: DbConnection): void {
          +function registerRowCallbacks(connection: DbConnection): void {
             const tableAccessors = [
          -    conn.db.myMatches,
          -    conn.db.myMatchParticipants,
          -    conn.db.myPlayerUnits,
          -    conn.db.unitType,
          -    conn.db.myGrids,
          -    conn.db.myGridEntities,
          -    conn.db.myCellStates,
          -    conn.db.actorDirectory,
          -    conn.db.lobbyOpenMatches,
          +    connection.db.myMatches,
          +    connection.db.myMatchParticipants,
          +    connection.db.myPlayerUnits,
          +    connection.db.unitType,
          +    connection.db.myGrids,
          +    connection.db.myGridEntities,
          +    connection.db.myCellStates,
          +    connection.db.actorDirectory,
          +    connection.db.lobbyOpenMatches,
             ];
             for (const t of tableAccessors) {
               t.onInsert(() => broadcastState());
          @@ -291,6 +291,22 @@ function registerRowCallbacks(conn: DbConnection): void {
             }
           }
           
          +function subscribeToTables(connection: DbConnection): SubscriptionHandle {
          +  return connection
          +    .subscriptionBuilder()
          +    .onApplied(() => broadcastState())
          +    .onError((ctx: ErrorContext) =>
          +      console.error('global sub error', ctx.event)
          +    )
          +    .subscribe([
          +      tables.myMatches,
          +      tables.myMatchParticipants,
          +      tables.unitType,
          +      tables.actorDirectory,
          +      tables.lobbyOpenMatches,
          +    ]);
          +}
          +
           async function bindSession(
             token: string,
             user: AuthUser,
          @@ -305,8 +321,8 @@ async function bindSession(
               broadcastConn('connecting');
               try {
                 const conn = await connect(
          -        serverCfg.stdbUri,
          -        serverCfg.appDatabase
          +        serverCfg.spacetimeUri,
          +        serverCfg.databaseName
                 );
                 currentConn = conn;
                 reconnectAttempt = 0;
          @@ -316,19 +332,7 @@ async function bindSession(
           
                 registerRowCallbacks(conn);
           
          -      globalSub = conn
          -        .subscriptionBuilder()
          -        .onApplied(() => broadcastState())
          -        .onError((ctx: ErrorContext) =>
          -          console.error('global sub error', ctx.event)
          -        )
          -        .subscribe([
          -          tables.myMatches,
          -          tables.myMatchParticipants,
          -          tables.unitType,
          -          tables.actorDirectory,
          -          tables.lobbyOpenMatches,
          -        ]);
          +      globalSub = subscribeToTables(conn);
           
                 // Re-open per-match subscription if a match was active before reconnect.
                 const previousActive = activeMatchId;
          diff --git a/spacetime-lobby-ts/README.md b/spacetime-lobby-ts/README.md
          index 58a4d1dfa88..a64d6b3d09b 100644
          --- a/spacetime-lobby-ts/README.md
          +++ b/spacetime-lobby-ts/README.md
          @@ -60,14 +60,16 @@ After generating bindings, a client joins through the host operation and reads
           match state through subscriptions:
           
           ```ts
          +import { tables } from './module_bindings';
          +
           await conn.reducers.findDuel({});
           
           conn
             .subscriptionBuilder()
             .subscribe([
          -    'SELECT * FROM my_lobby_tickets',
          -    'SELECT * FROM my_lobby_rooms',
          -    'SELECT * FROM my_lobby_room_seats',
          +    tables.myLobbyTickets,
          +    tables.myLobbyRooms,
          +    tables.myLobbyRoomSeats,
             ]);
           ```
           
          @@ -85,10 +87,10 @@ export {
             join_queue,
             join_ranked_queue,
             cancel_ticket,
          -  my_lobby_tickets,
          -  my_lobby_rooms,
          -  lobby_queue_summary,
          -  lobby_ranked_leaderboard,
          +  myLobbyTickets,
          +  myLobbyRooms,
          +  lobbyQueueSummary,
          +  lobbyRankedLeaderboard,
           } from '@spacetimedb/lobby';
           ```
           
          diff --git a/spacetime-lobby-ts/example/server.ts b/spacetime-lobby-ts/example/server.ts
          index 447b78e7755..617eec266cb 100644
          --- a/spacetime-lobby-ts/example/server.ts
          +++ b/spacetime-lobby-ts/example/server.ts
          @@ -32,11 +32,11 @@ app.use(express.json({ limit: '128kb' }));
           app.use(express.static(path.join(__dirname, 'public')));
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, database: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
          -  res.json({ stdbUri: STDB_URI, database: DB_NAME });
          +  res.json({ spacetimeUri: STDB_URI, databaseName: DB_NAME });
           });
           
           app.listen(PORT, HOST, () => {
          diff --git a/spacetime-lobby-ts/example/src/app.ts b/spacetime-lobby-ts/example/src/app.ts
          index fa3c90628cf..0b6e1dcc9bc 100644
          --- a/spacetime-lobby-ts/example/src/app.ts
          +++ b/spacetime-lobby-ts/example/src/app.ts
          @@ -290,7 +290,7 @@ function maneuversTable(): TableEvents {
           }
           
           function tokenKey(config: ServerConfig): string {
          -  return `${TOKEN_KEY_PREFIX}:${config.stdbUri}:${config.database}`;
          +  return `${TOKEN_KEY_PREFIX}:${config.spacetimeUri}:${config.databaseName}`;
           }
           
           function loadToken(config: ServerConfig): string | undefined {
          @@ -325,7 +325,7 @@ function isStaleTokenError(err: unknown): boolean {
             );
           }
           
          -async function loadConfig(): Promise {
          +async function loadServerConfig(): Promise {
             const r = await fetch('/api/config');
             if (!r.ok) throw new Error(`/api/config returned ${r.status}`);
             return (await r.json()) as ServerConfig;
          @@ -337,15 +337,15 @@ function connectOnce(
           ): Promise {
             return new Promise((resolve, reject) => {
               let builder = DbConnection.builder()
          -      .withUri(config.stdbUri)
          -      .withDatabaseName(config.database);
          +      .withUri(config.spacetimeUri)
          +      .withDatabaseName(config.databaseName);
               if (token) builder = builder.withToken(token);
               builder
          -      .onConnect((c, identity, token) => {
          -        conn = c;
          +      .onConnect((connection, identity, token) => {
          +        conn = connection;
                   me = identity.toHexString();
                   if (token) saveToken(config, token);
          -        resolve(c);
          +        resolve(connection);
                 })
                 .onDisconnect((_ctx, err) => {
                   showToast(err?.message ?? 'Disconnected.', 'error');
          @@ -892,7 +892,7 @@ function render(): void {
             setScreen(desiredScreen());
           }
           
          -function wireTables(): void {
          +function registerRowCallbacks(): void {
             const rerender = () => render();
             const sources = [
               profileTable(),
          @@ -917,7 +917,7 @@ function wireTables(): void {
             }
           }
           
          -function wireActions(): void {
          +function registerUiHandlers(): void {
             const moveShip = async (direction: -1 | 1) => {
               const index = shipClasses.indexOf(selectedShip);
               const next =
          @@ -1033,10 +1033,9 @@ function wireActions(): void {
             });
           }
           
          -async function run(): Promise {
          -  const config = await loadConfig();
          -  const c = await connect(config);
          -  c.subscriptionBuilder()
          +function subscribeToTables(connection: DbConnection): void {
          +  connection
          +    .subscriptionBuilder()
               .onApplied(() => render())
               .onError((ctx: ErrorContext) =>
                 console.error('subscription error', ctx.event)
          @@ -1057,13 +1056,19 @@ async function run(): Promise {
                 tables.myDuelRoundLogs,
                 tables.myDuelManeuvers,
               ]);
          -  wireTables();
          -  wireActions();
          +}
          +
          +async function main(): Promise {
          +  const config = await loadServerConfig();
          +  const connection = await connect(config);
          +  registerRowCallbacks();
          +  subscribeToTables(connection);
          +  registerUiHandlers();
             setupTooltip();
             showToast('Connected.');
             render();
           }
           
          -run().catch(err => {
          +main().catch(err => {
             showToast(errorMessage(err), 'error');
           });
          diff --git a/spacetime-lobby-ts/example/src/model.ts b/spacetime-lobby-ts/example/src/model.ts
          index a1f935ad5b1..9419927d70f 100644
          --- a/spacetime-lobby-ts/example/src/model.ts
          +++ b/spacetime-lobby-ts/example/src/model.ts
          @@ -1,6 +1,6 @@
           export interface ServerConfig {
          -  stdbUri: string;
          -  database: string;
          +  spacetimeUri: string;
          +  databaseName: string;
           }
           
           export type TableEvents = {
          diff --git a/spacetime-posthog-ts/example/server.ts b/spacetime-posthog-ts/example/server.ts
          index e13bf8fa26e..cc040d07426 100644
          --- a/spacetime-posthog-ts/example/server.ts
          +++ b/spacetime-posthog-ts/example/server.ts
          @@ -76,7 +76,7 @@ function connectAttempt(token: string | undefined): Promise {
             });
           }
           
          -async function connectStdb(): Promise {
          +async function connect(): Promise {
             const stored = loadServerToken(
               SERVER_TOKEN_PATH,
               process.env.STDB_SERVER_TOKEN
          @@ -206,13 +206,13 @@ app.use(express.json({ limit: '256kb' }));
           app.use(express.static(path.join(__dirname, 'public')));
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, database: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
             res.json({
          -    stdbUri: STDB_URI,
          -    database: DB_NAME,
          +    spacetimeUri: STDB_URI,
          +    databaseName: DB_NAME,
               posthogAppUrl: POSTHOG_PROJECT_API_KEY ? posthogAppUrl() : null,
             });
           });
          @@ -220,7 +220,7 @@ app.get('/api/config', (_req: Request, res: Response) => {
           (async () => {
             console.log(`[stdb] connecting to ${STDB_URI}/${DB_NAME} ...`);
             try {
          -    const connected = await connectStdb();
          +    const connected = await connect();
               stdb = connected.connection;
               grantServerIdentity({
                 spacetimeBin: SPACETIME_BIN,
          diff --git a/spacetime-posthog-ts/example/src/app.ts b/spacetime-posthog-ts/example/src/app.ts
          index 526188c4e88..a30300ec0e3 100644
          --- a/spacetime-posthog-ts/example/src/app.ts
          +++ b/spacetime-posthog-ts/example/src/app.ts
          @@ -16,8 +16,8 @@ import {
           } from '../spacetimedb/src/economy';
           
           interface ServerConfig {
          -  stdbUri: string;
          -  database: string;
          +  spacetimeUri: string;
          +  databaseName: string;
             posthogAppUrl?: string | null;
           }
           
          @@ -224,16 +224,16 @@ function connect(config: ServerConfig): Promise {
             const attempt = (token: string | null): Promise =>
               new Promise((resolve, reject) => {
                 let builder = DbConnection.builder()
          -        .withUri(config.stdbUri)
          -        .withDatabaseName(config.database)
          +        .withUri(config.spacetimeUri)
          +        .withDatabaseName(config.databaseName)
                   // Persist the token so this browser keeps its identity across reloads.
          -        .onConnect((c, _identity, tok) => {
          +        .onConnect((connection, _identity, token) => {
                     try {
          -            localStorage.setItem(TOKEN_KEY, tok);
          +            localStorage.setItem(TOKEN_KEY, token);
                     } catch {
                       /* ignore */
                     }
          -          resolve(c);
          +          resolve(connection);
                   })
                   .onDisconnect((_ctx, err) => {
                     stopSimulation();
          @@ -803,7 +803,7 @@ async function tickSimulation(): Promise {
             });
           }
           
          -function wireUi(): void {
          +function registerNavigationHandlers(): void {
             $('menuGrid').addEventListener('click', event => {
               const card = (event.target as HTMLElement).closest(
                 '[data-variant-id]'
          @@ -827,7 +827,7 @@ function guard(fn: () => Promise): () => Promise {
             };
           }
           
          -function wireActions(): void {
          +function registerActionHandlers(): void {
             $('runToggle').addEventListener('click', () => {
               running = !running;
               restartTimer();
          @@ -930,7 +930,7 @@ function wireActions(): void {
             });
           }
           
          -function wireTableEvents(): void {
          +function registerRowCallbacks(): void {
             const render = () => renderAll();
             const sources = [
               productsTable(),
          @@ -950,10 +950,33 @@ function wireTableEvents(): void {
             }
           }
           
          -async function run(): Promise {
          -  // Wire the chrome first so the menu and drawer are interactive immediately.
          -  wireUi();
          -  wireActions();
          +function subscribeToTables(connection: DbConnection): void {
          +  connection
          +    .subscriptionBuilder()
          +    .onApplied(() => {
          +      renderAll();
          +      showToast('Context Cafe ready.');
          +    })
          +    .onError((ctx: ErrorContext) =>
          +      console.error('subscription error', ctx.event)
          +    )
          +    .subscribe([
          +      tables.cafeProducts,
          +      tables.cafeVariants,
          +      tables.cafeScenarios,
          +      tables.cafeConfig,
          +      tables.cafeMetrics,
          +      tables.cafeEcon,
          +      tables.cafeQueue,
          +      tables.cafeRecentSessions,
          +      tables.cafeAnalyticsSummary,
          +    ]);
          +}
          +
          +async function main(): Promise {
          +  // Register the chrome handlers first so the menu and drawer work immediately.
          +  registerNavigationHandlers();
          +  registerActionHandlers();
           
             const config = await loadServerConfig();
           
          @@ -975,32 +998,12 @@ async function run(): Promise {
               console.error('init_session failed', err);
             }
           
          -  conn
          -    .subscriptionBuilder()
          -    .onApplied(() => {
          -      renderAll();
          -      showToast('Context Cafe ready.');
          -    })
          -    .onError((ctx: ErrorContext) =>
          -      console.error('subscription error', ctx.event)
          -    )
          -    .subscribe([
          -      tables.cafeProducts,
          -      tables.cafeVariants,
          -      tables.cafeScenarios,
          -      tables.cafeConfig,
          -      tables.cafeMetrics,
          -      tables.cafeEcon,
          -      tables.cafeQueue,
          -      tables.cafeRecentSessions,
          -      tables.cafeAnalyticsSummary,
          -    ]);
          -
          -  wireTableEvents();
          +  registerRowCallbacks();
          +  subscribeToTables(conn);
           
             renderAll();
           }
           
          -run().catch(err => {
          +main().catch(err => {
             showToast(err instanceof Error ? err.message : String(err), 'error');
           });
          diff --git a/spacetime-presence-ts/README.md b/spacetime-presence-ts/README.md
          index c6197c9e530..00843f582a0 100644
          --- a/spacetime-presence-ts/README.md
          +++ b/spacetime-presence-ts/README.md
          @@ -101,6 +101,8 @@ After generating bindings, send heartbeats through the host procedure and
           subscribe to the host's public or caller-scoped presence table:
           
           ```ts
          +import { tables } from './module_bindings';
          +
           await conn.procedures.heartbeat({
             scope: 'room:42',
             status: 'online',
          @@ -108,7 +110,7 @@ await conn.procedures.heartbeat({
           
           conn
             .subscriptionBuilder()
          -  .subscribe(["SELECT * FROM presence_entry WHERE scope = 'room:42'"]);
          +  .subscribe([tables.myPresenceEntries.where(row => row.scope.eq('room:42'))]);
           ```
           
           ## API
          diff --git a/spacetime-presence-ts/example/server.ts b/spacetime-presence-ts/example/server.ts
          index a63024708d1..cbedbfe365e 100644
          --- a/spacetime-presence-ts/example/server.ts
          +++ b/spacetime-presence-ts/example/server.ts
          @@ -148,13 +148,13 @@ app.use('/auth', proxyStdbRoute('/auth'));
           app.use('/files', proxyStdbRoute('/files'));
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, app: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
             res.json({
          -    stdbUri: STDB_URI,
          -    appDatabase: DB_NAME,
          +    spacetimeUri: STDB_URI,
          +    databaseName: DB_NAME,
               auth: {
                 issuerUrl: AUTH_ISSUER_URL,
                 baseUrl: AUTH_BASE_URL,
          diff --git a/spacetime-presence-ts/example/src/app.ts b/spacetime-presence-ts/example/src/app.ts
          index dc76064605a..d4c87428cb7 100644
          --- a/spacetime-presence-ts/example/src/app.ts
          +++ b/spacetime-presence-ts/example/src/app.ts
          @@ -93,8 +93,8 @@ declare global {
           }
           
           interface ServerConfig {
          -  stdbUri: string;
          -  appDatabase: string;
          +  spacetimeUri: string;
          +  databaseName: string;
           }
           
           interface AuthUser {
          @@ -356,12 +356,12 @@ function connect(cfg: ServerConfig): Promise {
             return new Promise((resolve, reject) => {
               const priorToken = loadStdbToken();
               DbConnection.builder()
          -      .withUri(cfg.stdbUri)
          -      .withDatabaseName(cfg.appDatabase)
          +      .withUri(cfg.spacetimeUri)
          +      .withDatabaseName(cfg.databaseName)
                 .withToken(priorToken)
          -      .onConnect((c, _identity, token) => {
          +      .onConnect((connection, _identity, token) => {
                   if (token) saveStdbToken(token);
          -        resolve(c);
          +        resolve(connection);
                 })
                 .onDisconnect((_ctx, err) => {
                   conn = null;
          @@ -374,8 +374,9 @@ function connect(cfg: ServerConfig): Promise {
             });
           }
           
          -function subscribeToTables(c: DbConnection): void {
          -  c.subscriptionBuilder()
          +function subscribeToTables(connection: DbConnection): void {
          +  connection
          +    .subscriptionBuilder()
               .onApplied(() => emitData())
               .onError((ctx: ErrorContext) =>
                 console.error('subscription error', ctx.event)
          @@ -398,21 +399,21 @@ function subscribeToTables(c: DbConnection): void {
               ]);
           }
           
          -function registerRowCallbacks(c: DbConnection): void {
          +function registerRowCallbacks(connection: DbConnection): void {
             const reRender = () => emitData();
             const tableAccessors = [
          -    c.db.myChatUsers,
          -    c.db.myRooms,
          -    c.db.myRoomMembers,
          -    c.db.myRoomMessages,
          -    c.db.myRoomMessageReactions,
          -    c.db.myRoomAttachments,
          -    c.db.myServerMembers,
          -    c.db.myMessageThreads,
          -    c.db.myThreadMessages,
          -    c.db.myRoomReadCursors,
          -    c.db.myPresenceEntries,
          -    c.db.myRateLimitStatus,
          +    connection.db.myChatUsers,
          +    connection.db.myRooms,
          +    connection.db.myRoomMembers,
          +    connection.db.myRoomMessages,
          +    connection.db.myRoomMessageReactions,
          +    connection.db.myRoomAttachments,
          +    connection.db.myServerMembers,
          +    connection.db.myMessageThreads,
          +    connection.db.myThreadMessages,
          +    connection.db.myRoomReadCursors,
          +    connection.db.myPresenceEntries,
          +    connection.db.myRateLimitStatus,
             ];
             for (const t of tableAccessors) {
               t.onInsert(reRender);
          @@ -420,9 +421,9 @@ function registerRowCallbacks(c: DbConnection): void {
               t.onDelete(reRender);
             }
           
          -  c.db.myServers.onInsert(reRender);
          -  c.db.myServers.onUpdate(reRender);
          -  c.db.myServers.onDelete((_ctx: EventContext, row: Server) => {
          +  connection.db.myServers.onInsert(reRender);
          +  connection.db.myServers.onUpdate(reRender);
          +  connection.db.myServers.onDelete((_ctx: EventContext, row: Server) => {
               if (activeServerId === row.id) {
                 activeServerId = null;
                 activeRoomId = null;
          @@ -441,14 +442,14 @@ function registerRowCallbacks(c: DbConnection): void {
               };
               emitAuth();
             };
          -  c.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) =>
          +  connection.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) =>
               syncUserFromRow(row)
             );
          -  c.db.myAuthUser.onUpdate(
          +  connection.db.myAuthUser.onUpdate(
               (_ctx: EventContext, _old: AuthUserRow, neu: AuthUserRow) =>
                 syncUserFromRow(neu)
             );
          -  c.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => {
          +  connection.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => {
               if (!authUser || row.userId !== authUser.userId) return;
               authUser = null;
               sessionExpiresAt = undefined;
          @@ -713,8 +714,8 @@ function derivePresenceSnapshot() {
             return { global, typingByRoom };
           }
           
          -async function initializeIdentity(c: DbConnection): Promise {
          -  const me = await c.procedures.whoami({});
          +async function initializeIdentity(connection: DbConnection): Promise {
          +  const me = await connection.procedures.whoami({});
             meHex = me.senderIdentityHex;
             const snap = derivePresenceSnapshot();
             window.dispatchEvent(
          @@ -734,14 +735,14 @@ async function main(): Promise {
             emitConn('connecting');
             if (!config) config = await loadServerConfig();
           
          -  const c = await connect(config);
          -  conn = c;
          +  const connection = await connect(config);
          +  conn = connection;
             reconnectAttempt = 0;
             emitConn('connected');
          -  registerRowCallbacks(c);
          -  subscribeToTables(c);
          +  registerRowCallbacks(connection);
          +  subscribeToTables(connection);
             installApi();
          -  await initializeIdentity(c);
          +  await initializeIdentity(connection);
             await restoreSession();
             window.dispatchEvent(new CustomEvent('chat:ready'));
           }
          diff --git a/spacetime-rate-limit-ts/example/server.ts b/spacetime-rate-limit-ts/example/server.ts
          index 978c2ba5743..69d238011b9 100644
          --- a/spacetime-rate-limit-ts/example/server.ts
          +++ b/spacetime-rate-limit-ts/example/server.ts
          @@ -27,11 +27,11 @@ app.use(
           );
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, app: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
          -  res.json({ stdbUri: STDB_URI, appDatabase: DB_NAME });
          +  res.json({ spacetimeUri: STDB_URI, databaseName: DB_NAME });
           });
           
           app.listen(PORT, HOST, () => {
          diff --git a/spacetime-rate-limit-ts/example/src/app.ts b/spacetime-rate-limit-ts/example/src/app.ts
          index 0ac5933f905..b5817a15549 100644
          --- a/spacetime-rate-limit-ts/example/src/app.ts
          +++ b/spacetime-rate-limit-ts/example/src/app.ts
          @@ -114,8 +114,8 @@ declare global {
           }
           
           interface ServerConfig {
          -  stdbUri: string;
          -  appDatabase: string;
          +  spacetimeUri: string;
          +  databaseName: string;
           }
           
           type TableEvents = {
          @@ -239,8 +239,8 @@ function openConnection(
               };
           
               const builder = DbConnection.builder()
          -      .withUri(cfg.stdbUri)
          -      .withDatabaseName(cfg.appDatabase)
          +      .withUri(cfg.spacetimeUri)
          +      .withDatabaseName(cfg.databaseName)
                 .onConnect((conn, identity, nextToken) =>
                   settle(() => {
                     currentIdentityHex =
          @@ -265,7 +265,7 @@ function openConnection(
           }
           
           async function connect(cfg: ServerConfig): Promise {
          -  const tokenKey = `${TOKEN_STORAGE_PREFIX}.${cfg.stdbUri}.${cfg.appDatabase}`;
          +  const tokenKey = `${TOKEN_STORAGE_PREFIX}.${cfg.spacetimeUri}.${cfg.databaseName}`;
             const token = window.localStorage.getItem(tokenKey) ?? undefined;
             try {
               return await openConnection(cfg, tokenKey, token);
          @@ -287,25 +287,30 @@ function scheduleReconnect(): void {
             reconnectTimer = setTimeout(() => {
               reconnectTimer = null;
               reconnectAttempt++;
          -    run().catch(err => {
          +    main().catch(err => {
                 broadcastConn('error', err instanceof Error ? err.message : String(err));
                 scheduleReconnect();
               });
             }, delay);
           }
           
          -function wireDataHandlers(conn: DbConnection): void {
          -  const db = conn.db as NamespacedDb;
          -  let subscriptionsApplied = false;
          -  const seenEventIds = new Set();
          +type TableSyncState = {
          +  subscriptionApplied: boolean;
          +  seenEventIds: Set;
          +};
           
          -  conn
          +function subscribeToTables(
          +  connection: DbConnection,
          +  state: TableSyncState
          +): void {
          +  const db = connection.db as NamespacedDb;
          +  connection
               .subscriptionBuilder()
               .onApplied(() => {
                 for (const row of db.reactorEvents.iter()) {
          -        seenEventIds.add(row.id.toString());
          +        state.seenEventIds.add(row.id.toString());
                 }
          -      subscriptionsApplied = true;
          +      state.subscriptionApplied = true;
                 broadcastState();
               })
               .onError((ctx: ErrorContext) =>
          @@ -319,14 +324,21 @@ function wireDataHandlers(conn: DbConnection): void {
                 tables.reactorShop,
                 tables.rateLimitDemoConfig,
               ]);
          +}
           
          +function registerRowCallbacks(
          +  connection: DbConnection,
          +  state: TableSyncState
          +): void {
          +  const db = connection.db as NamespacedDb;
             db.reactorState.onInsert(() => broadcastState());
             db.reactorState.onUpdate(() => broadcastState());
             db.reactorState.onDelete(() => broadcastState());
             db.reactorEvents.onInsert((_ctx, row) => {
               const id = row.id.toString();
          -    const isNewLiveEvent = subscriptionsApplied && !seenEventIds.has(id);
          -    seenEventIds.add(id);
          +    const isNewLiveEvent =
          +      state.subscriptionApplied && !state.seenEventIds.has(id);
          +    state.seenEventIds.add(id);
               if (isNewLiveEvent) broadcastEventInsert(row);
               broadcastState();
             });
          @@ -379,7 +391,7 @@ function installReactorActions(): ReactorActions {
             return actions;
           }
           
          -async function run(): Promise {
          +async function main(): Promise {
             window.reactor = undefined;
             broadcastConn('connecting');
             if (!serverConfig) {
          @@ -389,7 +401,12 @@ async function run(): Promise {
               const conn = await connect(serverConfig);
               currentConn = conn;
               reconnectAttempt = 0;
          -    wireDataHandlers(conn);
          +    const tableSyncState: TableSyncState = {
          +      subscriptionApplied: false,
          +      seenEventIds: new Set(),
          +    };
          +    registerRowCallbacks(conn, tableSyncState);
          +    subscribeToTables(conn, tableSyncState);
               const reactor = installReactorActions();
               window.dispatchEvent(new CustomEvent('reactor:ready'));
               broadcastConn('connected');
          @@ -403,7 +420,7 @@ async function run(): Promise {
             }
           }
           
          -run().catch(err => {
          +main().catch(err => {
             console.error('reactor connection failed', err);
             broadcastConn('error', err instanceof Error ? err.message : String(err));
             scheduleReconnect();
          diff --git a/spacetime-resend-ts/example/server.ts b/spacetime-resend-ts/example/server.ts
          index fa93b393e52..019f419248c 100644
          --- a/spacetime-resend-ts/example/server.ts
          +++ b/spacetime-resend-ts/example/server.ts
          @@ -90,7 +90,7 @@ function connectAttempt(token: string | undefined): Promise {
             });
           }
           
          -async function connectStdb(): Promise {
          +async function connect(): Promise {
             const stored = loadServerToken(
               SERVER_TOKEN_PATH,
               process.env.STDB_SERVER_TOKEN
          @@ -125,13 +125,13 @@ app.use(express.json({ limit: '512kb' }));
           app.use(express.static(path.join(__dirname, 'public')));
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, database: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
             res.json({
          -    stdbUri: STDB_URI,
          -    database: DB_NAME,
          +    spacetimeUri: STDB_URI,
          +    databaseName: DB_NAME,
               resendConfigured,
               defaultFrom: DEFAULT_FROM,
               allowedRecipients: ALLOWED_RECIPIENTS,
          @@ -191,7 +191,7 @@ async function bootstrapResendConfig(): Promise {
           (async () => {
             console.log(`[stdb] connecting to ${STDB_URI}/${DB_NAME} ...`);
             try {
          -    const connected = await connectStdb();
          +    const connected = await connect();
               stdb = connected.connection;
               grantServerIdentity({
                 spacetimeBin: SPACETIME_BIN,
          diff --git a/spacetime-resend-ts/example/src/app.ts b/spacetime-resend-ts/example/src/app.ts
          index 8e5ad1f14f2..2f1dec1bd09 100644
          --- a/spacetime-resend-ts/example/src/app.ts
          +++ b/spacetime-resend-ts/example/src/app.ts
          @@ -46,8 +46,8 @@ type SendResult = {
           };
           
           type ServerConfig = {
          -  stdbUri: string;
          -  database: string;
          +  spacetimeUri: string;
          +  databaseName: string;
             resendConfigured: boolean;
             defaultFrom: string;
             allowedRecipients: string[];
          @@ -159,8 +159,8 @@ async function loadServerConfig(): Promise {
           async function connect(config: ServerConfig): Promise {
             return new Promise((resolve, reject) => {
               DbConnection.builder()
          -      .withUri(config.stdbUri)
          -      .withDatabaseName(config.database)
          +      .withUri(config.spacetimeUri)
          +      .withDatabaseName(config.databaseName)
                 .onConnect(c => resolve(c))
                 .onDisconnect((_ctx, err) => {
                   showError(`Disconnected: ${err?.message ?? 'connection lost'}`);
          @@ -487,7 +487,7 @@ function scheduleRender() {
             }, 0);
           }
           
          -function wireTable(name: string) {
          +function registerTableCallbacks(name: string): void {
             const accessor = table(name);
             if (!accessor) throw new Error(`missing table accessor: ${name}`);
             accessor.onInsert(() => scheduleRender());
          @@ -495,12 +495,14 @@ function wireTable(name: string) {
             accessor.onDelete(() => scheduleRender());
           }
           
          -function wireDataHandlers() {
          +function registerRowCallbacks(): void {
             for (const name of ['myDispatchEmails', 'myDispatchDeliveryEvents']) {
          -    wireTable(name);
          +    registerTableCallbacks(name);
             }
          +}
           
          -  conn!
          +function subscribeToTables(connection: DbConnection): void {
          +  connection
               .subscriptionBuilder()
               .onApplied((_ctx: SubscriptionEventContext) => {
                 render();
          @@ -602,7 +604,7 @@ async function submitCompose() {
             }
           }
           
          -function wireActions() {
          +function registerUiHandlers(): void {
             $('compose-form').addEventListener('submit', event => {
               event.preventDefault();
               submitCompose().catch(err => {
          @@ -656,12 +658,13 @@ function wireActions() {
           }
           
           async function main() {
          -  wireActions();
          +  registerUiHandlers();
             currentConfig = await loadServerConfig();
             const recipientInput = $('to-input') as HTMLInputElement;
             recipientInput.value = currentConfig.allowedRecipients[0] ?? '';
             conn = await connect(currentConfig);
          -  wireDataHandlers();
          +  registerRowCallbacks();
          +  subscribeToTables(conn);
           }
           
           main().catch(err => {
          diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts
          index aa30bb18637..54692d367e8 100644
          --- a/spacetime-stripe-ts/example/server.ts
          +++ b/spacetime-stripe-ts/example/server.ts
          @@ -143,7 +143,7 @@ function connectAttempt(token: string | undefined): Promise {
             });
           }
           
          -async function connectStdb(): Promise {
          +async function connect(): Promise {
             const stored = loadServerToken(
               SERVER_TOKEN_PATH,
               process.env.STDB_SERVER_TOKEN
          @@ -205,14 +205,14 @@ function staticOptions() {
           }
           
           app.get('/api/health', (_req: Request, res: Response) => {
          -  res.json({ ok: true, database: DB_NAME });
          +  res.json({ ok: true, databaseName: DB_NAME });
           });
           
           app.get('/api/config', (_req: Request, res: Response) => {
             const envStripeSecret = process.env.STRIPE_SECRET_KEY?.trim() ?? '';
             res.json({
          -    stdbUri: STDB_URI,
          -    database: DB_NAME,
          +    spacetimeUri: STDB_URI,
          +    databaseName: DB_NAME,
               hasStripeSecretKey: envStripeSecret.length > 0,
               stripeConfigured,
               adminEndpointsEnabled: false,
          @@ -356,7 +356,7 @@ async function seedCatalogIfEmpty(conn: DbConnection): Promise {
           (async () => {
             console.log(`[stdb] connecting to ${STDB_URI} (database=${DB_NAME}) ...`);
             try {
          -    const connected = await connectStdb();
          +    const connected = await connect();
               stdb = connected.connection;
               grantServerIdentity({
                 spacetimeBin: SPACETIME_BIN,
          diff --git a/spacetime-stripe-ts/example/src/app.ts b/spacetime-stripe-ts/example/src/app.ts
          index c82a08b1ad6..4b84b365aee 100644
          --- a/spacetime-stripe-ts/example/src/app.ts
          +++ b/spacetime-stripe-ts/example/src/app.ts
          @@ -50,8 +50,8 @@ type StoreProductRow = {
           };
           
           interface ServerConfig {
          -  stdbUri: string;
          -  database: string;
          +  spacetimeUri: string;
          +  databaseName: string;
           }
           
           const products = new Map();
          @@ -109,15 +109,15 @@ async function loadServerConfig(): Promise {
             return (await r.json()) as ServerConfig;
           }
           
          -function connectApp(config: ServerConfig): Promise {
          +function connect(config: ServerConfig): Promise {
             return new Promise((resolve, reject) => {
               const timeout = window.setTimeout(
          -      () => reject(new Error(`Timed out connecting to ${config.stdbUri}`)),
          +      () => reject(new Error(`Timed out connecting to ${config.spacetimeUri}`)),
                 10000
               );
               DbConnection.builder()
          -      .withUri(config.stdbUri)
          -      .withDatabaseName(config.database)
          +      .withUri(config.spacetimeUri)
          +      .withDatabaseName(config.databaseName)
                 .withCompression('none')
                 .onConnect(c => {
                   window.clearTimeout(timeout);
          @@ -158,32 +158,59 @@ function isRecord(value: unknown): value is Record {
             return typeof value === 'object' && value !== null && !Array.isArray(value);
           }
           
          +function registerRowCallbacks(connection: DbConnection): void {
          +  connection.db.storeProduct.onInsert(
          +    (_ctx: EventContext, row: StoreProductRow) => {
          +      products.set(row.productId, row);
          +      broadcastCatalog();
          +    }
          +  );
          +  connection.db.storeProduct.onUpdate(
          +    (_ctx: EventContext, _oldRow: StoreProductRow, row: StoreProductRow) => {
          +      products.set(row.productId, row);
          +      broadcastCatalog();
          +    }
          +  );
          +  connection.db.storeProduct.onDelete(
          +    (_ctx: EventContext, row: StoreProductRow) => {
          +      products.delete(row.productId);
          +      broadcastCatalog();
          +    }
          +  );
          +}
          +
          +function subscribeToTables(connection: DbConnection): void {
          +  connection
          +    .subscriptionBuilder()
          +    .onApplied(() => {
          +      products.clear();
          +      for (const row of connection.db.storeProduct.iter() as Iterable) {
          +        products.set(row.productId, row);
          +      }
          +      broadcastCatalog();
          +      updateConnState('connected');
          +      window.dispatchEvent(new CustomEvent('stdb:ready'));
          +    })
          +    .onError((ctx: ErrorContext) => {
          +      console.error('catalog sub error', ctx.event);
          +      updateConnState('error', String(ctx.event));
          +    })
          +    .subscribe([tables.storeProduct]);
          +}
          +
           async function main() {
             updateConnState('connecting');
             let conn: DbConnection;
             try {
               const config = await loadServerConfig();
          -    conn = await connectApp(config);
          +    conn = await connect(config);
             } catch (err) {
               console.error('STDB connect failed:', err);
               updateConnState('error', err instanceof Error ? err.message : String(err));
               return;
             }
           
          -  conn.db.storeProduct.onInsert((_ctx: EventContext, row: StoreProductRow) => {
          -    products.set(row.productId, row);
          -    broadcastCatalog();
          -  });
          -  conn.db.storeProduct.onUpdate(
          -    (_ctx: EventContext, _o: StoreProductRow, n: StoreProductRow) => {
          -      products.set(n.productId, n);
          -      broadcastCatalog();
          -    }
          -  );
          -  conn.db.storeProduct.onDelete((_ctx: EventContext, row: StoreProductRow) => {
          -    products.delete(row.productId);
          -    broadcastCatalog();
          -  });
          +  registerRowCallbacks(conn);
           
             window.stdb = {
               getOrCreateCustomer: args => api('/api/customer', args),
          @@ -200,22 +227,7 @@ async function main() {
                 ),
             };
           
          -  conn
          -    .subscriptionBuilder()
          -    .onApplied(() => {
          -      products.clear();
          -      for (const row of conn.db.storeProduct.iter() as Iterable) {
          -        products.set(row.productId, row);
          -      }
          -      broadcastCatalog();
          -      updateConnState('connected');
          -      window.dispatchEvent(new CustomEvent('stdb:ready'));
          -    })
          -    .onError((ctx: ErrorContext) => {
          -      console.error('catalog sub error', ctx.event);
          -      updateConnState('error', String(ctx.event));
          -    })
          -    .subscribe([tables.storeProduct]);
          +  subscribeToTables(conn);
           }
           
           main();
          
          From b74999b2aa9894317234d5582398ec45819b65cd Mon Sep 17 00:00:00 2001
          From: bradleyshep 
          Date: Wed, 26 Aug 2026 10:31:41 -0400
          Subject: [PATCH 20/33] Deduplicate shared example authentication UI
          
          ---
           .gitignore                                    |   2 +
           pnpm-lock.yaml                                |  30 ++
           spacetime-auth-ts/example/package.json        |   1 +
           spacetime-auth-ts/example/public/index.html   |  92 +---
           spacetime-auth-ts/example/public/styles.css   | 175 -------
           spacetime-auth-ts/example/public/ui.js        | 164 -------
           spacetime-auth-ts/example/src/app.ts          |  34 +-
           spacetime-example-ui-ts/package.json          |  30 ++
           spacetime-example-ui-ts/scripts/auth.test.ts  | 139 ++++++
           spacetime-example-ui-ts/src/auth-panel.ts     | 448 ++++++++++++++++++
           spacetime-example-ui-ts/src/icons.ts          |  75 +++
           spacetime-example-ui-ts/src/index.ts          |  11 +
           spacetime-example-ui-ts/src/styles/index.css  | 253 ++++++++++
           spacetime-example-ui-ts/tsconfig.json         |  13 +
           spacetime-grid-ts/example/package.json        |   1 +
           spacetime-grid-ts/example/public/index.html   |  96 +---
           spacetime-grid-ts/example/public/styles.css   | 176 +------
           spacetime-grid-ts/example/public/ui.js        |  98 ----
           spacetime-grid-ts/example/src/app.ts          |  40 +-
           spacetime-presence-ts/example/package.json    |   1 +
           .../example/public/index.html                 | 146 +-----
           .../example/public/styles.css                 | 201 --------
           spacetime-presence-ts/example/public/ui.js    | 161 +------
           spacetime-presence-ts/example/src/app.ts      | 103 +++-
           24 files changed, 1174 insertions(+), 1316 deletions(-)
           create mode 100644 spacetime-example-ui-ts/package.json
           create mode 100644 spacetime-example-ui-ts/scripts/auth.test.ts
           create mode 100644 spacetime-example-ui-ts/src/auth-panel.ts
           create mode 100644 spacetime-example-ui-ts/src/icons.ts
           create mode 100644 spacetime-example-ui-ts/src/index.ts
           create mode 100644 spacetime-example-ui-ts/src/styles/index.css
           create mode 100644 spacetime-example-ui-ts/tsconfig.json
          
          diff --git a/.gitignore b/.gitignore
          index 3cb5bce0458..4e591eb8d48 100644
          --- a/.gitignore
          +++ b/.gitignore
          @@ -209,6 +209,8 @@ __pycache__/
           spacetime-*-ts/example/.stdb-server-token
           spacetime-*-ts/example/public/app.js
           spacetime-*-ts/example/public/app.js.map
          +spacetime-*-ts/example/public/app.css
          +spacetime-*-ts/example/public/app.css.map
           spacetime-*-ts/ts-codegen/
           
           /protobuf
          diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
          index 18b971d00d7..6d373b6f9dd 100644
          --- a/pnpm-lock.yaml
          +++ b/pnpm-lock.yaml
          @@ -544,6 +544,9 @@ importers:
           
             spacetime-auth-ts/example:
               dependencies:
          +      '@spacetimedb/example-ui':
          +        specifier: workspace:*
          +        version: link:../../spacetime-example-ui-ts
                 dotenv:
                   specifier: ^16.4.7
                   version: 16.6.1
          @@ -712,6 +715,27 @@ importers:
                   specifier: ^5.9.3
                   version: 5.9.3
           
          +  spacetime-example-ui-ts:
          +    devDependencies:
          +      '@types/node':
          +        specifier: ^22.10.2
          +        version: 22.18.0
          +      eslint:
          +        specifier: ^9.17.0
          +        version: 9.33.0(jiti@2.6.1)
          +      jsdom:
          +        specifier: ^26.1.0
          +        version: 26.1.0
          +      prettier:
          +        specifier: ^3.3.3
          +        version: 3.6.2
          +      typescript:
          +        specifier: ~5.6.2
          +        version: 5.6.3
          +      vitest:
          +        specifier: ^3.2.4
          +        version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2)
          +
             spacetime-files-ts:
               dependencies:
                 '@spacetimedb/crypto':
          @@ -807,6 +831,9 @@ importers:
           
             spacetime-grid-ts/example:
               dependencies:
          +      '@spacetimedb/example-ui':
          +        specifier: workspace:*
          +        version: link:../../spacetime-example-ui-ts
                 dotenv:
                   specifier: ^16.4.7
                   version: 16.6.1
          @@ -1008,6 +1035,9 @@ importers:
           
             spacetime-presence-ts/example:
               dependencies:
          +      '@spacetimedb/example-ui':
          +        specifier: workspace:*
          +        version: link:../../spacetime-example-ui-ts
                 dotenv:
                   specifier: ^16.4.7
                   version: 16.6.1
          diff --git a/spacetime-auth-ts/example/package.json b/spacetime-auth-ts/example/package.json
          index d500862b6f5..bad17f39900 100644
          --- a/spacetime-auth-ts/example/package.json
          +++ b/spacetime-auth-ts/example/package.json
          @@ -13,6 +13,7 @@
               "dev": "pnpm run build && tsx server.ts"
             },
             "dependencies": {
          +    "@spacetimedb/example-ui": "workspace:*",
               "dotenv": "^16.4.7",
               "express": "^4.21.2",
               "spacetimedb": "workspace:*"
          diff --git a/spacetime-auth-ts/example/public/index.html b/spacetime-auth-ts/example/public/index.html
          index a36026b2c71..8cbb9d6ffd8 100644
          --- a/spacetime-auth-ts/example/public/index.html
          +++ b/spacetime-auth-ts/example/public/index.html
          @@ -8,6 +8,7 @@
                 content="A SpacetimeDB authentication example with password and OAuth sign-in, sessions, profiles, and private notes."
               />
               SpacetimeDB Notes
          +    
               
             
             
          @@ -43,7 +44,7 @@
                         src="/assets/brand.svg"
                         alt="SpacetimeDB"
                     />
          -          

          Notes Test App

          +

          Notes Example

          @@ -114,96 +115,9 @@

          Notes Test App

          -
          -
          - -

          Welcome back

          -

          Sign in to continue.

          - -
          - - -
          - -
          or
          - -
          - - -
          - -
          - - -
          - - - -

          - Forgot password? -

          -

          - Don't have an account? - Sign up -

          -
          +
          diff --git a/spacetime-auth-ts/example/public/styles.css b/spacetime-auth-ts/example/public/styles.css index 353c6d2e7fd..b6a4b75c7c2 100644 --- a/spacetime-auth-ts/example/public/styles.css +++ b/spacetime-auth-ts/example/public/styles.css @@ -223,181 +223,6 @@ a:hover { gap: 14px; } -/* ============================================================ - Auth panel. Shared block across the auth-using test apps. - Uses STDB tokens (--color-*, --font-*, --radius-*). - Keep these rules in sync across apps. - ============================================================ */ -.auth-card { - width: 100%; - max-width: 380px; - background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); - border: 1px solid var(--color-shade4); - border-radius: var(--radius-lg); - padding: 28px; - display: flex; - flex-direction: column; - gap: 12px; -} -.auth-logo { - width: 56px; - height: auto; - margin: 0 auto 4px; - display: block; -} -.auth-card h1 { - font-family: var(--font-inter); - font-size: 18px; - font-weight: 700; - margin: 0; - text-align: center; - color: var(--color-n1); -} -.auth-sub { - font-family: var(--font-inter); - font-size: 13px; - color: var(--color-n4); - margin: 0 0 8px; - text-align: center; -} -.auth-oauth { - display: flex; - flex-direction: column; - gap: 8px; -} -.btn.oauth { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 10px 14px; - font-family: var(--font-inter); - font-size: 13px; - font-weight: 500; - background: var(--color-shade7); - border: 1px solid var(--color-shade4); - color: var(--color-n1); - border-radius: var(--radius-sm); - cursor: pointer; -} -.btn.oauth:hover:not(:disabled) { - background: var(--color-shade4); - border-color: var(--color-n4); -} -.btn.oauth svg { - flex-shrink: 0; - width: 16px; - height: 16px; -} -.btn.block { - width: 100%; - display: flex; - align-items: center; - justify-content: center; -} -.auth-divider { - display: flex; - align-items: center; - gap: 8px; - margin: 4px 0; - color: var(--color-n4); - font-size: 11px; - font-family: var(--font-ibm); - text-transform: uppercase; - letter-spacing: 0.08em; -} -.auth-divider::before, -.auth-divider::after { - content: ''; - flex: 1; - height: 1px; - background: var(--color-shade4); -} -.auth-field { - display: flex; - flex-direction: column; - gap: 4px; -} -.auth-field label { - font-family: var(--font-ibm); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--color-n4); -} -.auth-field input { - background: var(--color-shade6); - border: 1px solid var(--color-shade4); - color: var(--color-n1); - font-family: var(--font-inter); - font-size: 13px; - padding: 8px 10px; - border-radius: var(--radius-sm); - outline: none; -} -.auth-field input:focus { - border-color: var(--color-green); - box-shadow: 0 0 0 3px var(--color-green-20); -} -.auth-foot { - margin: 0; - text-align: center; - font-family: var(--font-inter); - font-size: 12px; - color: var(--color-n4); -} -.auth-foot a { - color: var(--color-green); - cursor: pointer; - text-decoration: none; - font-weight: 600; -} -.auth-foot a:hover { - text-decoration: underline; -} -.auth-card .btn.primary.block { - margin-top: 4px; -} -/* Lock down sizing so the card renders identically across apps - regardless of their per-app global input/.btn rules. */ -.auth-card { - width: 380px; - gap: 12px; -} -.auth-card .auth-logo { - width: 56px; - height: 56px; -} -.auth-card h1 { - font-size: 18px; - line-height: 24px; -} -.auth-card .auth-sub { - font-size: 13px; - line-height: 18px; -} -.auth-card .auth-field input, -.auth-card .btn { - height: 40px; - box-sizing: border-box; - width: 100%; - font-family: var(--font-inter); - font-size: 13px; - font-weight: 600; -} -.auth-card .auth-field input { - padding: 0 12px; -} -.auth-card .btn.oauth { - padding: 0 14px; -} -.auth-card .auth-field label { - line-height: 14px; -} -.auth-card .auth-foot { - font-size: 12px; - line-height: 18px; -} .divider { display: grid; grid-template-columns: 1fr auto 1fr; diff --git a/spacetime-auth-ts/example/public/ui.js b/spacetime-auth-ts/example/public/ui.js index a918e5689e7..218ed5272c1 100644 --- a/spacetime-auth-ts/example/public/ui.js +++ b/spacetime-auth-ts/example/public/ui.js @@ -31,20 +31,6 @@ function showToast(kind, msg, dur = 4500) { }, dur); } -// OAuth callback error surfacing. The submodule redirects to /?error=... -// on failure (state expired, denied, etc.). -(function checkOauthError() { - const params = new URLSearchParams(window.location.search); - const err = params.get('error'); - if (err) { - showToast('err', `OAuth: ${err}`, 8000); - params.delete('error'); - const qs = params.toString(); - const url = window.location.pathname + (qs ? '?' + qs : ''); - window.history.replaceState({}, '', url); - } -})(); - window.addEventListener('auth:conn', e => { const pill = $('conn-pill'); const text = $('conn-text'); @@ -59,22 +45,6 @@ window.addEventListener('auth:conn', e => { text.textContent = e.detail.detail || 'disconnected'; } }); -window.addEventListener('auth:server-config', e => { - const oauth = e.detail?.oauth || {}; - const google = $('oauth-google'); - const github = $('oauth-github'); - google.disabled = !oauth.google; - github.disabled = !oauth.github; - google.title = oauth.google - ? '' - : 'Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env'; - github.title = oauth.github - ? '' - : 'Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in .env'; - google.setAttribute('aria-disabled', String(!oauth.google)); - github.setAttribute('aria-disabled', String(!oauth.github)); -}); - function dismissBootSplash() { const splash = document.getElementById('bootSplash'); if (!splash) return; @@ -319,126 +289,6 @@ async function tryCall(btnId, fn, okMsg) { } } -let authMode = 'login'; // 'login' | 'signup' | 'forgot' | 'reset' -let resetToken = null; -function applyAuthMode() { - const isSignup = authMode === 'signup'; - const isForgot = authMode === 'forgot'; - const isReset = authMode === 'reset'; - $('auth-title').textContent = isSignup - ? 'Create account' - : isForgot - ? 'Reset your password' - : isReset - ? 'Set a new password' - : 'Sign in'; - $('auth-sub').textContent = isSignup - ? 'Email and password, 8+ chars.' - : isForgot - ? "Enter your email. We'll send a reset link if the account exists." - : isReset - ? 'Enter a new password.' - : 'Welcome back.'; - $('em-name-field').hidden = !isSignup; - document.querySelector('label[for=em-email]').parentElement.hidden = isReset; - document.querySelector('label[for=em-pass]').parentElement.hidden = isForgot; - $('em-pass').setAttribute( - 'autocomplete', - isSignup || isReset ? 'new-password' : 'current-password' - ); - $('em-pass').setAttribute( - 'placeholder', - isSignup || isReset ? 'min 8 chars' : '' - ); - $('em-btn').textContent = isSignup - ? 'Create account' - : isForgot - ? 'Send reset email' - : isReset - ? 'Reset password' - : 'Sign in'; - $('forgot-foot').hidden = isSignup || isForgot || isReset; - $('toggle-prompt').textContent = isSignup - ? 'Already have an account?' - : "Don't have an account?"; - $('toggle-link').textContent = - isForgot || isReset ? 'Back to sign in' : isSignup ? 'Sign in' : 'Sign up'; -} -$('toggle-link').addEventListener('click', () => { - if (authMode === 'forgot' || authMode === 'reset') { - authMode = 'login'; - } else { - authMode = authMode === 'login' ? 'signup' : 'login'; - } - applyAuthMode(); -}); -$('forgot-link').addEventListener('click', () => { - authMode = 'forgot'; - applyAuthMode(); -}); -// Block default form navigation; the existing em-btn click handler -// fires for both clicks and Enter-to-submit. -$('auth-form').addEventListener('submit', e => e.preventDefault()); - -$('em-btn').addEventListener('click', () => - tryCall('em-btn', async () => { - const email = $('em-email').value.trim(); - const password = $('em-pass').value; - if (authMode === 'signup') { - await window.auth.signup({ - email, - password, - name: $('em-name').value.trim() || undefined, - }); - showToast('ok', 'account created'); - } else if (authMode === 'forgot') { - await window.auth.forgotPassword(email); - showToast( - 'ok', - 'If the account exists, a reset link was sent. (Check STDB log in dev.)', - 7000 - ); - authMode = 'login'; - applyAuthMode(); - } else if (authMode === 'reset') { - if (!resetToken) throw new Error('missing_token'); - await window.auth.resetPassword(resetToken, password); - showToast('ok', 'password reset; sign in below'); - resetToken = null; - authMode = 'login'; - window.history.replaceState({}, '', '/'); - applyAuthMode(); - } else { - await window.auth.login({ email, password }); - showToast('ok', 'signed in'); - } - }) -); - -(function checkResetToken() { - if (window.location.pathname === '/auth/password/reset') { - const params = new URLSearchParams(window.location.search); - const token = params.get('token'); - if (token) { - resetToken = token; - authMode = 'reset'; - } - } -})(); -(function checkVerifyOk() { - const params = new URLSearchParams(window.location.search); - if (params.get('verified') === '1') { - showToast('ok', 'email verified', 5000); - params.delete('verified'); - const qs = params.toString(); - window.history.replaceState( - {}, - '', - window.location.pathname + (qs ? '?' + qs : '') - ); - } -})(); -applyAuthMode(); $('logout-btn').addEventListener('click', () => tryCall('logout-btn', () => window.auth.logout(), 'signed out') ); @@ -465,17 +315,3 @@ $('nt-btn').addEventListener('click', () => 'note saved' ) ); -$('oauth-google').addEventListener('click', () => { - if ($('oauth-google').disabled) { - showToast('err', 'Google OAuth is not configured', 4000); - return; - } - window.auth.oauthStart('google'); -}); -$('oauth-github').addEventListener('click', () => { - if ($('oauth-github').disabled) { - showToast('err', 'GitHub OAuth is not configured', 4000); - return; - } - window.auth.oauthStart('github'); -}); diff --git a/spacetime-auth-ts/example/src/app.ts b/spacetime-auth-ts/example/src/app.ts index 7bcbc521887..ead14d5db8f 100644 --- a/spacetime-auth-ts/example/src/app.ts +++ b/spacetime-auth-ts/example/src/app.ts @@ -1,3 +1,9 @@ +import { + authUrlState, + clearAuthResultParams, + mountAuthPanel, +} from '@spacetimedb/example-ui'; +import '@spacetimedb/example-ui/styles.css'; import { DbConnection, tables, @@ -133,7 +139,10 @@ async function loadServerConfig(): Promise { const r = await fetch('/api/config', { credentials: 'same-origin' }); if (!r.ok) throw new Error(`/api/config returned ${r.status}`); const cfg = (await r.json()) as ServerConfig; - dispatch('auth:server-config', cfg); + authPanel.setProviders({ + google: Boolean(cfg.oauth?.google), + github: Boolean(cfg.oauth?.github), + }); return cfg; } @@ -368,6 +377,29 @@ function setProfile(args: { name?: string; image?: string }) { conn.reducers.updateProfile({ name: args.name, image: args.image }); } +const authResult = authUrlState(window.location); +const authPanelRoot = document.getElementById('auth-panel'); +if (!authPanelRoot) throw new Error('missing_auth_panel'); +const authPanel = mountAuthPanel(authPanelRoot, { + productName: 'Notes', + actions: { + login, + signup, + forgotPassword, + resetPassword, + oauthStart, + }, + initialMode: authResult.mode, + resetToken: authResult.resetToken, +}); +if (authResult.oauthError) { + authPanel.showMessage('error', `OAuth: ${authResult.oauthError}`); +} +if (authResult.verified) { + authPanel.showMessage('success', 'Email verified.'); +} +clearAuthResultParams(window.location, window.history); + window.auth = { signup, login, diff --git a/spacetime-example-ui-ts/package.json b/spacetime-example-ui-ts/package.json new file mode 100644 index 00000000000..3c83e5f0cea --- /dev/null +++ b/spacetime-example-ui-ts/package.json @@ -0,0 +1,30 @@ +{ + "name": "@spacetimedb/example-ui", + "description": "Shared interface primitives for SpacetimeDB submodule examples.", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./styles.css": "./src/styles/index.css" + }, + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "eslint": "^9.17.0", + "jsdom": "^26.1.0", + "prettier": "^3.3.3", + "typescript": "~5.6.2", + "vitest": "^3.2.4" + } +} diff --git a/spacetime-example-ui-ts/scripts/auth.test.ts b/spacetime-example-ui-ts/scripts/auth.test.ts new file mode 100644 index 00000000000..baf33343274 --- /dev/null +++ b/spacetime-example-ui-ts/scripts/auth.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + authModeCopy, + authUrlState, + clearAuthResultParams, + mountAuthPanel, +} from '../src/auth-panel'; + +beforeEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); + +describe('authModeCopy', () => { + it('returns login copy for the selected product', () => { + expect(authModeCopy('login', 'Grid')).toEqual({ + title: 'Welcome to Grid', + subtitle: 'Sign in to continue.', + submit: 'Sign in', + showEmail: true, + showName: false, + showPassword: true, + showForgot: true, + togglePrompt: "Don't have an account?", + toggleText: 'Sign up', + }); + }); +}); + +describe('authUrlState', () => { + it('reads a password reset token', () => { + expect( + authUrlState({ + pathname: '/auth/password/reset', + search: '?token=reset-123', + }) + ).toEqual({ + mode: 'reset', + resetToken: 'reset-123', + oauthError: undefined, + verified: false, + }); + }); + + it('reads OAuth and verification results', () => { + expect( + authUrlState({ pathname: '/', search: '?error=denied&verified=1' }) + ).toEqual({ + mode: 'login', + resetToken: undefined, + oauthError: 'denied', + verified: true, + }); + }); +}); + +describe('clearAuthResultParams', () => { + it('removes callback results and retains unrelated parameters', () => { + let nextUrl = ''; + clearAuthResultParams( + { pathname: '/notes', search: '?error=denied&verified=1&tab=active' }, + { + replaceState: (_data, _unused, url) => { + nextUrl = String(url); + }, + } + ); + expect(nextUrl).toBe('/notes?tab=active'); + }); +}); + +describe('mountAuthPanel', () => { + it('submits password login through the supplied action', async () => { + const login = vi.fn(async () => undefined); + const root = document.createElement('div'); + document.body.append(root); + mountAuthPanel(root, { + productName: 'Grid', + actions: { + login, + signup: vi.fn(async () => undefined), + forgotPassword: vi.fn(async () => undefined), + resetPassword: vi.fn(async () => undefined), + oauthStart: vi.fn(), + }, + }); + + const email = root.querySelector('input[type="email"]'); + const password = root.querySelector( + 'input[type="password"]' + ); + const form = root.querySelector('form'); + expect(email).not.toBeNull(); + expect(password).not.toBeNull(); + expect(form).not.toBeNull(); + email!.value = 'user@example.com'; + password!.value = 'password-123'; + form!.requestSubmit(); + + await vi.waitFor(() => { + expect(login).toHaveBeenCalledWith({ + email: 'user@example.com', + password: 'password-123', + }); + }); + }); + + it('changes modes and enables configured OAuth providers', () => { + const oauthStart = vi.fn(); + const root = document.createElement('div'); + document.body.append(root); + const panel = mountAuthPanel(root, { + productName: 'Chat', + actions: { + login: vi.fn(async () => undefined), + signup: vi.fn(async () => undefined), + forgotPassword: vi.fn(async () => undefined), + resetPassword: vi.fn(async () => undefined), + oauthStart, + }, + }); + + const google = root.querySelector( + '[data-provider="google"]' + ); + const modeToggle = + root.querySelectorAll('.auth-link')[1]; + expect(google?.disabled).toBe(true); + modeToggle.click(); + expect(root.querySelector('h1')?.textContent).toBe('Create account'); + + panel.setProviders({ google: true }); + expect(google?.disabled).toBe(false); + google?.click(); + expect(oauthStart).toHaveBeenCalledWith('google'); + }); +}); diff --git a/spacetime-example-ui-ts/src/auth-panel.ts b/spacetime-example-ui-ts/src/auth-panel.ts new file mode 100644 index 00000000000..7718ea23e15 --- /dev/null +++ b/spacetime-example-ui-ts/src/auth-panel.ts @@ -0,0 +1,448 @@ +import { githubIcon, googleIcon, spacetimeMark } from './icons'; + +export type AuthMode = 'login' | 'signup' | 'forgot' | 'reset'; + +export type AuthProviderAvailability = { + google: boolean; + github: boolean; +}; + +export type AuthPanelActions = { + login(args: { email: string; password: string }): Promise; + signup(args: { + email: string; + password: string; + name?: string; + }): Promise; + forgotPassword(email: string): Promise; + resetPassword(token: string, newPassword: string): Promise; + oauthStart(provider: 'google' | 'github'): void; +}; + +export type AuthPanelOptions = { + productName: string; + actions: AuthPanelActions; + logoSrc?: string; + initialMode?: AuthMode; + resetToken?: string; +}; + +export type AuthPanelController = { + destroy(): void; + focus(): void; + setMode(mode: AuthMode, resetToken?: string): void; + setProviders(providers: Partial): void; + showMessage(kind: 'error' | 'success', message: string): void; +}; + +type AuthModeCopy = { + title: string; + subtitle: string; + submit: string; + showEmail: boolean; + showName: boolean; + showPassword: boolean; + showForgot: boolean; + togglePrompt: string; + toggleText: string; +}; + +export function authModeCopy( + mode: AuthMode, + productName: string +): AuthModeCopy { + switch (mode) { + case 'signup': + return { + title: 'Create account', + subtitle: `Create an account to continue to ${productName}.`, + submit: 'Create account', + showEmail: true, + showName: true, + showPassword: true, + showForgot: false, + togglePrompt: 'Already have an account?', + toggleText: 'Sign in', + }; + case 'forgot': + return { + title: 'Reset your password', + subtitle: + 'Enter your email. A reset link will be sent if the account exists.', + submit: 'Send reset email', + showEmail: true, + showName: false, + showPassword: false, + showForgot: false, + togglePrompt: '', + toggleText: 'Back to sign in', + }; + case 'reset': + return { + title: 'Set a new password', + subtitle: 'Enter a new password with at least eight characters.', + submit: 'Reset password', + showEmail: false, + showName: false, + showPassword: true, + showForgot: false, + togglePrompt: '', + toggleText: 'Back to sign in', + }; + case 'login': + return { + title: `Welcome to ${productName}`, + subtitle: 'Sign in to continue.', + submit: 'Sign in', + showEmail: true, + showName: false, + showPassword: true, + showForgot: true, + togglePrompt: "Don't have an account?", + toggleText: 'Sign up', + }; + } +} + +export function authUrlState(location: Pick): { + mode: AuthMode; + resetToken?: string; + oauthError?: string; + verified: boolean; +} { + const params = new URLSearchParams(location.search); + const resetToken = + location.pathname === '/auth/password/reset' + ? (params.get('token') ?? undefined) + : undefined; + return { + mode: resetToken ? 'reset' : 'login', + resetToken, + oauthError: params.get('error') ?? undefined, + verified: params.get('verified') === '1', + }; +} + +export function clearAuthResultParams( + location: Pick, + history: Pick +): void { + const params = new URLSearchParams(location.search); + const hadResult = params.has('error') || params.has('verified'); + if (!hadResult) return; + params.delete('error'); + params.delete('verified'); + const search = params.toString(); + history.replaceState( + {}, + '', + `${location.pathname}${search ? `?${search}` : ''}` + ); +} + +function element( + tag: K, + className?: string +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + return node; +} + +function field( + id: string, + labelText: string, + type: HTMLInputElement['type'], + autocomplete: HTMLInputElement['autocomplete'], + placeholder: string +): { wrapper: HTMLDivElement; input: HTMLInputElement } { + const wrapper = element('div', 'auth-field'); + const label = element('label'); + label.htmlFor = id; + label.textContent = labelText; + const input = element('input'); + input.id = id; + input.type = type; + input.autocomplete = autocomplete; + input.placeholder = placeholder; + wrapper.append(label, input); + return { wrapper, input }; +} + +function oauthButton( + provider: 'google' | 'github', + label: string +): HTMLButtonElement { + const button = element('button', 'btn oauth block'); + button.type = 'button'; + button.dataset.provider = provider; + button.append(provider === 'google' ? googleIcon() : githubIcon(), label); + return button; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function mountAuthPanel( + root: HTMLElement, + options: AuthPanelOptions +): AuthPanelController { + const abort = new AbortController(); + let mode = options.initialMode ?? 'login'; + let resetToken = options.resetToken; + let providers: AuthProviderAvailability = { google: false, github: false }; + root.classList.add('example-auth-panel'); + + const form = element('form', 'auth-card'); + + const logoLink = element('a'); + logoLink.href = 'https://spacetimedb.com'; + logoLink.target = '_blank'; + logoLink.rel = 'noopener noreferrer'; + logoLink.className = 'auth-logo-link'; + const logo = options.logoSrc + ? Object.assign(element('img', 'auth-logo'), { + src: options.logoSrc, + alt: 'SpacetimeDB', + }) + : spacetimeMark(); + logoLink.append(logo); + + const title = element('h1'); + const subtitle = element('p', 'auth-sub'); + const oauth = element('div', 'auth-oauth'); + const google = oauthButton('google', 'Continue with Google'); + const github = oauthButton('github', 'Continue with GitHub'); + oauth.append(google, github); + + const divider = element('div', 'auth-divider'); + const dividerText = element('span'); + dividerText.textContent = 'or'; + divider.append(dividerText); + + const email = field( + 'example-auth-email', + 'Email', + 'email', + 'email', + 'you@example.com' + ); + const name = field( + 'example-auth-name', + 'Name (optional)', + 'text', + 'name', + 'Display name' + ); + const password = field( + 'example-auth-password', + 'Password', + 'password', + 'current-password', + '' + ); + password.input.minLength = 8; + + const message = element('p', 'auth-message'); + message.setAttribute('role', 'status'); + message.setAttribute('aria-live', 'polite'); + message.hidden = true; + + const submit = element('button', 'btn primary block'); + submit.type = 'submit'; + + const forgotFoot = element('p', 'auth-foot'); + const forgot = element('button', 'auth-link'); + forgot.type = 'button'; + forgot.textContent = 'Forgot password?'; + forgotFoot.append(forgot); + + const toggleFoot = element('p', 'auth-foot'); + const togglePrompt = element('span'); + const toggle = element('button', 'auth-link'); + toggle.type = 'button'; + toggleFoot.append(togglePrompt, document.createTextNode(' '), toggle); + + form.append( + logoLink, + title, + subtitle, + oauth, + divider, + email.wrapper, + name.wrapper, + password.wrapper, + message, + submit, + forgotFoot, + toggleFoot + ); + root.replaceChildren(form); + + function showMessage(kind: 'error' | 'success', text: string): void { + message.className = `auth-message ${kind}`; + message.textContent = text; + message.hidden = false; + } + + function clearMessage(): void { + message.textContent = ''; + message.hidden = true; + } + + function applyMode(): void { + const copy = authModeCopy(mode, options.productName); + title.textContent = copy.title; + subtitle.textContent = copy.subtitle; + submit.textContent = copy.submit; + email.wrapper.hidden = !copy.showEmail; + email.input.required = copy.showEmail; + name.wrapper.hidden = !copy.showName; + password.wrapper.hidden = !copy.showPassword; + password.input.required = copy.showPassword; + password.input.autocomplete = + mode === 'login' ? 'current-password' : 'new-password'; + password.input.placeholder = mode === 'login' ? '' : 'Minimum 8 characters'; + forgotFoot.hidden = !copy.showForgot; + togglePrompt.textContent = copy.togglePrompt; + toggle.textContent = copy.toggleText; + clearMessage(); + } + + function applyProviders(): void { + for (const [button, enabled, envNames] of [ + [google, providers.google, 'GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET'], + [github, providers.github, 'GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET'], + ] as const) { + button.disabled = !enabled; + button.setAttribute('aria-disabled', String(!enabled)); + button.title = enabled ? '' : `Set ${envNames} in .env`; + } + } + + async function run(action: () => Promise): Promise { + clearMessage(); + submit.disabled = true; + form.setAttribute('aria-busy', 'true'); + try { + await action(); + } catch (error) { + showMessage('error', errorMessage(error)); + } finally { + submit.disabled = false; + form.removeAttribute('aria-busy'); + } + } + + form.addEventListener( + 'submit', + event => { + event.preventDefault(); + void run(async () => { + const emailValue = email.input.value.trim(); + const passwordValue = password.input.value; + if (mode === 'signup') { + await options.actions.signup({ + email: emailValue, + password: passwordValue, + name: name.input.value.trim() || undefined, + }); + return; + } + if (mode === 'forgot') { + await options.actions.forgotPassword(emailValue); + mode = 'login'; + applyMode(); + showMessage( + 'success', + 'If the account exists, a password reset link was sent.' + ); + return; + } + if (mode === 'reset') { + if (!resetToken) throw new Error('auth.missing_reset_token'); + await options.actions.resetPassword(resetToken, passwordValue); + resetToken = undefined; + mode = 'login'; + window.history.replaceState({}, '', '/'); + applyMode(); + showMessage( + 'success', + 'Password reset. Sign in with the new password.' + ); + return; + } + await options.actions.login({ + email: emailValue, + password: passwordValue, + }); + }); + }, + { signal: abort.signal } + ); + + forgot.addEventListener( + 'click', + () => { + mode = 'forgot'; + applyMode(); + email.input.focus(); + }, + { signal: abort.signal } + ); + + toggle.addEventListener( + 'click', + () => { + mode = + mode === 'forgot' || mode === 'reset' + ? 'login' + : mode === 'login' + ? 'signup' + : 'login'; + applyMode(); + email.input.focus(); + }, + { signal: abort.signal } + ); + + for (const button of [google, github]) { + button.addEventListener( + 'click', + () => { + const provider = button.dataset.provider as 'google' | 'github'; + if (!providers[provider]) { + showMessage('error', `${provider} OAuth is not configured.`); + return; + } + options.actions.oauthStart(provider); + }, + { signal: abort.signal } + ); + } + + applyMode(); + applyProviders(); + + return { + destroy() { + abort.abort(); + root.replaceChildren(); + root.classList.remove('example-auth-panel'); + }, + focus() { + (mode === 'reset' ? password.input : email.input).focus(); + }, + setMode(nextMode, nextResetToken) { + mode = nextMode; + resetToken = nextResetToken; + applyMode(); + }, + setProviders(nextProviders) { + providers = { ...providers, ...nextProviders }; + applyProviders(); + }, + showMessage, + }; +} diff --git a/spacetime-example-ui-ts/src/icons.ts b/spacetime-example-ui-ts/src/icons.ts new file mode 100644 index 00000000000..d8be3a7b3db --- /dev/null +++ b/spacetime-example-ui-ts/src/icons.ts @@ -0,0 +1,75 @@ +export function googleIcon(): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('aria-hidden', 'true'); + for (const [fill, d] of [ + [ + '#4285F4', + 'M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.56c2.08-1.92 3.28-4.74 3.28-8.09z', + ], + [ + '#34A853', + 'M12 23c2.97 0 5.46-.98 7.28-2.66l-3.56-2.76c-.98.66-2.24 1.06-3.72 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23z', + ], + [ + '#FBBC05', + 'M5.84 14.11A6.6 6.6 0 0 1 5.5 12c0-.73.13-1.44.34-2.11V7.05H2.18A11 11 0 0 0 1 12c0 1.78.43 3.46 1.18 4.95l3.66-2.84z', + ], + [ + '#EA4335', + 'M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.05l3.66 2.84C6.71 7.3 9.14 5.38 12 5.38z', + ], + ]) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('fill', fill); + path.setAttribute('d', d); + svg.append(path); + } + return svg; +} + +export function githubIcon(): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'currentColor'); + svg.setAttribute('aria-hidden', 'true'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute( + 'd', + 'M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56v-2c-3.2.7-3.87-1.36-3.87-1.36-.53-1.34-1.3-1.7-1.3-1.7-1.06-.72.08-.7.08-.7 1.17.08 1.79 1.2 1.79 1.2 1.04 1.78 2.73 1.26 3.4.96.11-.75.41-1.26.74-1.55-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.47.11-3.06 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.78 0c2.2-1.49 3.18-1.18 3.18-1.18.63 1.59.23 2.77.11 3.06.74.81 1.19 1.84 1.19 3.1 0 4.43-2.7 5.41-5.27 5.69.42.36.79 1.08.79 2.18v3.23c0 .31.21.67.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5z' + ); + svg.append(path); + return svg; +} + +export function spacetimeMark(): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 35 32'); + svg.setAttribute('role', 'img'); + svg.setAttribute('aria-label', 'SpacetimeDB'); + svg.classList.add('auth-logo'); + for (const [d, fillRule] of [ + [ + 'M28.8002 15.317C28.5535 9.53226 29.63 6.19046 35 0L24.2649 10.9106C26.8343 14.2552 26.6051 19.0995 23.5774 22.1767C20.5498 25.2538 15.7834 25.4867 12.4925 22.8754L10.5042 24.8962L10.5116 24.9024L7.35285 28.1361C9.73784 26.7321 13.4208 27.1349 15.6425 27.3779C16.2579 27.4452 16.7611 27.5003 17.0937 27.5013C20.1371 27.6534 23.2301 26.5483 25.5544 24.186C27.9465 21.7549 29.0284 18.4965 28.8002 15.317Z', + false, + ], + [ + 'M17.9063 4.49871C18.2389 4.49971 18.7421 4.55476 19.3575 4.62207C21.5792 4.86508 25.2622 5.26792 27.6472 3.86395L24.4884 7.0976L24.4958 7.10383L22.5075 9.12462C19.2166 6.51328 14.4502 6.74618 11.4226 9.82332C8.3949 12.9005 8.16574 17.7448 10.7351 21.0894L0 32C5.36996 25.8095 6.44651 22.4677 6.1998 16.683C5.97163 13.5035 7.05355 10.2451 9.44557 7.81402C11.7699 5.45167 14.8629 4.34657 17.9063 4.49871Z', + false, + ], + [ + 'M24.7486 16C24.7486 20.0687 21.5033 23.367 17.5 23.367C13.4967 23.367 10.2514 20.0687 10.2514 16C10.2514 11.9313 13.4967 8.63292 17.5 8.63292C21.5033 8.63292 24.7486 11.9313 24.7486 16ZM17.5 21.6C20.5752 21.6 23.0682 19.0928 23.0682 16C23.0682 12.9072 20.5752 10.4 17.5 10.4C14.4248 10.4 11.9318 12.9072 11.9318 16C11.9318 19.0928 14.4248 21.6 17.5 21.6Z', + true, + ], + ] as const) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + path.setAttribute('fill', '#D7D8D9'); + if (fillRule) { + path.setAttribute('fill-rule', 'evenodd'); + path.setAttribute('clip-rule', 'evenodd'); + } + svg.append(path); + } + return svg; +} diff --git a/spacetime-example-ui-ts/src/index.ts b/spacetime-example-ui-ts/src/index.ts new file mode 100644 index 00000000000..b23ec22f07d --- /dev/null +++ b/spacetime-example-ui-ts/src/index.ts @@ -0,0 +1,11 @@ +export { + authModeCopy, + authUrlState, + clearAuthResultParams, + mountAuthPanel, + type AuthMode, + type AuthPanelActions, + type AuthPanelController, + type AuthPanelOptions, + type AuthProviderAvailability, +} from './auth-panel'; diff --git a/spacetime-example-ui-ts/src/styles/index.css b/spacetime-example-ui-ts/src/styles/index.css new file mode 100644 index 00000000000..dd60f24604d --- /dev/null +++ b/spacetime-example-ui-ts/src/styles/index.css @@ -0,0 +1,253 @@ +:root { + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +.example-auth-panel { + width: min(380px, 100%); +} + +.auth-card { + box-sizing: border-box; + width: 100%; + padding: 28px; + display: flex; + flex-direction: column; + gap: 12px; + color: var(--color-n1); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); +} + +.auth-logo-link { + display: inline-flex; + align-self: center; +} + +.auth-logo { + width: 56px; + height: 56px; + display: block; +} + +.auth-card h1 { + margin: 0; + color: var(--color-n1); + font-family: var(--font-inter); + font-size: 18px; + line-height: 24px; + text-align: center; +} + +.auth-sub, +.auth-foot, +.auth-message { + margin: 0; + font-family: var(--font-inter); + font-size: 12px; + line-height: 18px; + text-align: center; +} + +.auth-sub, +.auth-foot { + color: var(--color-n4); +} + +.auth-sub { + margin-bottom: 8px; + font-size: 13px; +} + +.auth-oauth, +.auth-field { + display: flex; + flex-direction: column; +} + +.auth-oauth { + gap: 8px; +} + +.auth-field { + gap: 4px; +} + +.auth-field label { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + line-height: 14px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.auth-field input, +.auth-card .btn { + box-sizing: border-box; + width: 100%; + height: 40px; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; +} + +.auth-field input { + padding: 0 12px; + color: var(--color-n1); + background: var(--color-shade6); + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + outline: none; +} + +.auth-field input:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} + +.auth-card .btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0 14px; + border-radius: var(--radius-sm); + cursor: pointer; +} + +.auth-card .btn.oauth { + gap: 10px; + color: var(--color-n1); + background: var(--color-shade7); + border: 1px solid var(--color-shade4); +} + +.auth-card .btn.oauth:hover:not(:disabled) { + background: var(--color-shade4); + border-color: var(--color-n4); +} + +.auth-card .btn.oauth svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.auth-card .btn.primary { + margin-top: 4px; + color: var(--color-n8); + background: var(--color-n3); + border: 2px solid var(--color-n3); +} + +.auth-card .btn.primary:hover:not(:disabled) { + color: var(--color-n8); + background: var(--color-white); + border-color: var(--color-white); +} + +.auth-card .btn:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.auth-divider { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.auth-divider::before, +.auth-divider::after { + height: 1px; + flex: 1; + content: ''; + background: var(--color-shade4); +} + +.auth-link { + padding: 0; + color: var(--color-green); + font: inherit; + font-weight: 600; + background: none; + border: 0; + cursor: pointer; +} + +.auth-link:hover { + text-decoration: underline; +} + +.auth-message { + padding: 8px 10px; + border: 1px solid transparent; + border-radius: var(--radius-sm); +} + +.auth-message.error { + color: var(--color-orange); + background: #ff9e9e12; + border-color: #ff9e9e40; +} + +.auth-message.success { + color: var(--color-green); + background: var(--color-green-10); + border-color: var(--color-green-25); +} + +@media (max-width: 480px) { + .auth-card { + padding: 22px; + } +} diff --git a/spacetime-example-ui-ts/tsconfig.json b/spacetime-example-ui-ts/tsconfig.json new file mode 100644 index 00000000000..20fc11f9205 --- /dev/null +++ b/spacetime-example-ui-ts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +} diff --git a/spacetime-grid-ts/example/package.json b/spacetime-grid-ts/example/package.json index 5e32ebb1e4d..651d09e361c 100644 --- a/spacetime-grid-ts/example/package.json +++ b/spacetime-grid-ts/example/package.json @@ -14,6 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-grid-ts/example/public/index.html b/spacetime-grid-ts/example/public/index.html index dc4f2147d5a..aa7f884bc71 100644 --- a/spacetime-grid-ts/example/public/index.html +++ b/spacetime-grid-ts/example/public/index.html @@ -8,102 +8,12 @@ content="A turn-based tactics example using SpacetimeDB grid storage, pathfinding, range queries, and movement validation." /> SpacetimeDB Grid + -
          -
          - -

          Welcome to Grid

          -

          Sign in to continue.

          - -
          - - -
          - -
          or
          - -
          - - -
          - -
          - - -
          - -

          - Forgot password? -

          -

          - Don't have an account? - Sign up -

          -
          +
          @@ -120,7 +30,7 @@

          Welcome to Grid

          src="/assets/brand.svg" alt="SpacetimeDB" /> - Grid Test App + Grid Example
          diff --git a/spacetime-grid-ts/example/public/styles.css b/spacetime-grid-ts/example/public/styles.css index 329da8b731e..6b5f95f18b8 100644 --- a/spacetime-grid-ts/example/public/styles.css +++ b/spacetime-grid-ts/example/public/styles.css @@ -48,10 +48,7 @@ body { overflow: hidden; } -/* ============================================================ - Auth panel shared with the agents example. Keep the shared markup - and styles synchronized across both examples. - ============================================================ */ +/* Authentication shell */ .auth-shell { position: fixed; inset: 0; @@ -68,177 +65,6 @@ body { ), var(--color-shade7); } -.auth-card { - width: 100%; - max-width: 380px; - background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); - border: 1px solid var(--color-shade4); - border-radius: var(--radius-lg); - padding: 28px; - display: flex; - flex-direction: column; - gap: 12px; -} -.auth-logo { - width: 56px; - height: auto; - margin: 0 auto 4px; - display: block; -} -.auth-card h1 { - font-family: var(--font-inter); - font-size: 18px; - font-weight: 700; - margin: 0; - text-align: center; - color: var(--color-n1); -} -.auth-sub { - font-family: var(--font-inter); - font-size: 13px; - color: var(--color-n4); - margin: 0 0 8px; - text-align: center; -} -.auth-oauth { - display: flex; - flex-direction: column; - gap: 8px; -} -.btn.oauth { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 10px 14px; - font-family: var(--font-inter); - font-size: 13px; - font-weight: 500; - background: var(--color-shade7); - border: 1px solid var(--color-shade4); - color: var(--color-n1); - border-radius: var(--radius-sm); - cursor: pointer; -} -.btn.oauth:hover:not(:disabled) { - background: var(--color-shade4); - border-color: var(--color-n4); -} -.btn.oauth svg { - flex-shrink: 0; - width: 16px; - height: 16px; -} -.btn.block { - width: 100%; - display: flex; - align-items: center; - justify-content: center; -} -.auth-divider { - display: flex; - align-items: center; - gap: 8px; - margin: 4px 0; - color: var(--color-n4); - font-size: 11px; - font-family: var(--font-ibm); - text-transform: uppercase; - letter-spacing: 0.08em; -} -.auth-divider::before, -.auth-divider::after { - content: ''; - flex: 1; - height: 1px; - background: var(--color-shade4); -} -.auth-field { - display: flex; - flex-direction: column; - gap: 4px; -} -.auth-field label { - font-family: var(--font-ibm); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--color-n4); -} -.auth-field input { - background: var(--color-shade6); - border: 1px solid var(--color-shade4); - color: var(--color-n1); - font-family: var(--font-inter); - font-size: 13px; - padding: 8px 10px; - border-radius: var(--radius-sm); - outline: none; -} -.auth-field input:focus { - border-color: var(--color-green); - box-shadow: 0 0 0 3px var(--color-green-20); -} -.auth-foot { - margin: 0; - text-align: center; - font-family: var(--font-inter); - font-size: 12px; - color: var(--color-n4); -} -.auth-foot a { - color: var(--color-green); - cursor: pointer; - text-decoration: none; - font-weight: 600; -} -.auth-foot a:hover { - text-decoration: underline; -} -.auth-card .btn.primary.block { - margin-top: 4px; -} -/* Lock down sizing so the card renders identically across apps - regardless of their per-app global input/.btn rules. */ -.auth-card { - width: 380px; - gap: 12px; -} -.auth-card .auth-logo { - width: 56px; - height: 56px; -} -.auth-card h1 { - font-size: 18px; - line-height: 24px; -} -.auth-card .auth-sub { - font-size: 13px; - line-height: 18px; -} -.auth-card .auth-field input, -.auth-card .btn { - height: 40px; - box-sizing: border-box; - width: 100%; - font-family: var(--font-inter); - font-size: 13px; - font-weight: 600; -} -.auth-card .auth-field input { - padding: 0 12px; -} -.auth-card .btn.oauth { - padding: 0 14px; -} -.auth-card .auth-field label { - line-height: 14px; -} -.auth-card .auth-foot { - font-size: 12px; - line-height: 18px; -} - /* ============================================================ Buttons (spacetime-web canonical) ============================================================ */ diff --git a/spacetime-grid-ts/example/public/ui.js b/spacetime-grid-ts/example/public/ui.js index 67a62c79bd4..c3932600e88 100644 --- a/spacetime-grid-ts/example/public/ui.js +++ b/spacetime-grid-ts/example/public/ui.js @@ -27,104 +27,6 @@ function toast(kind, msg) { }, 3000); } -let authMode = 'login'; -function setAuthMode(m) { - authMode = m; - const title = $('auth-title'), - sub = $('auth-sub'), - submit = $('auth-submit'); - const togglePrompt = $('toggle-prompt'), - toggleLink = $('toggle-link'); - const forgotFoot = $('forgot-link').parentElement; - const nameField = $('auth-name-field'), - passField = $('auth-pass').closest('.auth-field'); - if (m === 'signup') { - title.textContent = 'Create an account'; - sub.textContent = 'Sign up to play.'; - submit.textContent = 'Create account'; - togglePrompt.textContent = 'Already have an account?'; - toggleLink.textContent = 'Sign in'; - forgotFoot.hidden = true; - nameField.hidden = false; - passField.hidden = false; - $('auth-pass').autocomplete = 'new-password'; - } else if (m === 'forgot') { - title.textContent = 'Reset password'; - sub.textContent = "Enter your email and we'll send a reset link."; - submit.textContent = 'Send reset link'; - togglePrompt.textContent = 'Remembered it?'; - toggleLink.textContent = 'Sign in'; - forgotFoot.hidden = true; - nameField.hidden = true; - passField.hidden = true; - } else { - title.textContent = 'Welcome to Grid'; - sub.textContent = 'Sign in to continue.'; - submit.textContent = 'Sign in'; - togglePrompt.textContent = "Don't have an account?"; - toggleLink.textContent = 'Sign up'; - forgotFoot.hidden = false; - nameField.hidden = true; - passField.hidden = false; - $('auth-pass').autocomplete = 'current-password'; - } -} -$('toggle-link').addEventListener('click', () => - setAuthMode(authMode === 'login' ? 'signup' : 'login') -); -$('forgot-link').addEventListener('click', () => setAuthMode('forgot')); -$('auth-form').addEventListener('submit', async e => { - e.preventDefault(); - if (!window.auth) return; - const email = $('auth-email').value.trim(); - const password = $('auth-pass').value; - $('auth-submit').disabled = true; - try { - if (authMode === 'signup') { - const name = $('auth-name').value.trim() || undefined; - await window.auth.signup({ email, password, name }); - } else if (authMode === 'forgot') { - await window.auth.forgotPassword(email); - toast('ok', 'Reset link sent (dev mailer logs to STDB console).'); - setAuthMode('login'); - } else { - await window.auth.login({ email, password }); - } - } catch (err) { - toast('err', err.message ?? String(err)); - } finally { - $('auth-submit').disabled = false; - } -}); -window.addEventListener('auth:server-config', e => { - const oauth = e.detail?.oauth || {}; - const google = $('oauth-google'); - const github = $('oauth-github'); - google.disabled = !oauth.google; - github.disabled = !oauth.github; - google.title = oauth.google - ? '' - : 'Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env'; - github.title = oauth.github - ? '' - : 'Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in .env'; - google.setAttribute('aria-disabled', String(!oauth.google)); - github.setAttribute('aria-disabled', String(!oauth.github)); -}); -$('oauth-google').addEventListener('click', () => { - if ($('oauth-google').disabled) { - toast('err', 'Google OAuth is not configured'); - return; - } - window.auth?.oauthStart('google'); -}); -$('oauth-github').addEventListener('click', () => { - if ($('oauth-github').disabled) { - toast('err', 'GitHub OAuth is not configured'); - return; - } - window.auth?.oauthStart('github'); -}); $('btn-logout').addEventListener('click', () => window.auth?.logout()); let state = null; diff --git a/spacetime-grid-ts/example/src/app.ts b/spacetime-grid-ts/example/src/app.ts index 999cfdf741b..e241e547911 100644 --- a/spacetime-grid-ts/example/src/app.ts +++ b/spacetime-grid-ts/example/src/app.ts @@ -1,3 +1,9 @@ +import { + authUrlState, + clearAuthResultParams, + mountAuthPanel, +} from '@spacetimedb/example-ui'; +import '@spacetimedb/example-ui/styles.css'; import { DbConnection, tables, @@ -182,7 +188,10 @@ async function loadServerConfig(): Promise { const res = await fetch('/api/config', { credentials: 'same-origin' }); if (!res.ok) throw new Error(`/api/config returned ${res.status}`); const cfg = (await res.json()) as ServerConfig; - dispatch('auth:server-config', cfg); + authPanel.setProviders({ + google: Boolean(cfg.oauth?.google), + github: Boolean(cfg.oauth?.github), + }); return cfg; } @@ -412,10 +421,39 @@ function oauthStart(provider: 'google' | 'github'): void { async function forgotPassword(email: string): Promise { await callJson('/auth/password/forgot', { email }); } +async function resetPassword( + token: string, + newPassword: string +): Promise { + await callJson('/auth/password/reset', { token, newPassword }); +} async function requestEmailVerify(): Promise { await callJson('/auth/email/verify-request', {}); } +const authResult = authUrlState(window.location); +const authPanelRoot = document.getElementById('auth-panel'); +if (!authPanelRoot) throw new Error('missing_auth_panel'); +const authPanel = mountAuthPanel(authPanelRoot, { + productName: 'Grid', + actions: { + login, + signup, + forgotPassword, + resetPassword, + oauthStart, + }, + initialMode: authResult.mode, + resetToken: authResult.resetToken, +}); +if (authResult.oauthError) { + authPanel.showMessage('error', `OAuth: ${authResult.oauthError}`); +} +if (authResult.verified) { + authPanel.showMessage('success', 'Email verified.'); +} +clearAuthResultParams(window.location, window.history); + async function main(): Promise { window.auth = { signup, diff --git a/spacetime-presence-ts/example/package.json b/spacetime-presence-ts/example/package.json index 5f73837b232..51252eac9fe 100644 --- a/spacetime-presence-ts/example/package.json +++ b/spacetime-presence-ts/example/package.json @@ -14,6 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-presence-ts/example/public/index.html b/spacetime-presence-ts/example/public/index.html index 06579dfb6cc..9a7fcaab5a0 100644 --- a/spacetime-presence-ts/example/public/index.html +++ b/spacetime-presence-ts/example/public/index.html @@ -8,6 +8,7 @@ content="A SpacetimeDB chat example with authentication, presence, typing indicators, rooms, and unread counts." /> SpacetimeDB Chat + @@ -36,117 +37,7 @@
          -
          - -

          Welcome to chat

          -

          Sign in to continue.

          - -
          - - -
          - -
          or
          - -
          - - -
          - -
          - - -
          - - - - - -

          - Forgot password? -

          -

          - Don't have an account? - Sign up -

          -
          +
          @@ -639,39 +530,6 @@

          Channel settings

          - -
          { } }); -function openAuthModal() { - $('authModal').classList.add('open'); - $('authEmail').focus(); -} -function closeAuthModal() { - $('authModal').classList.remove('open'); +function focusAuthPanel() { + document.querySelector('#auth-panel input')?.focus(); } + function setResult(text, ok = true) { if (!text) return; const host = @@ -252,7 +249,6 @@ function setAuthedUi(user) { renderUserBar(); shell.classList.remove('signed-out'); app.classList.remove('signed-out'); - closeAuthModal(); } else { openBtn.classList.remove('hidden'); userBar.classList.add('hidden'); @@ -293,7 +289,7 @@ function updateComposerState() { } function requireAuthAction() { if (state.authenticated) return true; - openAuthModal(); + focusAuthPanel(); setResult('Sign in to continue.', false); return false; } @@ -1224,151 +1220,10 @@ window.addEventListener('chat:auth', e => { renderAll(); }); -$('openAuthBtn').addEventListener('click', openAuthModal); -$('closeAuthBtn').addEventListener('click', closeAuthModal); -$('authModal').addEventListener('click', e => { - if (e.target === $('authModal')) closeAuthModal(); -}); -window.addEventListener('keydown', e => { - if (e.key === 'Escape') closeAuthModal(); +$('openAuthBtn').addEventListener('click', () => { + focusAuthPanel(); }); -async function loginFrom(emailId, passId) { - await window.chat.login({ - email: $(emailId).value.trim(), - password: $(passId).value, - }); - $(passId).value = ''; - setResult('Signed in.'); -} - -async function signupFrom(emailId, passId) { - await window.chat.signup({ - email: $(emailId).value.trim(), - password: $(passId).value, - }); - $(passId).value = ''; - setResult('Account created.'); -} - -$('authLoginBtn').addEventListener('click', async () => { - try { - await loginFrom('authEmail', 'authPass'); - } catch (err) { - setResult(`sign in failed: ${err.message ?? err}`, false); - } -}); - -$('authSignupBtn').addEventListener('click', async () => { - try { - await signupFrom('authEmail', 'authPass'); - } catch (err) { - setResult(`sign up failed: ${err.message ?? err}`, false); - } -}); - -let landingAuthMode = 'login'; -function setLandingAuthMode(mode) { - landingAuthMode = mode; - const status = document.getElementById('landingStatus'); - if (status) { - status.hidden = true; - status.textContent = ''; - status.classList.remove('ok'); - } - const title = $('landingAuthTitle'); - const sub = $('landingAuthSub'); - const submit = $('landingSubmitBtn'); - const togglePrompt = $('togglePrompt'); - const toggleLink = $('toggleLink'); - const forgotFoot = $('forgotFoot'); - const nameField = $('authNameField'); - const passField = $('authPassField'); - if (mode === 'signup') { - title.textContent = 'Create an account'; - sub.textContent = 'Sign up to start chatting.'; - submit.textContent = 'Create account'; - togglePrompt.textContent = 'Already have an account?'; - toggleLink.textContent = 'Sign in'; - forgotFoot.hidden = true; - nameField.hidden = false; - passField.hidden = false; - $('authPassLanding').autocomplete = 'new-password'; - } else if (mode === 'forgot') { - title.textContent = 'Reset password'; - sub.textContent = "Enter your email and we'll send a reset link."; - submit.textContent = 'Send reset link'; - togglePrompt.textContent = 'Remembered it?'; - toggleLink.textContent = 'Sign in'; - forgotFoot.hidden = true; - nameField.hidden = true; - passField.hidden = true; - } else { - title.textContent = 'Welcome to chat'; - sub.textContent = 'Sign in to continue.'; - submit.textContent = 'Sign in'; - togglePrompt.textContent = "Don't have an account?"; - toggleLink.textContent = 'Sign up'; - forgotFoot.hidden = false; - nameField.hidden = true; - passField.hidden = false; - $('authPassLanding').autocomplete = 'current-password'; - } -} - -$('toggleLink').addEventListener('click', () => { - setLandingAuthMode(landingAuthMode === 'login' ? 'signup' : 'login'); -}); -$('forgotLink').addEventListener('click', () => setLandingAuthMode('forgot')); - -function setLandingStatus(text, ok = false) { - const el = $('landingStatus'); - if (!text) { - el.hidden = true; - el.textContent = ''; - return; - } - el.hidden = false; - el.textContent = text; - el.classList.toggle('ok', ok); -} - -$('authCard').addEventListener('submit', async e => { - e.preventDefault(); - if (!window.chat) return; - const email = $('authEmailLanding').value.trim(); - const password = $('authPassLanding').value; - const submit = $('landingSubmitBtn'); - submit.disabled = true; - setLandingStatus(''); - try { - if (landingAuthMode === 'signup') { - const name = $('authNameLanding').value.trim() || undefined; - await window.chat.signup({ email, password, name }); - } else if (landingAuthMode === 'forgot') { - await window.chat.forgotPassword(email); - setLandingStatus( - 'Reset link sent. Check the STDB module log (dev mailer).', - true - ); - setLandingAuthMode('login'); - } else { - await window.chat.login({ email, password }); - } - } catch (err) { - setLandingStatus(`${landingAuthMode} failed: ${err.message ?? err}`, false); - } finally { - submit.disabled = false; - } -}); - -$('oauthGoogle').addEventListener('click', () => - window.chat?.oauthStart('google') -); -$('oauthGithub').addEventListener('click', () => - window.chat?.oauthStart('github') -); - $('authLogoutBtn').addEventListener('click', async () => { try { await window.chat.logout(); @@ -1697,7 +1552,7 @@ $('toggleMembersBtn').addEventListener('click', () => { $('homeBtn').addEventListener('click', () => { if (!state.authenticated) { - openAuthModal(); + focusAuthPanel(); return; } const mine = myServers(); @@ -1799,7 +1654,7 @@ function clearPendingAtts() { async function sendCurrentMessage() { if (!state.authenticated) { console.warn('send blocked: not authenticated'); - openAuthModal(); + focusAuthPanel(); return; } const room = activeRoom(); diff --git a/spacetime-presence-ts/example/src/app.ts b/spacetime-presence-ts/example/src/app.ts index d4c87428cb7..fa3a87a24c9 100644 --- a/spacetime-presence-ts/example/src/app.ts +++ b/spacetime-presence-ts/example/src/app.ts @@ -1,3 +1,9 @@ +import { + authUrlState, + clearAuthResultParams, + mountAuthPanel, +} from '@spacetimedb/example-ui'; +import '@spacetimedb/example-ui/styles.css'; import { DbConnection, tables, @@ -82,6 +88,7 @@ declare global { logout: () => Promise; oauthStart: (provider: 'google' | 'github') => void; forgotPassword: (email: string) => Promise; + resetPassword: (token: string, newPassword: string) => Promise; requestEmailVerify: () => Promise; whoami: () => Promise<{ userId: string | undefined; @@ -95,6 +102,10 @@ declare global { interface ServerConfig { spacetimeUri: string; databaseName: string; + oauth?: { + google?: boolean; + github?: boolean; + }; } interface AuthUser { @@ -273,7 +284,48 @@ async function callJson(path: string, body?: unknown): Promise { async function loadServerConfig(): Promise { const r = await fetch('/api/config'); if (!r.ok) throw new Error(`/api/config returned ${r.status}`); - return (await r.json()) as ServerConfig; + const nextConfig = (await r.json()) as ServerConfig; + authPanel.setProviders({ + google: Boolean(nextConfig.oauth?.google), + github: Boolean(nextConfig.oauth?.github), + }); + return nextConfig; +} + +async function signup(args: { + email: string; + password: string; + name?: string; +}): Promise { + const result = await callJson<{ token: string }>('/auth/password/signup', { + email: args.email, + password: args.password, + name: args.name, + }); + await bindSession(result.token); +} + +async function login(args: { email: string; password: string }): Promise { + const result = await callJson<{ token: string }>('/auth/password/login', { + email: args.email, + password: args.password, + }); + await bindSession(result.token); +} + +function oauthStart(provider: 'google' | 'github'): void { + window.location.href = `/auth/${provider}/start?redirectTo=/`; +} + +async function forgotPassword(email: string): Promise { + await callJson('/auth/password/forgot', { email }); +} + +async function resetPassword( + token: string, + newPassword: string +): Promise { + await callJson('/auth/password/reset', { token, newPassword }); } function requireConn(): DbConnection { @@ -633,21 +685,8 @@ function installApi(): void { heartbeat: () => { return requireConn().reducers.heartbeat({}); }, - signup: async args => { - const r = await callJson<{ token: string }>('/auth/password/signup', { - email: args.email, - password: args.password, - name: args.name, - }); - await bindSession(r.token); - }, - login: async args => { - const r = await callJson<{ token: string }>('/auth/password/login', { - email: args.email, - password: args.password, - }); - await bindSession(r.token); - }, + signup, + login, logout: async () => { const c = conn; if (c) { @@ -665,12 +704,9 @@ function installApi(): void { emitAuth(); emitData(); }, - oauthStart: provider => { - window.location.href = `/auth/${provider}/start?redirectTo=/`; - }, - forgotPassword: async email => { - await callJson('/auth/password/forgot', { email }); - }, + oauthStart, + forgotPassword, + resetPassword, requestEmailVerify: async () => { await callJson('/auth/email/verify-request', {}); }, @@ -692,6 +728,29 @@ function installApi(): void { }; } +const authResult = authUrlState(window.location); +const authPanelRoot = document.getElementById('auth-panel'); +if (!authPanelRoot) throw new Error('missing_auth_panel'); +const authPanel = mountAuthPanel(authPanelRoot, { + productName: 'Chat', + actions: { + login, + signup, + forgotPassword, + resetPassword, + oauthStart, + }, + initialMode: authResult.mode, + resetToken: authResult.resetToken, +}); +if (authResult.oauthError) { + authPanel.showMessage('error', `OAuth: ${authResult.oauthError}`); +} +if (authResult.verified) { + authPanel.showMessage('success', 'Email verified.'); +} +clearAuthResultParams(window.location, window.history); + function typingScopeForRoom(roomId: bigint): string { return `${PRESENCE_SCOPE_TYPING_PREFIX}${roomId.toString()}`; } From dcc646361236ba84d1f88932439ab51890976de8 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 10:32:11 -0400 Subject: [PATCH 21/33] cleanup --- spacetime-agents-ts/README.md | 6 +++--- spacetime-agents-ts/example/README.md | 4 ++-- .../example/spacetimedb/scripts/test-loop.ts | 2 +- spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts | 2 +- .../example/spacetimedb/src/agents/summarizer.ts | 2 +- spacetime-agents-ts/example/spacetimedb/src/loop.ts | 2 +- spacetime-agents-ts/example/spacetimedb/src/runtime.ts | 2 +- spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts | 2 +- .../example/spacetimedb/src/tools/getTime.ts | 4 ++-- spacetime-agents-ts/package.json | 6 +----- spacetime-agents-ts/scripts/{test-kit.ts => test.ts} | 6 +++--- spacetime-agents-ts/src/{kit.ts => agent.ts} | 0 spacetime-agents-ts/src/index.ts | 4 ++-- spacetime-agents-ts/src/submodule/index.ts | 2 +- spacetime-retry-ts/README.md | 6 ++---- spacetime-retry-ts/package.json | 4 ---- spacetime-retry-ts/scripts/test.ts | 2 +- spacetime-retry-ts/src/{kit.ts => handler.ts} | 0 spacetime-retry-ts/src/index.ts | 2 +- spacetime-retry-ts/src/submodule.ts | 2 +- 20 files changed, 25 insertions(+), 35 deletions(-) rename spacetime-agents-ts/scripts/{test-kit.ts => test.ts} (99%) rename spacetime-agents-ts/src/{kit.ts => agent.ts} (100%) rename spacetime-retry-ts/src/{kit.ts => handler.ts} (100%) diff --git a/spacetime-agents-ts/README.md b/spacetime-agents-ts/README.md index fae2f8d1f97..ca00c389f10 100644 --- a/spacetime-agents-ts/README.md +++ b/spacetime-agents-ts/README.md @@ -111,8 +111,8 @@ for private configuration, caller-scoped views, and an agent loop. embedding requests. - `cosineSimilarity` and `topKByScore` provide in-memory ranking helpers. -Documented subpath exports are `./submodule`, `./kit`, `./openrouter`, -`./providers`, `./embeddings`, and `./stale-locks`. +Documented subpath exports are `./submodule`, `./openrouter`, `./providers`, +`./embeddings`, and `./stale-locks`. Tool dispatch rejects malformed JSON, missing and unknown fields, incorrect types, unsafe integers, inputs above 64 KiB, arrays above 1,000 items, and tool @@ -142,7 +142,7 @@ Package entrypoints: - `@spacetimedb/agents` exports the complete public surface. - `@spacetimedb/agents/submodule` exports the ready-to-mount Agents schema and installer. -- `@spacetimedb/agents/kit` exports typed agents, tools, and dispatch. +- `@spacetimedb/agents` exports typed agents, tools, and dispatch. - `@spacetimedb/agents/providers` exports provider adapters. - `@spacetimedb/agents/embeddings` exports embedding and ranking helpers. - `@spacetimedb/agents/openrouter` exports the common chat request layer. diff --git a/spacetime-agents-ts/example/README.md b/spacetime-agents-ts/example/README.md index e8640d29868..2428d0cdb01 100644 --- a/spacetime-agents-ts/example/README.md +++ b/spacetime-agents-ts/example/README.md @@ -8,7 +8,7 @@ and file HTTP handlers. ## What this demonstrates -- Defining typed agents and tools with `@spacetimedb/agents/kit`. +- Defining typed agents and tools with `@spacetimedb/agents`. - Running an agent loop from a SpacetimeDB procedure with OpenRouter, OpenAI, or Anthropic. - Isolating threads, messages, locks, files, and embeddings by authenticated user. @@ -152,7 +152,7 @@ Agents are registered by key in `spacetimedb/src/agents/index.ts`. The registry is the runtime name stored on each thread. ```ts -import { defineAgent } from '@spacetimedb/agents/kit'; +import { defineAgent } from '@spacetimedb/agents'; import myTool from '../tools/myTool'; export default defineAgent({ diff --git a/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts b/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts index 93339d36139..a58640ecde1 100644 --- a/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts +++ b/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts @@ -20,7 +20,7 @@ import { formatMessagesForSummarizer, } from '../src/summarize.ts'; import type { HttpLike } from '@spacetimedb/agents/openrouter'; -import type { InvokeResult } from '@spacetimedb/agents/kit'; +import type { InvokeResult } from '@spacetimedb/agents'; let failures = 0; function assert(cond: boolean, msg: string): void { diff --git a/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts b/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts index c15030fff72..61cd43569eb 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts @@ -1,4 +1,4 @@ -import { defineAgent } from '@spacetimedb/agents/kit'; +import { defineAgent } from '@spacetimedb/agents'; import getTime from '../tools/getTime'; import echo from '../tools/echo'; diff --git a/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts b/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts index 05952a4c212..039589fade7 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts @@ -1,4 +1,4 @@ -import { defineAgent } from '@spacetimedb/agents/kit'; +import { defineAgent } from '@spacetimedb/agents'; export default defineAgent({ defaultModel: 'anthropic/claude-haiku-4.5', diff --git a/spacetime-agents-ts/example/spacetimedb/src/loop.ts b/spacetime-agents-ts/example/spacetimedb/src/loop.ts index 64d41f4ef61..691a5621805 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/loop.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/loop.ts @@ -8,7 +8,7 @@ import { type ResponseFormat, type Provider, } from '@spacetimedb/agents/openrouter'; -import type { InvokeResult } from '@spacetimedb/agents/kit'; +import type { InvokeResult } from '@spacetimedb/agents'; export const USER_CONTENT_MAX = 32_000; export const TOOL_RESULT_MAX = 64_000; diff --git a/spacetime-agents-ts/example/spacetimedb/src/runtime.ts b/spacetime-agents-ts/example/spacetimedb/src/runtime.ts index 61e0ffcc377..cb12e45609b 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/runtime.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/runtime.ts @@ -1,4 +1,4 @@ -import { makeAgentRegistry } from '@spacetimedb/agents/kit'; +import { makeAgentRegistry } from '@spacetimedb/agents'; import { callChat, type ChatMessage, diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts index 3af39f1c2c1..1ffb164b74d 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts @@ -1,5 +1,5 @@ import { t } from 'spacetimedb/server'; -import { agentTool } from '@spacetimedb/agents/kit'; +import { agentTool } from '@spacetimedb/agents'; export default agentTool( 'echoes the given message back to the caller', diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts index 498d35e938d..c9acf204208 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts @@ -1,12 +1,12 @@ import { t } from 'spacetimedb/server'; -import { agentTool } from '@spacetimedb/agents/kit'; +import { agentTool } from '@spacetimedb/agents'; import type { Tx } from '../types'; export default agentTool( 'returns the current server time as an ISO-8601 string', t.unit(), ctx => { - // This cast avoids a circular reference between the kit and schema types. + // This cast breaks the circular type dependency between the tool and module schema. const tx = ctx as Tx; const micros = tx.timestamp.microsSinceUnixEpoch as bigint; return new Date(Number(micros / 1000n)).toISOString(); diff --git a/spacetime-agents-ts/package.json b/spacetime-agents-ts/package.json index d674f023e53..2358b75aac1 100644 --- a/spacetime-agents-ts/package.json +++ b/spacetime-agents-ts/package.json @@ -15,10 +15,6 @@ "types": "./src/openrouter.ts", "default": "./src/openrouter.ts" }, - "./kit": { - "types": "./src/kit.ts", - "default": "./src/kit.ts" - }, "./providers": { "types": "./src/providers.ts", "default": "./src/providers.ts" @@ -65,7 +61,7 @@ "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", "typecheck": "tsc --noEmit", "test": "pnpm run test:unit", - "test:unit": "tsx scripts/test-kit.ts" + "test:unit": "tsx scripts/test.ts" }, "dependencies": {}, "peerDependencies": { diff --git a/spacetime-agents-ts/scripts/test-kit.ts b/spacetime-agents-ts/scripts/test.ts similarity index 99% rename from spacetime-agents-ts/scripts/test-kit.ts rename to spacetime-agents-ts/scripts/test.ts index c9c026902d4..7cb39fad5f1 100644 --- a/spacetime-agents-ts/scripts/test-kit.ts +++ b/spacetime-agents-ts/scripts/test.ts @@ -1,4 +1,4 @@ -// Pure-Node tests for spacetime-agents-ts/kit. +// Pure-Node tests for the Agents package. // Avoids importing 'spacetimedb/server' (Node 22 ESM can't parse its `using` decls); // builds minimal AlgebraicType fixtures matching what t.object(...) would produce. @@ -8,7 +8,7 @@ import { defineAgent, makeAgentRegistry, typeBuilderToJsonSchema, -} from '../src/kit.ts'; +} from '../src/agent.ts'; import { openRouterProvider, openAiProvider, @@ -1186,4 +1186,4 @@ if (failures > 0) { process.stderr.write(`\n${failures} test(s) failed.\n`); process.exit(1); } -process.stdout.write('\nall kit tests passed.\n'); +process.stdout.write('\nall Agents tests passed.\n'); diff --git a/spacetime-agents-ts/src/kit.ts b/spacetime-agents-ts/src/agent.ts similarity index 100% rename from spacetime-agents-ts/src/kit.ts rename to spacetime-agents-ts/src/agent.ts diff --git a/spacetime-agents-ts/src/index.ts b/spacetime-agents-ts/src/index.ts index afcf0f80b5a..958ce70ff81 100644 --- a/spacetime-agents-ts/src/index.ts +++ b/spacetime-agents-ts/src/index.ts @@ -4,14 +4,14 @@ export { defineAgent, makeAgentRegistry, typeBuilderToJsonSchema, -} from './kit.ts'; +} from './agent.ts'; export type { AgentTool, AgentDefinition, AgentRegistry, InvokeResult, ToolMap, -} from './kit.ts'; +} from './agent.ts'; export { callChat, isRetryableError } from './openrouter.ts'; export type { diff --git a/spacetime-agents-ts/src/submodule/index.ts b/spacetime-agents-ts/src/submodule/index.ts index b22d9cee75a..2a099b33c2a 100644 --- a/spacetime-agents-ts/src/submodule/index.ts +++ b/spacetime-agents-ts/src/submodule/index.ts @@ -12,7 +12,7 @@ import { import { Timestamp, type Identity } from 'spacetimedb'; import { deleteStaleThreadLocks, staleLockCutoffMicros } from '../stale-locks'; import { installAgents } from './install'; -import { agentTool, defineAgent, makeAgentRegistry } from '../kit'; +import { agentTool, defineAgent, makeAgentRegistry } from '../agent'; import { callChat, type ChatMessage, diff --git a/spacetime-retry-ts/README.md b/spacetime-retry-ts/README.md index f988b7c4d28..29d02c06dd4 100644 --- a/spacetime-retry-ts/README.md +++ b/spacetime-retry-ts/README.md @@ -101,11 +101,9 @@ screens can subscribe to the factory's admin task and history views. Package entrypoints: - `@spacetimedb/retry/submodule` exports `createRetrySubmodule`. -- `@spacetimedb/retry/kit` exports handler, dispatch, and result helpers. -- `@spacetimedb/retry` re-exports the supported public surface. +- `@spacetimedb/retry` exports handler, dispatch, and result helpers. -The helpers are available from the package root and `./kit`; the complete -factory is available from `./submodule`. +The complete factory is available from `./submodule`. ## Testing diff --git a/spacetime-retry-ts/package.json b/spacetime-retry-ts/package.json index 01fe9a89565..e37077d8579 100644 --- a/spacetime-retry-ts/package.json +++ b/spacetime-retry-ts/package.json @@ -11,10 +11,6 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, - "./kit": { - "types": "./src/kit.ts", - "default": "./src/kit.ts" - }, "./submodule": { "types": "./src/submodule.ts", "default": "./src/submodule.ts" diff --git a/spacetime-retry-ts/scripts/test.ts b/spacetime-retry-ts/scripts/test.ts index 13e23feac74..d6622d58feb 100644 --- a/spacetime-retry-ts/scripts/test.ts +++ b/spacetime-retry-ts/scripts/test.ts @@ -6,7 +6,7 @@ import { retryFailed, retryHandler, retryOk, -} from '../src/kit.ts'; +} from '../src/handler.ts'; const fakeBuilder = (): TypeBuilder => ({}) as unknown as TypeBuilder; diff --git a/spacetime-retry-ts/src/kit.ts b/spacetime-retry-ts/src/handler.ts similarity index 100% rename from spacetime-retry-ts/src/kit.ts rename to spacetime-retry-ts/src/handler.ts diff --git a/spacetime-retry-ts/src/index.ts b/spacetime-retry-ts/src/index.ts index a73228de9ed..f49751f7aee 100644 --- a/spacetime-retry-ts/src/index.ts +++ b/spacetime-retry-ts/src/index.ts @@ -5,5 +5,5 @@ export { retryOk, type RetryHandler, type RetryResult, -} from './kit'; +} from './handler'; export { createRetrySubmodule, type RetryHandlers } from './submodule'; diff --git a/spacetime-retry-ts/src/submodule.ts b/spacetime-retry-ts/src/submodule.ts index 8c3e79f7baa..2be1873e4aa 100644 --- a/spacetime-retry-ts/src/submodule.ts +++ b/spacetime-retry-ts/src/submodule.ts @@ -1,4 +1,4 @@ -import { makeRetryDispatch, type RetryHandler } from './kit'; +import { makeRetryDispatch, type RetryHandler } from './handler'; import type { Identity, ScheduleAt, Timestamp } from 'spacetimedb'; import type { Infer, VariantsObj } from 'spacetimedb/server'; From ccc613ea19aeb689f27801f4e98614272d2c23f9 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 10:35:42 -0400 Subject: [PATCH 22/33] Reuse shared authentication UI in Agents example --- pnpm-lock.yaml | 3 + spacetime-agents-ts/example/package.json | 1 + spacetime-agents-ts/example/public/index.html | 95 +--------- spacetime-agents-ts/example/public/styles.css | 171 ------------------ spacetime-agents-ts/example/public/ui.js | 80 -------- spacetime-agents-ts/example/src/app.ts | 52 +++++- 6 files changed, 52 insertions(+), 350 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d373b6f9dd..38f92aebe6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,6 +363,9 @@ importers: spacetime-agents-ts/example: dependencies: + '@spacetimedb/example-ui': + specifier: workspace:* + version: link:../../spacetime-example-ui-ts dotenv: specifier: ^16.4.7 version: 16.6.1 diff --git a/spacetime-agents-ts/example/package.json b/spacetime-agents-ts/example/package.json index ef12b966d6e..67869a23c2e 100644 --- a/spacetime-agents-ts/example/package.json +++ b/spacetime-agents-ts/example/package.json @@ -14,6 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-agents-ts/example/public/index.html b/spacetime-agents-ts/example/public/index.html index dc65fefe76e..067f473b2fa 100644 --- a/spacetime-agents-ts/example/public/index.html +++ b/spacetime-agents-ts/example/public/index.html @@ -8,6 +8,7 @@ content="A runnable SpacetimeDB agents example with authentication, threads, tools, and provider-backed responses." /> SpacetimeDB Agents + @@ -32,99 +33,7 @@
          -
          - -

          Welcome to Agents

          -

          Sign in to continue.

          - -
          - - -
          - -
          or
          - -
          - - -
          - -
          - - -
          - - - -

          - Forgot password? -

          -

          - Don't have an account? - Sign up -

          -
          +
          diff --git a/spacetime-agents-ts/example/public/styles.css b/spacetime-agents-ts/example/public/styles.css index 8a5b36bbdca..11485ab4b77 100644 --- a/spacetime-agents-ts/example/public/styles.css +++ b/spacetime-agents-ts/example/public/styles.css @@ -1115,177 +1115,6 @@ select:focus { ), var(--color-shade7); } -.auth-card { - width: 100%; - max-width: 380px; - background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); - border: 1px solid var(--color-shade4); - border-radius: var(--radius-lg); - padding: 28px; - display: flex; - flex-direction: column; - gap: 12px; -} -.auth-logo { - width: 56px; - height: auto; - margin: 0 auto 4px; - display: block; -} -.auth-card h1 { - font-family: var(--font-inter); - font-size: 18px; - font-weight: 700; - margin: 0; - text-align: center; - color: var(--color-n1); -} -.auth-sub { - font-family: var(--font-inter); - font-size: 13px; - color: var(--color-n4); - margin: 0 0 8px; - text-align: center; -} -.auth-oauth { - display: flex; - flex-direction: column; - gap: 8px; -} -.btn.oauth { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 10px 14px; - font-family: var(--font-inter); - font-size: 13px; - font-weight: 500; - background: var(--color-shade7); - border: 1px solid var(--color-shade4); - color: var(--color-n1); - border-radius: var(--radius-sm); - cursor: pointer; -} -.btn.oauth:hover:not(:disabled) { - background: var(--color-shade4); - border-color: var(--color-n4); -} -.btn.oauth svg { - flex-shrink: 0; - width: 16px; - height: 16px; -} -.btn.block { - width: 100%; - display: flex; - align-items: center; - justify-content: center; -} -.auth-divider { - display: flex; - align-items: center; - gap: 8px; - margin: 4px 0; - color: var(--color-n4); - font-size: 11px; - font-family: var(--font-ibm); - text-transform: uppercase; - letter-spacing: 0.08em; -} -.auth-divider::before, -.auth-divider::after { - content: ''; - flex: 1; - height: 1px; - background: var(--color-shade4); -} -.auth-field { - display: flex; - flex-direction: column; - gap: 4px; -} -.auth-field label { - font-family: var(--font-ibm); - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--color-n4); -} -.auth-field input { - background: var(--color-shade6); - border: 1px solid var(--color-shade4); - color: var(--color-n1); - font-family: var(--font-inter); - font-size: 13px; - padding: 8px 10px; - border-radius: var(--radius-sm); - outline: none; -} -.auth-field input:focus { - border-color: var(--color-green); - box-shadow: 0 0 0 3px var(--color-green-20); -} -.auth-foot { - margin: 0; - text-align: center; - font-family: var(--font-inter); - font-size: 12px; - color: var(--color-n4); -} -.auth-foot a { - color: var(--color-green); - cursor: pointer; - text-decoration: none; - font-weight: 600; -} -.auth-foot a:hover { - text-decoration: underline; -} -.auth-card .btn.primary.block { - margin-top: 4px; -} -/* Lock down sizing so the card renders identically across apps - regardless of their per-app global input/.btn rules. */ -.auth-card { - width: 380px; - gap: 12px; -} -.auth-card .auth-logo { - width: 56px; - height: 56px; -} -.auth-card h1 { - font-size: 18px; - line-height: 24px; -} -.auth-card .auth-sub { - font-size: 13px; - line-height: 18px; -} -.auth-card .auth-field input, -.auth-card .btn { - height: 40px; - box-sizing: border-box; - width: 100%; - font-family: var(--font-inter); - font-size: 13px; - font-weight: 600; -} -.auth-card .auth-field input { - padding: 0 12px; -} -.auth-card .btn.oauth { - padding: 0 14px; -} -.auth-card .auth-field label { - line-height: 14px; -} -.auth-card .auth-foot { - font-size: 12px; - line-height: 18px; -} - /* ============================================================ User panel ============================================================ */ diff --git a/spacetime-agents-ts/example/public/ui.js b/spacetime-agents-ts/example/public/ui.js index 63a81f11ccc..72bb43de0ff 100644 --- a/spacetime-agents-ts/example/public/ui.js +++ b/spacetime-agents-ts/example/public/ui.js @@ -993,86 +993,6 @@ function updateButtons() { } let currentUserState = null; -let authMode = 'login'; // 'login' | 'signup' | 'forgot' - -function setAuthMode(mode) { - authMode = mode; - const title = $('auth-title'); - const sub = $('auth-sub'); - const submit = $('auth-submit'); - const togglePrompt = $('toggle-prompt'); - const toggleLink = $('toggle-link'); - const forgotFoot = $('forgot-link').parentElement; - const passField = $('auth-pass').closest('.auth-field'); - const nameField = $('auth-name-field'); - if (mode === 'signup') { - title.textContent = 'Create an account'; - sub.textContent = 'Sign up to start chatting.'; - submit.textContent = 'Create account'; - togglePrompt.textContent = 'Already have an account?'; - toggleLink.textContent = 'Sign in'; - forgotFoot.hidden = true; - nameField.hidden = false; - $('auth-pass').autocomplete = 'new-password'; - passField.hidden = false; - } else if (mode === 'forgot') { - title.textContent = 'Reset password'; - sub.textContent = "Enter your email and we'll send you a reset link."; - submit.textContent = 'Send reset link'; - togglePrompt.textContent = 'Remembered it?'; - toggleLink.textContent = 'Sign in'; - forgotFoot.hidden = true; - nameField.hidden = true; - passField.hidden = true; - } else { - title.textContent = 'Welcome to Agents'; - sub.textContent = 'Sign in to continue.'; - submit.textContent = 'Sign in'; - togglePrompt.textContent = "Don't have an account?"; - toggleLink.textContent = 'Sign up'; - forgotFoot.hidden = false; - nameField.hidden = true; - $('auth-pass').autocomplete = 'current-password'; - passField.hidden = false; - } -} - -$('toggle-link').addEventListener('click', () => { - setAuthMode(authMode === 'login' ? 'signup' : 'login'); -}); -$('forgot-link').addEventListener('click', () => setAuthMode('forgot')); - -$('auth-form').addEventListener('submit', async e => { - e.preventDefault(); - if (!window.auth) return; - const email = $('auth-email').value.trim(); - const password = $('auth-pass').value; - const submit = $('auth-submit'); - submit.disabled = true; - try { - if (authMode === 'signup') { - const name = $('auth-name').value.trim() || undefined; - await window.auth.signup({ email, password, name }); - } else if (authMode === 'forgot') { - await window.auth.forgotPassword(email); - toast('ok', 'Reset link sent. Check the STDB module log (dev mailer).'); - setAuthMode('login'); - } else { - await window.auth.login({ email, password }); - } - } catch (err) { - toast('err', err.message ?? String(err)); - } finally { - submit.disabled = false; - } -}); - -$('oauth-google').addEventListener('click', () => - window.auth?.oauthStart('google') -); -$('oauth-github').addEventListener('click', () => - window.auth?.oauthStart('github') -); $('btn-logout').addEventListener('click', async () => { if (!window.auth) return; diff --git a/spacetime-agents-ts/example/src/app.ts b/spacetime-agents-ts/example/src/app.ts index 2e5e8d5cb1b..07324ab950d 100644 --- a/spacetime-agents-ts/example/src/app.ts +++ b/spacetime-agents-ts/example/src/app.ts @@ -1,3 +1,9 @@ +import { + authUrlState, + clearAuthResultParams, + mountAuthPanel, +} from '@spacetimedb/example-ui'; +import '@spacetimedb/example-ui/styles.css'; import { DbConnection, tables, @@ -21,6 +27,14 @@ interface AuthMe { user: AuthUser; sessionExpiresAt: number; } +interface ServerConfig { + spacetimeUri: string; + databaseName: string; + oauth?: { + google?: boolean; + github?: boolean; + }; +} interface AuthUserRow extends AuthUser { createdAt: unknown; updatedAt: unknown; @@ -112,7 +126,7 @@ let currentConn: DbConnection | null = null; let globalSub: SubscriptionHandle | null = null; let messageSub: SubscriptionHandle | null = null; let activeThreadId: bigint | null = null; -let serverCfg: { spacetimeUri: string; databaseName: string } | null = null; +let serverCfg: ServerConfig | null = null; let currentUser: AuthUser | null = null; let currentExp: number | undefined; @@ -222,13 +236,15 @@ async function callJson(path: string, body?: unknown): Promise { return data as T; } -async function loadServerConfig(): Promise<{ - spacetimeUri: string; - databaseName: string; -}> { +async function loadServerConfig(): Promise { const res = await fetch('/api/config', { credentials: 'same-origin' }); if (!res.ok) throw new Error(`/api/config returned ${res.status}`); - return res.json(); + const nextConfig = (await res.json()) as ServerConfig; + authPanel.setProviders({ + google: Boolean(nextConfig.oauth?.google), + github: Boolean(nextConfig.oauth?.github), + }); + return nextConfig; } // Persist the STDB identity token so refresh reuses the same identity. @@ -516,6 +532,29 @@ async function revokeMySession(sessionId: string): Promise { requireConn().reducers.revokeMySession({ sessionId }); } +const authResult = authUrlState(window.location); +const authPanelRoot = document.getElementById('auth-panel'); +if (!authPanelRoot) throw new Error('missing_auth_panel'); +const authPanel = mountAuthPanel(authPanelRoot, { + productName: 'Agents', + actions: { + login, + signup, + forgotPassword, + resetPassword, + oauthStart, + }, + initialMode: authResult.mode, + resetToken: authResult.resetToken, +}); +if (authResult.oauthError) { + authPanel.showMessage('error', `OAuth: ${authResult.oauthError}`); +} +if (authResult.verified) { + authPanel.showMessage('success', 'Email verified.'); +} +clearAuthResultParams(window.location, window.history); + async function main(): Promise { window.auth = { signup, @@ -587,6 +626,7 @@ async function main(): Promise { }; broadcastConn('idle'); + serverCfg = await loadServerConfig(); dispatch('stdb:ready', {}); await restoreSession(); dispatch('auth:ready', {}); From 517954100f55325369298bcf9a9d92157713c4ac Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 10:41:00 -0400 Subject: [PATCH 23/33] Ignore generated example CSS in formatting checks --- .prettierignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.prettierignore b/.prettierignore index 6b4b24fe735..63e9880376a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,4 +6,6 @@ target coverage **/public/app.js **/public/app.js.map +**/public/app.css +**/public/app.css.map **/src/module_bindings/** From a3ae832b7dcaf45175249266fb547ec150d16f76 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 10:57:55 -0400 Subject: [PATCH 24/33] cleanup --- .../src/{runtime.ts => agent-runner.ts} | 21 ++- .../example/spacetimedb/src/index.ts | 19 ++- spacetime-agents-ts/example/src/app.ts | 133 ++++++++-------- spacetime-agents-ts/src/submodule/index.ts | 14 +- spacetime-api-keys-ts/scripts/test.ts | 10 +- .../src/{key-utils.ts => keys.ts} | 2 +- .../src/submodule/operations.ts | 6 +- spacetime-auth-ts/example/src/app.ts | 50 +++--- .../src/handlers/email_verify.ts | 2 +- spacetime-auth-ts/src/handlers/github.ts | 2 +- .../src/handlers/{_helpers.ts => http.ts} | 21 +-- spacetime-auth-ts/src/handlers/index.ts | 4 +- spacetime-auth-ts/src/handlers/oauth.ts | 2 +- spacetime-auth-ts/src/handlers/password.ts | 2 +- .../src/handlers/password_reset.ts | 2 +- spacetime-auth-ts/src/handlers/session.ts | 2 +- spacetime-auth-ts/src/index.ts | 22 +-- spacetime-auth-ts/src/mounted/index.ts | 38 ++--- spacetime-auth-ts/src/procedures.ts | 20 +-- spacetime-auth-ts/src/rate_limit.ts | 2 +- spacetime-auth-ts/src/submodule.ts | 2 +- spacetime-files-ts/README.md | 22 ++- .../example/spacetimedb/src/index.ts | 10 +- spacetime-files-ts/example/src/app.ts | 32 ++-- .../example/src/context-menu.ts | 2 +- spacetime-files-ts/example/src/dialog.ts | 2 +- spacetime-files-ts/example/src/downloads.ts | 5 +- spacetime-files-ts/example/src/paths.ts | 43 +++++ .../example/src/presentation.ts | 101 ++++++++++++ spacetime-files-ts/example/src/rendering.ts | 54 ++++--- spacetime-files-ts/example/src/session.ts | 30 ++++ spacetime-files-ts/example/src/uploads.ts | 10 +- spacetime-files-ts/example/src/utils.ts | 148 ------------------ spacetime-files-ts/example/src/viewer.ts | 12 +- spacetime-files-ts/src/handlers.ts | 2 +- spacetime-files-ts/src/index.ts | 12 +- spacetime-files-ts/src/procedures.ts | 10 +- spacetime-grid-ts/README.md | 3 +- spacetime-grid-ts/example/README.md | 4 +- .../example/spacetimedb/src/index.ts | 14 +- spacetime-grid-ts/example/src/app.ts | 67 ++++---- spacetime-grid-ts/src/index.ts | 16 +- spacetime-grid-ts/src/procedures.ts | 16 +- spacetime-lobby-ts/example/src/app.ts | 16 +- spacetime-posthog-ts/scripts/test.ts | 2 +- spacetime-posthog-ts/src/submodule/auth.ts | 2 +- spacetime-posthog-ts/src/submodule/config.ts | 2 +- spacetime-posthog-ts/src/submodule/http.ts | 18 ++- .../src/submodule/operations.ts | 4 +- .../src/submodule/{utils.ts => validation.ts} | 1 - .../src/submodule/value-utils.ts | 16 -- .../example/spacetimedb/src/index.ts | 2 +- spacetime-presence-ts/example/src/app.ts | 50 +++--- spacetime-rate-limit-ts/example/src/app.ts | 73 +++++---- spacetime-resend-ts/example/src/app.ts | 18 +-- spacetime-resend-ts/src/submodule/auth.ts | 2 +- spacetime-resend-ts/src/submodule/config.ts | 2 +- spacetime-resend-ts/src/submodule/http.ts | 4 +- .../src/submodule/operations.ts | 8 +- .../src/submodule/{utils.ts => validation.ts} | 2 +- spacetime-resend-ts/src/submodule/webhooks.ts | 6 +- spacetime-retry-ts/src/handler.ts | 6 +- spacetime-stripe-ts/README.md | 2 +- .../example/spacetimedb/src/store/auth.ts | 2 +- .../spacetimedb/src/store/operations.ts | 2 +- .../src/store/{utils.ts => validation.ts} | 0 spacetime-stripe-ts/example/src/app.ts | 10 +- spacetime-stripe-ts/src/submodule/auth.ts | 2 +- spacetime-stripe-ts/src/submodule/config.ts | 2 +- .../src/submodule/operations.ts | 8 +- .../src/submodule/operations/billing.ts | 6 +- .../src/submodule/{utils.ts => validation.ts} | 2 +- 72 files changed, 665 insertions(+), 596 deletions(-) rename spacetime-agents-ts/example/spacetimedb/src/{runtime.ts => agent-runner.ts} (96%) rename spacetime-api-keys-ts/src/{key-utils.ts => keys.ts} (96%) rename spacetime-auth-ts/src/handlers/{_helpers.ts => http.ts} (87%) create mode 100644 spacetime-files-ts/example/src/paths.ts create mode 100644 spacetime-files-ts/example/src/presentation.ts create mode 100644 spacetime-files-ts/example/src/session.ts delete mode 100644 spacetime-files-ts/example/src/utils.ts rename spacetime-posthog-ts/src/submodule/{utils.ts => validation.ts} (91%) delete mode 100644 spacetime-posthog-ts/src/submodule/value-utils.ts rename spacetime-resend-ts/src/submodule/{utils.ts => validation.ts} (94%) rename spacetime-stripe-ts/example/spacetimedb/src/store/{utils.ts => validation.ts} (100%) rename spacetime-stripe-ts/src/submodule/{utils.ts => validation.ts} (94%) diff --git a/spacetime-agents-ts/example/spacetimedb/src/runtime.ts b/spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts similarity index 96% rename from spacetime-agents-ts/example/spacetimedb/src/runtime.ts rename to spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts index cb12e45609b..f318e7672c2 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/runtime.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts @@ -28,7 +28,7 @@ type WriteCtx = Tx; export const registry = makeAgentRegistry(agents); -export interface ProcedureRuntimeContext { +export interface AgentProcedureContext { http: HttpLike; withTx: (fn: (tx: WriteCtx) => R) => R; } @@ -40,7 +40,7 @@ function threadMessagesAscending(tx: WriteCtx, threadId: bigint) { } export function maybeEmbedMessage( - ctx: ProcedureRuntimeContext, + ctx: AgentProcedureContext, threadId: bigint, messageId: bigint ): void { @@ -90,7 +90,7 @@ export function maybeEmbedMessage( }); } -function retrieveRag(ctx: ProcedureRuntimeContext, threadId: bigint): string[] { +function retrieveRag(ctx: AgentProcedureContext, threadId: bigint): string[] { return ctx.withTx(tx => { const thread = tx.db.thread.id.find(threadId); if (!thread) return []; @@ -148,10 +148,7 @@ function augmentSystemWithRag( return `${base ?? ''}\n\n## Relevant earlier messages\n${snippets.join('\n---\n')}`.trim(); } -function runSummarization( - ctx: ProcedureRuntimeContext, - threadId: bigint -): void { +function runSummarization(ctx: AgentProcedureContext, threadId: bigint): void { const decision = ctx.withTx(tx => { const thread = tx.db.thread.id.find(threadId); if (!thread) return null; @@ -284,7 +281,7 @@ function loadAttachments( return attachments; } -function adaptTx( +function createLoopContext( tx: WriteCtx, agentName: string, userId: string, @@ -344,8 +341,8 @@ function adaptTx( }; } -export function runLockedLoop( - ctx: ProcedureRuntimeContext, +export function runAgentForThread( + ctx: AgentProcedureContext, cfg: LoopConfig, agentName: string, userId: string, @@ -370,7 +367,9 @@ export function runLockedLoop( runAgentLoop({ http: ctx.http, withTx: (fn: (loopTx: LoopTx) => R): R => - ctx.withTx(tx => fn(adaptTx(tx, agentName, userId, recordTokens))), + ctx.withTx(tx => + fn(createLoopContext(tx, agentName, userId, recordTokens)) + ), llmToolDefs: registry.llmToolDefsFor(agentName), cfg: finalConfig, threadId, diff --git a/spacetime-agents-ts/example/spacetimedb/src/index.ts b/spacetime-agents-ts/example/spacetimedb/src/index.ts index fab404d1d39..56d98e2f875 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/index.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/index.ts @@ -20,7 +20,7 @@ import { setAuthConfigParams, getPublicKeyPemParams, linkConnectionParams, - linkConnectionImpl, + linkConnection, unlinkConnectionParams, updateProfileParams, revokeSessionParams, @@ -59,7 +59,7 @@ import { USER_CONTENT_MAX, type LoopConfig } from './loop'; import { SWEEPER_INTERVAL_MICROS } from './sweeper'; import { attachmentValidationError } from './attachments'; import { registerAgentViews } from './views'; -import { maybeEmbedMessage, registry, runLockedLoop } from './runtime'; +import { maybeEmbedMessage, registry, runAgentForThread } from './agent-runner'; const ONE_SECOND_MICROS = 1_000_000n; const DEFAULT_STALE_LOCK_THRESHOLD_SECS = 15 * 60; @@ -256,7 +256,7 @@ export const get_auth_public_key = spacetimedb.procedure( export const link_connection = spacetimedb.procedure( linkConnectionParams, t.object('LinkConnectionResult', { userId: t.string() }), - (ctx, args) => linkConnectionImpl(ctx.as.auth, args) + (ctx, args) => linkConnection(ctx.as.auth, args) ); export const unlink_connection = spacetimedb.reducer( @@ -365,7 +365,7 @@ export const authEmailVerify = spacetimedb.httpHandler((ctx, req) => verifyHandler(ctx.as.auth, req) ); -const fileServeHandler = files.makeFileServeImpl({ +const fileServeHandler = files.createFileHttpHandler({ getOwner: (ctx, req) => ctx.withTx((tx: TransactionCtx) => { const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( @@ -987,7 +987,14 @@ export const send_message = spacetimedb.procedure( }); maybeEmbedMessage(ctx, args.threadId, userMessageId); - runLockedLoop(ctx, cfg, agentName, userId, args.threadId, bumpRateLimit); + runAgentForThread( + ctx, + cfg, + agentName, + userId, + args.threadId, + bumpRateLimit + ); return {}; } ); @@ -1026,7 +1033,7 @@ export const regenerate_response = spacetimedb.procedure( return loaded; }); - runLockedLoop(ctx, cfg, agentName, userId, threadId, bumpRateLimit); + runAgentForThread(ctx, cfg, agentName, userId, threadId, bumpRateLimit); return {}; } ); diff --git a/spacetime-agents-ts/example/src/app.ts b/spacetime-agents-ts/example/src/app.ts index 07324ab950d..6e063fe1e21 100644 --- a/spacetime-agents-ts/example/src/app.ts +++ b/spacetime-agents-ts/example/src/app.ts @@ -135,12 +135,12 @@ let reconnectAttempt = 0; let reconnectTimer: ReturnType | null = null; const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; -function dispatch(name: string, detail: unknown): void { +function emitAppEvent(name: string, detail: unknown): void { window.dispatchEvent(new CustomEvent(name, { detail })); } -function broadcastThreads(): void { +function emitThreads(): void { if (!currentConn) { - dispatch('stdb:threads', { threads: [] }); + emitAppEvent('stdb:threads', { threads: [] }); return; } const sorted = [...currentConn.db.myThreads.iter()].sort((a, b) => { @@ -148,11 +148,11 @@ function broadcastThreads(): void { const bv = b.updatedAt.microsSinceUnixEpoch as bigint; return av < bv ? 1 : av > bv ? -1 : 0; }); - dispatch('stdb:threads', { threads: sorted }); + emitAppEvent('stdb:threads', { threads: sorted }); } -function broadcastMessages(): void { +function emitMessages(): void { if (!currentConn) { - dispatch('stdb:messages', { messages: [], attachments: {} }); + emitAppEvent('stdb:messages', { messages: [], attachments: {} }); return; } const sorted = [...currentConn.db.myMessages.iter()].sort((a, b) => @@ -164,35 +164,38 @@ function broadcastMessages(): void { const key = f.messageId.toString(); (atts[key] ??= []).push(f); } - dispatch('stdb:messages', { messages: sorted, attachments: atts }); + emitAppEvent('stdb:messages', { messages: sorted, attachments: atts }); } -function broadcastLocks(): void { +function emitThreadLocks(): void { if (!currentConn) { - dispatch('stdb:locks', { locks: [] }); + emitAppEvent('stdb:locks', { locks: [] }); return; } const entries: Array<[bigint, boolean]> = []; for (const l of currentConn.db.myThreadLocks.iter()) entries.push([l.threadId, l.cancelRequested]); - dispatch('stdb:locks', { locks: entries }); + emitAppEvent('stdb:locks', { locks: entries }); } -function broadcastOverrides(): void { +function emitAgentOverrides(): void { if (!currentConn) { - dispatch('stdb:overrides', { overrides: [] }); + emitAppEvent('stdb:overrides', { overrides: [] }); return; } - dispatch('stdb:overrides', { + emitAppEvent('stdb:overrides', { overrides: [...currentConn.db.agentOverride.iter()], }); } -function broadcastConfig(): void { - dispatch('stdb:config', { state: configState }); +function emitConfigState(): void { + emitAppEvent('stdb:config', { state: configState }); } -function broadcastConn(state: ConnState, detail?: string): void { - dispatch('stdb:connState', { state, detail }); +function emitConnectionState(state: ConnState, detail?: string): void { + emitAppEvent('stdb:connState', { state, detail }); } -function broadcastAuth(): void { - dispatch('auth:state', { user: currentUser, sessionExpiresAt: currentExp }); +function emitAuthState(): void { + emitAppEvent('auth:state', { + user: currentUser, + sessionExpiresAt: currentExp, + }); } function syncUserFromRow(row: AuthUserRow): void { @@ -204,7 +207,7 @@ function syncUserFromRow(row: AuthUserRow): void { name: row.name ?? undefined, image: row.image ?? undefined, }; - broadcastAuth(); + emitAuthState(); } function requireConn(): DbConnection { @@ -275,14 +278,14 @@ function connect(uri: string, databaseName: string): Promise { resolve(connection); }) .onDisconnect((_ctx, err) => { - broadcastConn('error', err?.message ?? 'disconnected'); + emitConnectionState('error', err?.message ?? 'disconnected'); currentConn = null; globalSub = null; messageSub = null; if (currentUser) scheduleReconnect(); }) .onConnectError((_ctx, err) => { - broadcastConn('error', err?.message ?? 'connect failed'); + emitConnectionState('error', err?.message ?? 'connect failed'); reject(err); }) .build(); @@ -325,13 +328,13 @@ function setActiveThread(threadId: bigint | null): void { messageSub.unsubscribe(); messageSub = null; } - broadcastMessages(); + emitMessages(); if (threadId === null || !currentConn) return; messageSub = currentConn .subscriptionBuilder() - .onApplied(() => broadcastMessages()) + .onApplied(() => emitMessages()) .onError((ctx: ErrorContext) => console.error('message sub error', ctx.event) ) @@ -339,25 +342,25 @@ function setActiveThread(threadId: bigint | null): void { } function registerRowCallbacks(connection: DbConnection): void { - connection.db.myThreads.onInsert(() => broadcastThreads()); - connection.db.myThreads.onUpdate(() => broadcastThreads()); - connection.db.myThreads.onDelete(() => broadcastThreads()); + connection.db.myThreads.onInsert(() => emitThreads()); + connection.db.myThreads.onUpdate(() => emitThreads()); + connection.db.myThreads.onDelete(() => emitThreads()); - connection.db.myMessages.onInsert(() => broadcastMessages()); - connection.db.myMessages.onUpdate(() => broadcastMessages()); - connection.db.myMessages.onDelete(() => broadcastMessages()); + connection.db.myMessages.onInsert(() => emitMessages()); + connection.db.myMessages.onUpdate(() => emitMessages()); + connection.db.myMessages.onDelete(() => emitMessages()); - connection.db.myFiles.onInsert(() => broadcastMessages()); - connection.db.myFiles.onUpdate(() => broadcastMessages()); - connection.db.myFiles.onDelete(() => broadcastMessages()); + connection.db.myFiles.onInsert(() => emitMessages()); + connection.db.myFiles.onUpdate(() => emitMessages()); + connection.db.myFiles.onDelete(() => emitMessages()); - connection.db.myThreadLocks.onInsert(() => broadcastLocks()); - connection.db.myThreadLocks.onUpdate(() => broadcastLocks()); - connection.db.myThreadLocks.onDelete(() => broadcastLocks()); + connection.db.myThreadLocks.onInsert(() => emitThreadLocks()); + connection.db.myThreadLocks.onUpdate(() => emitThreadLocks()); + connection.db.myThreadLocks.onDelete(() => emitThreadLocks()); - connection.db.agentOverride.onInsert(() => broadcastOverrides()); - connection.db.agentOverride.onUpdate(() => broadcastOverrides()); - connection.db.agentOverride.onDelete(() => broadcastOverrides()); + connection.db.agentOverride.onInsert(() => emitAgentOverrides()); + connection.db.agentOverride.onUpdate(() => emitAgentOverrides()); + connection.db.agentOverride.onDelete(() => emitAgentOverrides()); connection.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) => syncUserFromRow(row) @@ -369,7 +372,7 @@ function registerRowCallbacks(connection: DbConnection): void { if (!currentUser || row.userId !== currentUser.userId) return; currentUser = null; currentExp = undefined; - broadcastAuth(); + emitAuthState(); }); } @@ -377,10 +380,10 @@ function subscribeToTables(connection: DbConnection): SubscriptionHandle { return connection .subscriptionBuilder() .onApplied(() => { - broadcastThreads(); - broadcastLocks(); - broadcastOverrides(); - broadcastMessages(); + emitThreads(); + emitThreadLocks(); + emitAgentOverrides(); + emitMessages(); }) .onError((ctx: ErrorContext) => console.error('global sub error', ctx.event) @@ -399,7 +402,7 @@ async function refreshConfigStatus(): Promise { configState = status.isConfigured ? { kind: 'configured', status } : { kind: 'unconfigured' }; - broadcastConfig(); + emitConfigState(); return status; } @@ -414,7 +417,7 @@ async function bindSession( if (!serverCfg) serverCfg = await loadServerConfig(); if (!currentConn) { - broadcastConn('connecting'); + emitConnectionState('connecting'); try { const conn = await connect( serverCfg.spacetimeUri, @@ -422,16 +425,19 @@ async function bindSession( ); currentConn = conn; reconnectAttempt = 0; - broadcastConn('connected'); + emitConnectionState('connected'); - broadcastThreads(); - broadcastMessages(); - broadcastLocks(); - broadcastOverrides(); + emitThreads(); + emitMessages(); + emitThreadLocks(); + emitAgentOverrides(); registerRowCallbacks(conn); } catch (err) { - broadcastConn('error', err instanceof Error ? err.message : String(err)); + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); return; } } @@ -453,7 +459,7 @@ async function bindSession( } await refreshConfigStatus(); - broadcastAuth(); + emitAuthState(); } async function restoreSession(): Promise { @@ -501,11 +507,11 @@ async function logout(): Promise { } currentUser = null; currentExp = undefined; - broadcastThreads(); - broadcastMessages(); - broadcastLocks(); - broadcastOverrides(); - broadcastAuth(); + emitThreads(); + emitMessages(); + emitThreadLocks(); + emitAgentOverrides(); + emitAuthState(); } function oauthStart(provider: 'google' | 'github'): void { @@ -625,14 +631,17 @@ async function main(): Promise { }, }; - broadcastConn('idle'); + emitConnectionState('idle'); serverCfg = await loadServerConfig(); - dispatch('stdb:ready', {}); + emitAppEvent('stdb:ready', {}); await restoreSession(); - dispatch('auth:ready', {}); + emitAppEvent('auth:ready', {}); } main().catch(err => { console.error(err); - broadcastConn('error', err instanceof Error ? err.message : String(err)); + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); }); diff --git a/spacetime-agents-ts/src/submodule/index.ts b/spacetime-agents-ts/src/submodule/index.ts index 2a099b33c2a..0793ba7ef5b 100644 --- a/spacetime-agents-ts/src/submodule/index.ts +++ b/spacetime-agents-ts/src/submodule/index.ts @@ -751,7 +751,11 @@ function maybeRunSummarization(ctx: ProcLikeCtx, threadId: bigint): void { }); } -function adaptTx(tx: WriteCtx, agentName: string, owner: Identity): LoopTx { +function createLoopContext( + tx: WriteCtx, + agentName: string, + owner: Identity +): LoopTx { return { listMessages(threadId: bigint): LoopMessage[] { return threadMessagesAscending(tx, threadId).map(toLoopMessage); @@ -785,7 +789,7 @@ function adaptTx(tx: WriteCtx, agentName: string, owner: Identity): LoopTx { }; } -function runLockedLoop( +function runAgentForThread( ctx: ProcLikeCtx, cfg: LoopConfig, agentName: string, @@ -811,7 +815,7 @@ function runLockedLoop( runAgentLoop({ http: ctx.http, withTx: (fn: (lt: LoopTx) => R): R => - ctx.withTx(tx => fn(adaptTx(tx, agentName, owner))), + ctx.withTx(tx => fn(createLoopContext(tx, agentName, owner))), llmToolDefs: registry.llmToolDefsFor(agentName), cfg: finalCfg, threadId, @@ -870,7 +874,7 @@ export const send_message = spacetimedb.procedure( }); maybeEmbedMessage(ctx, args.threadId, userMessageId); - runLockedLoop(ctx, cfg, agentName, threadOwner, args.threadId); + runAgentForThread(ctx, cfg, agentName, threadOwner, args.threadId); return {}; } ); @@ -912,7 +916,7 @@ export const regenerate_response = spacetimedb.procedure( return loaded; }); - runLockedLoop(ctx, cfg, agentName, threadOwner, threadId); + runAgentForThread(ctx, cfg, agentName, threadOwner, threadId); return {}; } ); diff --git a/spacetime-api-keys-ts/scripts/test.ts b/spacetime-api-keys-ts/scripts/test.ts index 4ecb917f547..788a14fedad 100644 --- a/spacetime-api-keys-ts/scripts/test.ts +++ b/spacetime-api-keys-ts/scripts/test.ts @@ -3,9 +3,9 @@ import { base64Url, extractLookupPrefix, hashApiKey, - hashMatches, + matchesApiKeyHash, hasScope, -} from '../src/key-utils.ts'; +} from '../src/keys.ts'; assert.equal(base64Url(new Uint8Array([102, 111, 111])), 'Zm9v'); assert.equal(base64Url(new Uint8Array([255, 255, 255])), '____'); @@ -18,9 +18,9 @@ assert.equal(extractLookupPrefix('missing-secret'), undefined); const key = 'stdb_live_abcdefghijklmnop'; const hash = hashApiKey(key); assert.match(hash, /^[0-9a-f]{64}$/); -assert.equal(hashMatches(key, hash), true); -assert.equal(hashMatches(`${key}x`, hash), false); -assert.equal(hashMatches(key, 'not-hex'), false); +assert.equal(matchesApiKeyHash(key, hash), true); +assert.equal(matchesApiKeyHash(`${key}x`, hash), false); +assert.equal(matchesApiKeyHash(key, 'not-hex'), false); assert.equal(hasScope('["files:*","jobs:read"]', 'files:write'), true); assert.equal(hasScope('["files:*","jobs:read"]', 'jobs:read'), true); diff --git a/spacetime-api-keys-ts/src/key-utils.ts b/spacetime-api-keys-ts/src/keys.ts similarity index 96% rename from spacetime-api-keys-ts/src/key-utils.ts rename to spacetime-api-keys-ts/src/keys.ts index 7ab63a8c616..72db80e14c7 100644 --- a/spacetime-api-keys-ts/src/key-utils.ts +++ b/spacetime-api-keys-ts/src/keys.ts @@ -45,7 +45,7 @@ export function hashApiKey(key: string): string { return bytesToHex(sha256(textEncoder.encode(key))); } -export function hashMatches(key: string, expectedHex: string): boolean { +export function matchesApiKeyHash(key: string, expectedHex: string): boolean { try { return timingSafeEqual( hexToBytes(expectedHex), diff --git a/spacetime-api-keys-ts/src/submodule/operations.ts b/spacetime-api-keys-ts/src/submodule/operations.ts index 75e1671f5f9..b955e46f55b 100644 --- a/spacetime-api-keys-ts/src/submodule/operations.ts +++ b/spacetime-api-keys-ts/src/submodule/operations.ts @@ -19,10 +19,10 @@ import { base64Url, extractLookupPrefix, hashApiKey, - hashMatches, + matchesApiKeyHash, hasScope, LOOKUP_SECRET_CHARS, -} from '../key-utils'; +} from '../keys'; const DEFAULT_KEY_PREFIX = 'stdb_live'; const MAX_NAME_LENGTH = 120; @@ -382,7 +382,7 @@ export function verifyApiKey( record: false, }); } - if (!hashMatches(key, row.hash)) { + if (!matchesApiKeyHash(key, row.hash)) { return denied(ctx, { prefix, action, diff --git a/spacetime-auth-ts/example/src/app.ts b/spacetime-auth-ts/example/src/app.ts index ead14d5db8f..94d6b8817a4 100644 --- a/spacetime-auth-ts/example/src/app.ts +++ b/spacetime-auth-ts/example/src/app.ts @@ -79,11 +79,11 @@ let currentUser: AuthMe['user'] | null = null; let currentExp: number | undefined; let currentSenderHex: string | undefined; -function dispatch(name: string, detail: unknown) { +function emitAppEvent(name: string, detail: unknown) { window.dispatchEvent(new CustomEvent(name, { detail })); } -function broadcastAuth() { - dispatch('auth:state', { +function emitAuthState() { + emitAppEvent('auth:state', { user: currentUser, senderHex: currentSenderHex, sessionExpiresAt: currentExp, @@ -91,7 +91,7 @@ function broadcastAuth() { } let lastConnState: string = ''; let lastConnDetail: string = ''; -function broadcastConn( +function emitConnectionState( state: 'idle' | 'connecting' | 'connected' | 'error', detail?: string ) { @@ -99,9 +99,9 @@ function broadcastConn( if (state === lastConnState && d === lastConnDetail) return; lastConnState = state; lastConnDetail = d; - dispatch('auth:conn', { state, detail }); + emitAppEvent('auth:conn', { state, detail }); } -function broadcastNotes() { +function emitNotes() { const sorted = conn ? [...conn.db.myNotes.iter()].sort((a, b) => Number( @@ -109,7 +109,7 @@ function broadcastNotes() { ) ) : []; - dispatch('auth:notes', { notes: sorted }); + emitAppEvent('auth:notes', { notes: sorted }); } async function callJson(path: string, body?: unknown): Promise { @@ -176,12 +176,12 @@ function connect(): Promise { resolve(connection); }) .onDisconnect((_ctx, err) => { - broadcastConn('error', err?.message ?? 'disconnected'); + emitConnectionState('error', err?.message ?? 'disconnected'); conn = null; if (currentUser) scheduleReconnect(); }) .onConnectError((_ctx, err) => { - broadcastConn('error', 'connect failed'); + emitConnectionState('error', 'connect failed'); reject(err); }) .build(); @@ -216,14 +216,14 @@ async function bindSession(token: string, user: AuthMe['user'], exp: number) { currentExp = exp; if (!conn) { - broadcastConn('connecting'); + emitConnectionState('connecting'); try { conn = await connect(); registerRowCallbacks(conn); subscribeToTables(conn); - broadcastConn('connected'); + emitConnectionState('connected'); } catch (err) { - broadcastConn('error', (err as Error).message); + emitConnectionState('error', (err as Error).message); return; } } @@ -235,7 +235,7 @@ async function bindSession(token: string, user: AuthMe['user'], exp: number) { } catch (err) { console.warn('link_connection failed', err); } - broadcastAuth(); + emitAuthState(); } function syncUserFromRow(row: AuthUserRow) { @@ -247,21 +247,21 @@ function syncUserFromRow(row: AuthUserRow) { name: row.name ?? undefined, image: row.image ?? undefined, }; - broadcastAuth(); + emitAuthState(); } function subscribeToTables(connection: DbConnection): void { connection .subscriptionBuilder() - .onApplied(() => broadcastNotes()) + .onApplied(() => emitNotes()) .onError((ctx: ErrorContext) => console.error('sub error', ctx.event)) .subscribe([tables.myNotes, tables.myAuthUser]); } function registerRowCallbacks(connection: DbConnection): void { - connection.db.myNotes.onInsert(() => broadcastNotes()); - connection.db.myNotes.onUpdate(() => broadcastNotes()); - connection.db.myNotes.onDelete(() => broadcastNotes()); + connection.db.myNotes.onInsert(() => emitNotes()); + connection.db.myNotes.onUpdate(() => emitNotes()); + connection.db.myNotes.onDelete(() => emitNotes()); connection.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) => syncUserFromRow(row) @@ -273,7 +273,7 @@ function registerRowCallbacks(connection: DbConnection): void { if (!currentUser || row.userId !== currentUser.userId) return; currentUser = null; currentExp = undefined; - broadcastAuth(); + emitAuthState(); }); } @@ -319,8 +319,8 @@ async function logout() { currentUser = null; currentExp = undefined; currentSenderHex = undefined; - broadcastAuth(); - broadcastNotes(); + emitAuthState(); + emitNotes(); } function createNote(args: { title: string; body: string }) { @@ -342,7 +342,7 @@ async function whoami() { if (!conn) throw new Error('not_connected'); const r = await conn.procedures.whoami({}); currentSenderHex = r.senderIdentityHex; - broadcastAuth(); + emitAuthState(); return r; } @@ -418,15 +418,15 @@ window.auth = { }; async function main(): Promise { - broadcastConn('idle'); + emitConnectionState('idle'); try { serverCfg = await loadServerConfig(); } catch (err) { - broadcastConn('error', (err as Error).message); + emitConnectionState('error', (err as Error).message); return; } await restoreSession(); - dispatch('auth:ready', {}); + emitAppEvent('auth:ready', {}); } void main(); diff --git a/spacetime-auth-ts/src/handlers/email_verify.ts b/spacetime-auth-ts/src/handlers/email_verify.ts index 990df87e47e..944d8878dc7 100644 --- a/spacetime-auth-ts/src/handlers/email_verify.ts +++ b/spacetime-auth-ts/src/handlers/email_verify.ts @@ -14,7 +14,7 @@ import { parseQueryString, redirectResponse, requireConfig, -} from './_helpers.ts'; +} from './http.ts'; import { verifyJwt } from '../jwt.ts'; import { publicKeyFromPem } from '../keys.ts'; import { Timestamp } from 'spacetimedb'; diff --git a/spacetime-auth-ts/src/handlers/github.ts b/spacetime-auth-ts/src/handlers/github.ts index 58cd1e7076c..6189c53cac6 100644 --- a/spacetime-auth-ts/src/handlers/github.ts +++ b/spacetime-auth-ts/src/handlers/github.ts @@ -4,7 +4,7 @@ import { type OAuthProfile, type OAuthProviderSpec, } from './oauth.ts'; -import type { AuthHandlerCtx } from './_helpers.ts'; +import type { AuthHandlerCtx } from './http.ts'; const githubHeaders = { accept: 'application/vnd.github+json', diff --git a/spacetime-auth-ts/src/handlers/_helpers.ts b/spacetime-auth-ts/src/handlers/http.ts similarity index 87% rename from spacetime-auth-ts/src/handlers/_helpers.ts rename to spacetime-auth-ts/src/handlers/http.ts index 7378c595a8d..65d4323d0ed 100644 --- a/spacetime-auth-ts/src/handlers/_helpers.ts +++ b/spacetime-auth-ts/src/handlers/http.ts @@ -6,7 +6,7 @@ import type { AuthHandlerCtx, AuthTransactionCtx } from '../context.ts'; export type { AuthHandlerCtx, AuthTransactionCtx }; -export interface CookieOpts { +export interface CookieOptions { maxAgeSeconds?: number; path?: string; domain?: string; @@ -18,20 +18,21 @@ export interface CookieOpts { export function makeCookie( name: string, value: string, - opts: CookieOpts = {} + options: CookieOptions = {} ): string { const parts = [`${name}=${value}`]; - parts.push(`Path=${opts.path ?? '/'}`); - if (opts.maxAgeSeconds != null) parts.push(`Max-Age=${opts.maxAgeSeconds}`); - if (opts.domain) parts.push(`Domain=${opts.domain}`); - if (opts.httpOnly !== false) parts.push('HttpOnly'); - if (opts.secure !== false) parts.push('Secure'); - parts.push(`SameSite=${opts.sameSite ?? 'Lax'}`); + parts.push(`Path=${options.path ?? '/'}`); + if (options.maxAgeSeconds != null) + parts.push(`Max-Age=${options.maxAgeSeconds}`); + if (options.domain) parts.push(`Domain=${options.domain}`); + if (options.httpOnly !== false) parts.push('HttpOnly'); + if (options.secure !== false) parts.push('Secure'); + parts.push(`SameSite=${options.sameSite ?? 'Lax'}`); return parts.join('; '); } -export function clearCookie(name: string, opts: CookieOpts = {}): string { - return makeCookie(name, '', { ...opts, maxAgeSeconds: 0 }); +export function clearCookie(name: string, options: CookieOptions = {}): string { + return makeCookie(name, '', { ...options, maxAgeSeconds: 0 }); } export { shouldUseSecureCookies, userAgent } from '../request-trust.ts'; diff --git a/spacetime-auth-ts/src/handlers/index.ts b/spacetime-auth-ts/src/handlers/index.ts index 0e99deda275..213db3d7c39 100644 --- a/spacetime-auth-ts/src/handlers/index.ts +++ b/spacetime-auth-ts/src/handlers/index.ts @@ -28,6 +28,6 @@ export { readBearer, readSession, shouldUseSecureCookies, - type CookieOpts, -} from './_helpers.ts'; + type CookieOptions, +} from './http.ts'; export type { AuthHttpOptions, TrustedProxyHeader } from '../rate_limit.ts'; diff --git a/spacetime-auth-ts/src/handlers/oauth.ts b/spacetime-auth-ts/src/handlers/oauth.ts index a420c7859ee..70f0b04a9ee 100644 --- a/spacetime-auth-ts/src/handlers/oauth.ts +++ b/spacetime-auth-ts/src/handlers/oauth.ts @@ -19,7 +19,7 @@ import { parseQueryString, redirectResponse, requireConfig, -} from './_helpers.ts'; +} from './http.ts'; import { AUTH_RATE_LIMITS, type AuthHttpOptions, diff --git a/spacetime-auth-ts/src/handlers/password.ts b/spacetime-auth-ts/src/handlers/password.ts index 6bd5013021e..36cb58b2f86 100644 --- a/spacetime-auth-ts/src/handlers/password.ts +++ b/spacetime-auth-ts/src/handlers/password.ts @@ -18,7 +18,7 @@ import { makeCookie, requireConfig, safeJson, -} from './_helpers.ts'; +} from './http.ts'; import { AUTH_RATE_LIMITS, type AuthHttpOptions, diff --git a/spacetime-auth-ts/src/handlers/password_reset.ts b/spacetime-auth-ts/src/handlers/password_reset.ts index e1458d4c0a6..8000c7be4a6 100644 --- a/spacetime-auth-ts/src/handlers/password_reset.ts +++ b/spacetime-auth-ts/src/handlers/password_reset.ts @@ -12,7 +12,7 @@ import { jsonResponse, requireConfig, safeJson, -} from './_helpers.ts'; +} from './http.ts'; import { Timestamp } from 'spacetimedb'; import { AUTH_RATE_LIMITS, diff --git a/spacetime-auth-ts/src/handlers/session.ts b/spacetime-auth-ts/src/handlers/session.ts index 206ca2f3a02..93972d29e9e 100644 --- a/spacetime-auth-ts/src/handlers/session.ts +++ b/spacetime-auth-ts/src/handlers/session.ts @@ -12,7 +12,7 @@ import { requireConfig, ConfigMissingError, userAgent, -} from './_helpers.ts'; +} from './http.ts'; import { signJwt, verifyJwt } from '../jwt.ts'; import { privateKeyFromPem, publicKeyFromPem } from '../keys.ts'; import { newSessionToken, uuidV7 } from '../crypto.ts'; diff --git a/spacetime-auth-ts/src/index.ts b/spacetime-auth-ts/src/index.ts index e407cd06735..0743606e0c1 100644 --- a/spacetime-auth-ts/src/index.ts +++ b/spacetime-auth-ts/src/index.ts @@ -57,27 +57,27 @@ export { readBearer, readSession, configKeys, - type CookieOpts, -} from './handlers/_helpers.ts'; + type CookieOptions, +} from './handlers/http.ts'; export { setAuthConfigParams, - setAuthConfigImpl, - authSweepImpl, + setAuthConfig, + authSweep, revokeSessionParams, - revokeSessionImpl, + revokeSession, listMySessionsParams, - listMySessionsImpl, + listMySessions, revokeMySessionParams, - revokeMySessionImpl, + revokeMySession, getPublicKeyPemParams, - getPublicKeyPemImpl, + getPublicKeyPem, linkConnectionParams, - linkConnectionImpl, + linkConnection, unlinkConnectionParams, - unlinkConnectionImpl, + unlinkConnection, updateProfileParams, - updateProfileImpl, + updateProfile, } from './procedures.ts'; export { diff --git a/spacetime-auth-ts/src/mounted/index.ts b/spacetime-auth-ts/src/mounted/index.ts index cdfbce5fdda..d7508b8cdc7 100644 --- a/spacetime-auth-ts/src/mounted/index.ts +++ b/spacetime-auth-ts/src/mounted/index.ts @@ -13,22 +13,22 @@ import { } from '../tables'; import { setAuthConfigParams, - setAuthConfigImpl, - authSweepImpl, + setAuthConfig, + authSweep, getPublicKeyPemParams, - getPublicKeyPemImpl, + getPublicKeyPem, linkConnectionParams, - linkConnectionImpl, + linkConnection, unlinkConnectionParams, - unlinkConnectionImpl, + unlinkConnection, updateProfileParams, - updateProfileImpl, + updateProfile, revokeSessionParams, - revokeSessionImpl, + revokeSession, listMySessionsParams, - listMySessionsImpl, + listMySessions, revokeMySessionParams, - revokeMySessionImpl, + revokeMySession, passwordSignupHandler, passwordLoginHandler, meHandler, @@ -80,11 +80,11 @@ export const init = spacetimedb.init(ctx => { installAuth(ctx); }); -// On the first set_auth_config call (no PEM supplied), setAuthConfigImpl generates a fresh ES256 keypair in-module. +// On the first set_auth_config call, setAuthConfig generates an ES256 keypair when no PEM is supplied. export const set_auth_config = spacetimedb.reducer( setAuthConfigParams, (ctx, args) => { - setAuthConfigImpl(ctx, args); + setAuthConfig(ctx, args); } ); @@ -95,32 +95,32 @@ export const get_auth_public_key = spacetimedb.procedure( keyId: t.string(), issuerUrl: t.string(), }), - getPublicKeyPemImpl + getPublicKeyPem ); export const link_connection = spacetimedb.reducer( linkConnectionParams, (ctx, args) => { - linkConnectionImpl(ctx, args); + linkConnection(ctx, args); } ); export const unlink_connection = spacetimedb.reducer( unlinkConnectionParams, (ctx, args) => { - unlinkConnectionImpl(ctx, args); + unlinkConnection(ctx, args); } ); export const update_profile = spacetimedb.reducer( updateProfileParams, - updateProfileImpl + updateProfile ); export const revoke_session = spacetimedb.reducer( revokeSessionParams, (ctx, args) => { - revokeSessionImpl(ctx, args); + revokeSession(ctx, args); } ); @@ -138,13 +138,13 @@ export const list_my_sessions = spacetimedb.procedure( }) ), }), - listMySessionsImpl + listMySessions ); export const revoke_my_session = spacetimedb.reducer( revokeMySessionParams, (ctx, args) => { - revokeMySessionImpl(ctx, args); + revokeMySession(ctx, args); } ); @@ -152,7 +152,7 @@ export const auth_sweep = spacetimedb.reducer( { onSchedule: authSweeperTick }, { arg: authSweeperTick.rowType }, (ctx, _arg) => { - authSweepImpl(ctx); + authSweep(ctx); } ); diff --git a/spacetime-auth-ts/src/procedures.ts b/spacetime-auth-ts/src/procedures.ts index 5950eb57ca4..c1808253dc3 100644 --- a/spacetime-auth-ts/src/procedures.ts +++ b/spacetime-auth-ts/src/procedures.ts @@ -41,7 +41,7 @@ export const setAuthConfigParams = { const DEFAULT_COOKIE_NAME = 'stdb_auth'; const DEFAULT_SESSION_TTL_SECONDS = 60n * 60n * 24n * 7n; -export function setAuthConfigImpl( +export function setAuthConfig( ctx: AuthWriteCtx, args: InferTypeOfParams ): void { @@ -125,7 +125,7 @@ export function setAuthConfigImpl( const SWEEP_BATCH = 500; -export function authSweepImpl(ctx: AuthWriteCtx): void { +export function authSweep(ctx: AuthWriteCtx): void { const nowMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; withCtx(ctx, tx => { let n = 0; @@ -161,12 +161,12 @@ export function authSweepImpl(ctx: AuthWriteCtx): void { export const revokeSessionParams = { sessionId: t.string() }; -export function revokeSessionImpl( +export function revokeSession( ctx: AuthWriteCtx, args: InferTypeOfParams ): void { // Admin action for revoking any user's session. Self-service revocation is - // revokeMySessionImpl (caller-scoped). Verdict inside tx, deny outside. + // revokeMySession is caller-scoped. Compute the verdict inside the transaction. const verdict = withCtx(ctx, tx => authAdminVerdict(tx, ctx.sender)); denyIfNotAdmin(verdict); @@ -187,7 +187,7 @@ export interface MySessionSummary { isCurrent: boolean; } -export function listMySessionsImpl( +export function listMySessions( ctx: AuthWriteCtx, _args: Record ): { sessions: MySessionSummary[] } { @@ -220,7 +220,7 @@ export function listMySessionsImpl( export const revokeMySessionParams = { sessionId: t.string() }; -export function revokeMySessionImpl( +export function revokeMySession( ctx: AuthWriteCtx, args: InferTypeOfParams ): void { @@ -237,7 +237,7 @@ export function revokeMySessionImpl( export const getPublicKeyPemParams = {}; -export function getPublicKeyPemImpl( +export function getPublicKeyPem( ctx: AuthWriteCtx, _args: Record ): { publicKeyPem: string; keyId: string; issuerUrl: string } { @@ -257,7 +257,7 @@ export const linkConnectionParams = { sessionToken: t.string() }; const RETRY_FAILED_MSG = 'transaction retry failed again'; -export function linkConnectionImpl( +export function linkConnection( ctx: AuthWriteCtx, args: InferTypeOfParams ): { userId: string } { @@ -315,7 +315,7 @@ export function linkConnectionImpl( export const unlinkConnectionParams = {}; -export function unlinkConnectionImpl( +export function unlinkConnection( ctx: AuthWriteCtx, _args: Record ): void { @@ -335,7 +335,7 @@ export const updateProfileParams = { image: t.option(t.string()), }; -export function updateProfileImpl( +export function updateProfile( ctx: AuthWriteCtx, args: InferTypeOfParams ): void { diff --git a/spacetime-auth-ts/src/rate_limit.ts b/spacetime-auth-ts/src/rate_limit.ts index 34984674a87..7435adb37c0 100644 --- a/spacetime-auth-ts/src/rate_limit.ts +++ b/spacetime-auth-ts/src/rate_limit.ts @@ -3,7 +3,7 @@ import { consumeRateLimit, type RateLimitResult, } from '@spacetimedb/rate-limit/submodule'; -import { errorResponse } from './handlers/_helpers.ts'; +import { errorResponse } from './handlers/http.ts'; import { clientKey, type TrustedProxyHeader } from './request-trust.ts'; import type { AuthHandlerCtx } from './context.ts'; export { diff --git a/spacetime-auth-ts/src/submodule.ts b/spacetime-auth-ts/src/submodule.ts index 2db6bda5508..d0c1f6400ca 100644 --- a/spacetime-auth-ts/src/submodule.ts +++ b/spacetime-auth-ts/src/submodule.ts @@ -31,7 +31,7 @@ export { setAuthConfigParams, getPublicKeyPemParams, linkConnectionParams, - linkConnectionImpl, + linkConnection, unlinkConnectionParams, updateProfileParams, revokeSessionParams, diff --git a/spacetime-files-ts/README.md b/spacetime-files-ts/README.md index 121a2306324..6ea06d855b2 100644 --- a/spacetime-files-ts/README.md +++ b/spacetime-files-ts/README.md @@ -41,8 +41,7 @@ export const init = spacetimedb.init(ctx => { export const upload_file = spacetimedb.procedure( files.uploadFileParams, t.u64(), - (ctx, args) => - files.uploadFileImpl(ctx.as.files, args, ctx.sender.toHexString()) + (ctx, args) => files.uploadFile(ctx.as.files, args, ctx.sender.toHexString()) ); ``` @@ -124,10 +123,7 @@ Validation exports include `validateFileOwner`, `validateFilePath`, ### `uploadFile` ```ts -import { - uploadFileParams, - uploadFileImpl, -} from '@spacetimedb/files/procedures'; +import { uploadFileParams, uploadFile } from '@spacetimedb/files/procedures'; ``` - Args: `path`, `mimeType`, `bytes` (`u8[]`), `visibility`. @@ -151,7 +147,7 @@ import { import { listFilesParams, listFilesReturn, - listFilesImpl, + listFiles, } from '@spacetimedb/files/procedures'; ``` @@ -171,15 +167,15 @@ import { import { readFileBytesParams, readFileBytesReturn, - readFileBytesImpl, + readFileBytes, } from '@spacetimedb/files/procedures'; ``` - Args: `path`. Returns: `{ bytes: u8[], mimeType: string }`. - Owner-gated. Throws `files.not_found` / `files.not_owner`. - **Private files use an authenticated procedure.** SpacetimeDB HTTP route - handlers see the _module's_ identity, so `makeFileServeImpl` serves public - files. Procedures receive the authenticated sender. Wrap `readFileBytesImpl` + handlers see the _module's_ identity, so `createFileHttpHandler` serves public + files. Procedures receive the authenticated sender. Wrap `readFileBytes` in a procedure for private previews and downloads, and use HTTP for cacheable public files. @@ -187,20 +183,20 @@ import { export const read_file_bytes = spacetimedb.procedure( readFileBytesParams, readFileBytesReturn, - (ctx, args) => readFileBytesImpl(ctx, args, ctx.sender.toHexString()) + (ctx, args) => readFileBytes(ctx, args, ctx.sender.toHexString()) ); ``` ## HTTP serve handler ```ts -import { makeFileServeImpl } from '@spacetimedb/files/handlers'; +import { createFileHttpHandler } from '@spacetimedb/files/handlers'; ``` Wire a handler into your module's HTTP routes: ```ts -const serveFile = makeFileServeImpl({ +const serveFile = createFileHttpHandler({ getOwner: _ctx => undefined, }); ``` diff --git a/spacetime-files-ts/example/spacetimedb/src/index.ts b/spacetime-files-ts/example/spacetimedb/src/index.ts index 3c031b3b24b..34efcd1d179 100644 --- a/spacetime-files-ts/example/spacetimedb/src/index.ts +++ b/spacetime-files-ts/example/spacetimedb/src/index.ts @@ -14,11 +14,11 @@ import { FILE_BYTES_MAX, fileSummary, fileSha256Hex, - makeFileServeImpl, + createFileHttpHandler, ownerPathKey, readFileBytesParams, readFileBytesReturn, - readFileBytesImpl, + readFileBytes, validateMimeType, } from '@spacetimedb/files/submodule'; import * as files from '@spacetimedb/files/submodule'; @@ -424,19 +424,19 @@ export const read_file_bytes = spacetimedb.procedure( readFileBytesParams, readFileBytesReturn, (ctx, args) => - readFileBytesImpl( + readFileBytes( ctx, { path: normalizePath(args.path, 'file') }, ctx.sender.toHexString() ) ); -const fileServeImpl = makeFileServeImpl({ +const serveFile = createFileHttpHandler({ getOwner: ctx => ctx.identity?.toHexString?.(), }); export const file_serve = spacetimedb.httpHandler((ctx, req) => { - return fileServeImpl(ctx, req); + return serveFile(ctx, req); }); export const router = spacetimedb.httpRouter( diff --git a/spacetime-files-ts/example/src/app.ts b/spacetime-files-ts/example/src/app.ts index b356f13a60c..246adc5aa5d 100644 --- a/spacetime-files-ts/example/src/app.ts +++ b/spacetime-files-ts/example/src/app.ts @@ -1,21 +1,25 @@ import { DbConnection, tables, type ErrorContext } from './module_bindings/app'; import type { FileSummary, Folder } from './module_bindings/app/types'; import { - loadToken, - saveToken, - clearToken, + loadStdbToken, + saveStdbToken, + clearStdbToken, + type ServerConfig, +} from './session'; +import { parentPath, baseName, joinPath, childPrefix, fileUrl, - fmtSize, - tsMs, + type Visibility, +} from './paths'; +import { + formatFileSize, + timestampMilliseconds, escapeHtml, humanError, - type Visibility, - type ServerConfig, -} from './utils'; +} from './presentation'; import { downloadArchive, downloadFile as saveDownloadedFile, @@ -40,7 +44,7 @@ import { import { VaultSelection } from './selection'; let conn: DbConnection | null = null; -let authToken: string | undefined = loadToken(); +let authToken: string | undefined = loadStdbToken(); async function loadServerConfig(): Promise { const res = await fetch('/api/config'); @@ -56,7 +60,7 @@ function connect(config: ServerConfig): Promise { .withToken(authToken) .onConnect((connection, _identity, token) => { authToken = token; - saveToken(token); + saveStdbToken(token); resolve(connection); }) .onDisconnect((_ctx, err) => { @@ -278,7 +282,7 @@ const thumbCache = new Map(); let thumbGeneration = 0; // Object-URL cache keyed by path@mtime; older revisions revoked on refresh. async function getThumbUrl(row: FileSummary): Promise { - const key = `${row.path}@${tsMs(row.updatedAt)}`; + const key = `${row.path}@${timestampMilliseconds(row.updatedAt)}`; const cached = thumbCache.get(key); if (cached) return cached; const url = URL.createObjectURL(await getFileBlob(row)); @@ -427,7 +431,7 @@ function renderBulkbar(): void { function renderStorage(): void { const total = files.reduce((sum, f) => sum + Number(f.size), 0); $('storage').textContent = files.length - ? `${files.length} file${files.length === 1 ? '' : 's'} | ${fmtSize(total)} stored` + ? `${files.length} file${files.length === 1 ? '' : 's'} | ${formatFileSize(total)} stored` : 'No files stored yet'; } @@ -756,7 +760,7 @@ function downloadFolderZip(folderPath: string): Promise { .filter(f => f.path !== folderPath && f.path.startsWith(prefix)) .map(f => ({ name: `${root}/${f.path.slice(prefix.length)}/`, - mtimeMs: tsMs(f.updatedAt), + mtimeMs: timestampMilliseconds(f.updatedAt), })); return zipAndSave( inFiles, @@ -1144,7 +1148,7 @@ async function main(): Promise { // Stale stored token (server wiped/rekeyed): drop it, retry anonymously. if (!authToken) throw err; authToken = undefined; - clearToken(); + clearStdbToken(); conn = await connect(config); } } catch (err) { diff --git a/spacetime-files-ts/example/src/context-menu.ts b/spacetime-files-ts/example/src/context-menu.ts index 1ed4f0e95a4..4b17faa2d55 100644 --- a/spacetime-files-ts/example/src/context-menu.ts +++ b/spacetime-files-ts/example/src/context-menu.ts @@ -1,6 +1,6 @@ import type { FileSummary } from './module_bindings/app/types'; import { icon } from './rendering'; -import { escapeHtml } from './utils'; +import { escapeHtml } from './presentation'; export type ContextTarget = | { type: 'file' | 'folder'; path: string } diff --git a/spacetime-files-ts/example/src/dialog.ts b/spacetime-files-ts/example/src/dialog.ts index 4beadcfeb3c..9986464646e 100644 --- a/spacetime-files-ts/example/src/dialog.ts +++ b/spacetime-files-ts/example/src/dialog.ts @@ -1,4 +1,4 @@ -import { escapeHtml } from './utils'; +import { escapeHtml } from './presentation'; const element = (id: string): T => document.getElementById(id) as T; diff --git a/spacetime-files-ts/example/src/downloads.ts b/spacetime-files-ts/example/src/downloads.ts index 37c333153d5..873560278b3 100644 --- a/spacetime-files-ts/example/src/downloads.ts +++ b/spacetime-files-ts/example/src/downloads.ts @@ -1,6 +1,7 @@ import type { FileSummary } from './module_bindings/app/types'; import { buildZip, type ZipEntry } from './zip'; -import { baseName, fileUrl, humanError, tsMs } from './utils'; +import { baseName, fileUrl } from './paths'; +import { humanError, timestampMilliseconds } from './presentation'; export const ARCHIVE_FILE_COUNT_MAX = 250; export const ARCHIVE_ENTRY_COUNT_MAX = 1_000; @@ -113,7 +114,7 @@ export async function downloadArchive( entries.push({ name: entryName(file), bytes: new Uint8Array(await blob.arrayBuffer()), - mtimeMs: tsMs(file.updatedAt), + mtimeMs: timestampMilliseconds(file.updatedAt), }); } catch (error) { failures.push(`${baseName(file.path)}: ${humanError(error)}`); diff --git a/spacetime-files-ts/example/src/paths.ts b/spacetime-files-ts/example/src/paths.ts new file mode 100644 index 00000000000..9d83db50127 --- /dev/null +++ b/spacetime-files-ts/example/src/paths.ts @@ -0,0 +1,43 @@ +export type Visibility = 'owner' | 'public'; + +export function normalizePath( + path: string, + kind: 'file' | 'folder' = 'folder' +): string { + let normalized = String(path || '') + .trim() + .replaceAll('\\', '/') + .replace(/\/+/g, '/'); + if (!normalized.startsWith('/')) normalized = '/' + normalized; + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + if (kind === 'file' && normalized === '/') { + throw new Error('file path required'); + } + return normalized; +} + +export function parentPath(path: string): string { + if (path === '/') return '/'; + const separatorIndex = path.lastIndexOf('/'); + return separatorIndex <= 0 ? '/' : path.slice(0, separatorIndex); +} + +export function baseName(path: string): string { + if (path === '/') return '/'; + return path.slice(path.lastIndexOf('/') + 1); +} + +export function joinPath(directory: string, name: string): string { + return directory === '/' ? `/${name}` : `${directory}/${name}`; +} + +// '/docs' must not match '/docs2'. +export function childPrefix(path: string): string { + return path === '/' ? '/' : path + '/'; +} + +export function fileUrl(id: bigint): string { + return `/files?id=${encodeURIComponent(String(id))}`; +} diff --git a/spacetime-files-ts/example/src/presentation.ts b/spacetime-files-ts/example/src/presentation.ts new file mode 100644 index 00000000000..2e0782bd6b0 --- /dev/null +++ b/spacetime-files-ts/example/src/presentation.ts @@ -0,0 +1,101 @@ +import type { Timestamp } from 'spacetimedb'; + +export function formatFileSize( + value: number | bigint | string | undefined +): string { + const bytes = Number(value ?? 0); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +export function timestampMilliseconds( + timestamp: Timestamp | undefined +): number { + if (!timestamp) return 0; + try { + return Number(timestamp.microsSinceUnixEpoch / 1000n); + } catch { + return 0; + } +} + +export function formatTimestamp(timestamp: Timestamp | undefined): string { + const milliseconds = timestampMilliseconds(timestamp); + if (!milliseconds) return ''; + const date = new Date(milliseconds); + if (date.toDateString() === new Date().toDateString()) { + return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +export function escapeHtml(value: unknown): string { + return String(value ?? '').replace( + /[&<>"']/g, + character => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + character + ]! + ); +} + +export function fileKindPresentation(mimeType: string | undefined): { + className: string; + iconName: string; +} { + if (!mimeType) return { className: 'generic', iconName: 'file' }; + if (mimeType.startsWith('image/')) { + return { className: 'image', iconName: 'file-image' }; + } + if (mimeType.startsWith('audio/') || mimeType.startsWith('video/')) { + return { className: 'media', iconName: 'file-media' }; + } + if (mimeType.startsWith('text/') || mimeType === 'application/json') { + return { className: 'text', iconName: 'file-text' }; + } + return { className: 'generic', iconName: 'file' }; +} + +const ERROR_MESSAGES: Record = { + 'vault.folder_not_empty': + "That folder isn't empty. Delete its contents first.", + 'vault.folder_exists': 'A folder with that name already exists here.', + 'vault.file_exists': + 'A file with that name already exists at the destination.', + 'vault.parent_not_found': "That destination folder doesn't exist.", + 'vault.folder_not_found': "That folder doesn't exist.", + 'vault.file_not_found': "That file doesn't exist.", + 'vault.cannot_delete_root': "The root folder can't be deleted.", + 'vault.cannot_rename_root': "The root folder can't be renamed.", + 'vault.invalid_file_path': 'A file needs a name.', + 'vault.invalid_path': "That name isn't allowed.", + 'vault.invalid_visibility': 'That visibility value is invalid.', + 'files.invalid_path': "That name isn't allowed.", + 'files.invalid_visibility': 'That visibility value is invalid.', + 'files.not_found': "That file doesn't exist.", + 'files.invalid_mime_type': 'That file type is invalid.', +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? ''); +} + +export function errorCode(error: unknown): string { + return ( + (errorMessage(error).match(/\b(?:vault|files)\.[a-z_]+/) ?? [])[0] ?? '' + ); +} + +export function humanError( + error: unknown, + context: { name?: string } = {} +): string { + const rawMessage = errorMessage(error) || 'Something went wrong'; + const sizeMatch = rawMessage.match(/^files\.too_large:(\d+)\/(\d+)/); + if (sizeMatch) { + const name = context.name ? `"${context.name}"` : 'That file'; + return `${name} is ${formatFileSize(sizeMatch[1])}. Vault caps files at ${formatFileSize(sizeMatch[2])}.`; + } + return ERROR_MESSAGES[errorCode(error)] ?? rawMessage; +} diff --git a/spacetime-files-ts/example/src/rendering.ts b/spacetime-files-ts/example/src/rendering.ts index 7a315b0fc3c..6bb71932c37 100644 --- a/spacetime-files-ts/example/src/rendering.ts +++ b/spacetime-files-ts/example/src/rendering.ts @@ -1,14 +1,12 @@ import type { FileSummary, Folder } from './module_bindings/app/types'; +import { baseName, childPrefix, parentPath } from './paths'; import { - baseName, - childPrefix, escapeHtml, - fmtSize, - fmtWhen, - kindClass, - parentPath, - tsMs, -} from './utils'; + formatFileSize, + formatTimestamp, + fileKindPresentation, + timestampMilliseconds, +} from './presentation'; export type SortKey = 'name' | 'size' | 'updated' | 'visibility'; export type Entry = { type: 'file' | 'folder'; path: string }; @@ -46,7 +44,8 @@ export function createVaultRendering(getState: () => VaultRenderState) { let result = 0; if (sortKey === 'size') result = Number(a.size) - Number(b.size); else if (sortKey === 'updated') - result = tsMs(a.updatedAt) - tsMs(b.updatedAt); + result = + timestampMilliseconds(a.updatedAt) - timestampMilliseconds(b.updatedAt); else if (sortKey === 'visibility') result = a.visibility.localeCompare(b.visibility); if (result === 0) result = baseName(a.path).localeCompare(baseName(b.path)); @@ -56,7 +55,10 @@ export function createVaultRendering(getState: () => VaultRenderState) { const folderCmp = (a: Folder, b: Folder): number => { const { sortKey, sortDir } = getState(); let result = - sortKey === 'updated' ? tsMs(a.updatedAt) - tsMs(b.updatedAt) : 0; + sortKey === 'updated' + ? timestampMilliseconds(a.updatedAt) - + timestampMilliseconds(b.updatedAt) + : 0; if (result === 0) result = a.name.localeCompare(b.name); return result * sortDir; }; @@ -95,14 +97,14 @@ export function createVaultRendering(getState: () => VaultRenderState) { ``; const fileRowHtml = (file: FileSummary): string => { - const kind = kindClass(file.mimeType); + const kind = fileKindPresentation(file.mimeType); const isPublic = file.visibility === 'public'; return ` ${fileLiOpen(file, 'row')} ${selectionCheckbox(file)} - ${icon(kind.ico)}${escapeHtml(baseName(file.path))} - ${getState().searchQuery ? escapeHtml(parentPath(file.path)) : fmtSize(file.size)} - ${fmtWhen(file.updatedAt)} + ${icon(kind.iconName)}${escapeHtml(baseName(file.path))} + ${getState().searchQuery ? escapeHtml(parentPath(file.path)) : formatFileSize(file.size)} + ${formatTimestamp(file.updatedAt)} @@ -133,15 +135,15 @@ export function createVaultRendering(getState: () => VaultRenderState) { `; const fileTileHtml = (file: FileSummary): string => { - const kind = kindClass(file.mimeType); + const kind = fileKindPresentation(file.mimeType); const isPublic = file.visibility === 'public'; const isImage = (file.mimeType || '').startsWith('image/'); return ` ${fileLiOpen(file, 'tile')} ${selectionCheckbox(file)} ${icon(isPublic ? 'globe' : 'lock')} -
          ${icon(kind.ico)}
          -
          ${icon(kind.ico)}${escapeHtml(baseName(file.path))}
          +
          ${icon(kind.iconName)}
          +
          ${icon(kind.iconName)}${escapeHtml(baseName(file.path))}
          `; }; @@ -181,15 +183,15 @@ export function createVaultRendering(getState: () => VaultRenderState) { } export function fileDetailsHtml(row: FileSummary): string { - const kind = kindClass(row.mimeType); + const kind = fileKindPresentation(row.mimeType); const isImage = (row.mimeType || '').startsWith('image/'); - const updatedAtMs = tsMs(row.updatedAt); + const updatedAtMs = timestampMilliseconds(row.updatedAt); return ` -
          ${icon(kind.ico)}
          -
          ${icon(kind.ico)}${escapeHtml(baseName(row.path))}
          +
          ${icon(kind.iconName)}
          +
          ${icon(kind.iconName)}${escapeHtml(baseName(row.path))}
          Type${escapeHtml(row.mimeType || 'file')}
          -
          Size${fmtSize(row.size)}
          +
          Size${formatFileSize(row.size)}
          Location
          ${updatedAtMs ? `
          Modified${escapeHtml(new Date(updatedAtMs).toLocaleString())}
          ` : ''}
          Visibility${row.visibility === 'public' ? 'Public' : 'Private (owner only)'}
          @@ -203,14 +205,14 @@ export function folderDetailsHtml( stats: { fileCount: number; folderCount: number; bytes: number } ): string { const isRoot = folderPath === '/'; - const updatedAtMs = folder ? tsMs(folder.updatedAt) : 0; + const updatedAtMs = folder ? timestampMilliseconds(folder.updatedAt) : 0; return `
          ${icon('folder')}
          ${icon('folder')}${escapeHtml(isRoot ? 'Root' : (folder?.name ?? ''))}
          TypeFolder
          Contents${stats.fileCount} file${stats.fileCount === 1 ? '' : 's'}, ${stats.folderCount} folder${stats.folderCount === 1 ? '' : 's'}
          -
          Size${fmtSize(stats.bytes)}
          +
          Size${formatFileSize(stats.bytes)}
          ${!isRoot ? `
          Location
          ` : ''} ${updatedAtMs ? `
          Modified${escapeHtml(new Date(updatedAtMs).toLocaleString())}
          ` : ''}
          `; @@ -221,7 +223,7 @@ export function selectionDetailsHtml(rows: readonly FileSummary[]): string { return `
          ${icon('copy')}${rows.length} files selected
          -
          Total size${fmtSize(bytes)}
          +
          Total size${formatFileSize(bytes)}
          Public${rows.filter(row => row.visibility === 'public').length} of ${rows.length}
          `; } diff --git a/spacetime-files-ts/example/src/session.ts b/spacetime-files-ts/example/src/session.ts new file mode 100644 index 00000000000..47b039e53b9 --- /dev/null +++ b/spacetime-files-ts/example/src/session.ts @@ -0,0 +1,30 @@ +export interface ServerConfig { + spacetimeUri: string; + databaseName: string; +} + +const STDB_TOKEN_KEY = 'vault:auth-token'; + +export function loadStdbToken(): string | undefined { + try { + return localStorage.getItem(STDB_TOKEN_KEY) ?? undefined; + } catch { + return undefined; + } +} + +export function saveStdbToken(token: string | undefined): void { + try { + if (token) localStorage.setItem(STDB_TOKEN_KEY, token); + } catch { + // The connection keeps the token in memory when storage is unavailable. + } +} + +export function clearStdbToken(): void { + try { + localStorage.removeItem(STDB_TOKEN_KEY); + } catch { + // Storage is optional. + } +} diff --git a/spacetime-files-ts/example/src/uploads.ts b/spacetime-files-ts/example/src/uploads.ts index 132650a5a62..eee3bba6168 100644 --- a/spacetime-files-ts/example/src/uploads.ts +++ b/spacetime-files-ts/example/src/uploads.ts @@ -1,15 +1,13 @@ import { FILE_BYTES_MAX } from '@spacetimedb/files/constants'; import type { FileSummary } from './module_bindings/app/types'; import type { DialogOptions } from './dialog'; +import { joinPath, normalizePath, type Visibility } from './paths'; import { errorCode, escapeHtml, - fmtSize, + formatFileSize, humanError, - joinPath, - normalizePath, - type Visibility, -} from './utils'; +} from './presentation'; export interface DroppedEntries { files: Array<{ file: File; rel: string }>; @@ -169,7 +167,7 @@ export class UploadController { const accepted = entries.files.filter(entry => { if (entry.file.size <= FILE_BYTES_MAX) return true; failures.push( - `${entry.rel}: ${fmtSize(entry.file.size)} exceeds the ${fmtSize(FILE_BYTES_MAX)} cap` + `${entry.rel}: ${formatFileSize(entry.file.size)} exceeds the ${formatFileSize(FILE_BYTES_MAX)} cap` ); return false; }); diff --git a/spacetime-files-ts/example/src/utils.ts b/spacetime-files-ts/example/src/utils.ts deleted file mode 100644 index beab836ad4a..00000000000 --- a/spacetime-files-ts/example/src/utils.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { Timestamp } from 'spacetimedb'; - -export type Visibility = 'owner' | 'public'; - -export interface ServerConfig { - spacetimeUri: string; - databaseName: string; -} - -// Persisted token = same identity (and files) across reloads. -export const TOKEN_KEY = 'vault:auth-token'; - -export function loadToken(): string | undefined { - try { - return localStorage.getItem(TOKEN_KEY) ?? undefined; - } catch { - return undefined; - } -} - -export function saveToken(token: string | undefined): void { - try { - if (token) localStorage.setItem(TOKEN_KEY, token); - } catch { - /* storage unavailable; token stays in-memory only */ - } -} - -export function clearToken(): void { - try { - localStorage.removeItem(TOKEN_KEY); - } catch { - /* ignore */ - } -} - -export function normalizePath( - path: string, - kind: 'file' | 'folder' = 'folder' -): string { - let out = String(path || '') - .trim() - .replaceAll('\\', '/') - .replace(/\/+/g, '/'); - if (!out.startsWith('/')) out = '/' + out; - if (out.length > 1 && out.endsWith('/')) out = out.slice(0, -1); - if (kind === 'file' && out === '/') throw new Error('file path required'); - return out; -} -export function parentPath(path: string): string { - if (path === '/') return '/'; - const idx = path.lastIndexOf('/'); - return idx <= 0 ? '/' : path.slice(0, idx); -} -export function baseName(path: string): string { - if (path === '/') return '/'; - return path.slice(path.lastIndexOf('/') + 1); -} -export function joinPath(dir: string, name: string): string { - return dir === '/' ? `/${name}` : `${dir}/${name}`; -} -// '/docs' must not match '/docs2'. -export function childPrefix(path: string): string { - return path === '/' ? '/' : path + '/'; -} -export function fileUrl(id: bigint): string { - return `/files?id=${encodeURIComponent(String(id))}`; -} -export function fmtSize(value: number | bigint | string | undefined): string { - const n = Number(value ?? 0); - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - return `${(n / 1024 / 1024).toFixed(2)} MB`; -} -export function tsMs(ts: Timestamp | undefined): number { - if (!ts) return 0; - try { - return Number(ts.microsSinceUnixEpoch / 1000n); - } catch { - return 0; - } -} -export function fmtWhen(ts: Timestamp | undefined): string { - const ms = tsMs(ts); - if (!ms) return ''; - const d = new Date(ms); - if (d.toDateString() === new Date().toDateString()) { - return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); - } - return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); -} -export function escapeHtml(s: unknown): string { - return String(s ?? '').replace( - /[&<>"']/g, - c => - ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ - c - ]! - ); -} -export function kindClass(mime: string | undefined): { - cls: string; - ico: string; -} { - if (!mime) return { cls: 'generic', ico: 'file' }; - if (mime.startsWith('image/')) return { cls: 'image', ico: 'file-image' }; - if (mime.startsWith('audio/') || mime.startsWith('video/')) - return { cls: 'media', ico: 'file-media' }; - if (mime.startsWith('text/') || mime === 'application/json') - return { cls: 'text', ico: 'file-text' }; - return { cls: 'generic', ico: 'file' }; -} - -// Errors are `:`. Parse only the code because detail can contain user paths. -export const ERROR_MESSAGES: Record = { - 'vault.folder_not_empty': - "That folder isn't empty. Delete its contents first.", - 'vault.folder_exists': 'A folder with that name already exists here.', - 'vault.file_exists': - 'A file with that name already exists at the destination.', - 'vault.parent_not_found': "That destination folder doesn't exist.", - 'vault.folder_not_found': "That folder doesn't exist.", - 'vault.file_not_found': "That file doesn't exist.", - 'vault.cannot_delete_root': "The root folder can't be deleted.", - 'vault.cannot_rename_root': "The root folder can't be renamed.", - 'vault.invalid_file_path': 'A file needs a name.', - 'vault.invalid_path': "That name isn't allowed.", - 'vault.invalid_visibility': 'That visibility value is invalid.', - 'files.invalid_path': "That name isn't allowed.", - 'files.invalid_visibility': 'That visibility value is invalid.', - 'files.not_found': "That file doesn't exist.", - 'files.invalid_mime_type': 'That file type is invalid.', -}; -export function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err ?? ''); -} -export function errorCode(err: unknown): string { - return (errorMessage(err).match(/\b(?:vault|files)\.[a-z_]+/) ?? [])[0] ?? ''; -} -export function humanError(err: unknown, ctx: { name?: string } = {}): string { - const raw = errorMessage(err) || 'Something went wrong'; - const big = raw.match(/^files\.too_large:(\d+)\/(\d+)/); - if (big) { - const name = ctx.name ? `"${ctx.name}"` : 'That file'; - return `${name} is ${fmtSize(big[1])}. Vault caps files at ${fmtSize(big[2])}.`; - } - return ERROR_MESSAGES[errorCode(err)] ?? raw; -} diff --git a/spacetime-files-ts/example/src/viewer.ts b/spacetime-files-ts/example/src/viewer.ts index c1f2e6b309c..8cb30204a8f 100644 --- a/spacetime-files-ts/example/src/viewer.ts +++ b/spacetime-files-ts/example/src/viewer.ts @@ -1,5 +1,11 @@ import type { FileSummary } from './module_bindings/app/types'; -import { baseName, escapeHtml, fmtSize, humanError, tsMs } from './utils'; +import { baseName } from './paths'; +import { + escapeHtml, + formatFileSize, + humanError, + timestampMilliseconds, +} from './presentation'; const element = (id: string): T => document.getElementById(id) as T; @@ -108,10 +114,10 @@ export class FileViewer { this.index = index; this.path = row.path; element('lb-title').textContent = baseName(row.path); - const updatedAtMs = tsMs(row.updatedAt); + const updatedAtMs = timestampMilliseconds(row.updatedAt); element('lb-meta').textContent = [ row.mimeType || 'file', - fmtSize(row.size), + formatFileSize(row.size), row.visibility === 'public' ? 'Public' : 'Private', updatedAtMs ? new Date(updatedAtMs).toLocaleString() : '', this.files.length > 1 ? `${index + 1}/${this.files.length}` : '', diff --git a/spacetime-files-ts/src/handlers.ts b/spacetime-files-ts/src/handlers.ts index 1b06435605a..abfb370bb83 100644 --- a/spacetime-files-ts/src/handlers.ts +++ b/spacetime-files-ts/src/handlers.ts @@ -89,7 +89,7 @@ function responseHeaders( }; } -export function makeFileServeImpl(opts: FileServeOptions) { +export function createFileHttpHandler(opts: FileServeOptions) { return (rawCtx: unknown, req: Request): SyncResponse => { const ctx = rawCtx as FileHandlerContext; const method = req.method.toUpperCase(); diff --git a/spacetime-files-ts/src/index.ts b/spacetime-files-ts/src/index.ts index a633389188b..da8db5d799c 100644 --- a/spacetime-files-ts/src/index.ts +++ b/spacetime-files-ts/src/index.ts @@ -27,17 +27,17 @@ export { export { fileSha256Hex, uploadFileParams, - uploadFileImpl, + uploadFile, deleteFileParams, - deleteFileImpl, + deleteFile, listFilesParams, listFilesReturn, - listFilesImpl, + listFiles, readFileBytesParams, readFileBytesReturn, - readFileBytesImpl, + readFileBytes, setFileVisibilityParams, - setFileVisibilityImpl, + setFileVisibility, } from './procedures.ts'; -export { makeFileServeImpl } from './handlers.ts'; +export { createFileHttpHandler } from './handlers.ts'; diff --git a/spacetime-files-ts/src/procedures.ts b/spacetime-files-ts/src/procedures.ts index 79494ec0b4f..69fd889e5d0 100644 --- a/spacetime-files-ts/src/procedures.ts +++ b/spacetime-files-ts/src/procedures.ts @@ -98,7 +98,7 @@ export const uploadFileParams = { visibility: t.string(), }; -export function uploadFileImpl( +export function uploadFile( rawCtx: unknown, args: InferTypeOfParams, owner: string @@ -154,7 +154,7 @@ export const deleteFileParams = { path: t.string(), }; -export function deleteFileImpl( +export function deleteFile( rawCtx: unknown, args: InferTypeOfParams, owner: string @@ -182,7 +182,7 @@ export const listFilesParams = { export const listFilesReturn = fileListPage; // Caller's own files; bytes omitted (fetch via HTTP handler). -export function listFilesImpl( +export function listFiles( rawCtx: unknown, args: InferTypeOfParams, owner: string @@ -258,7 +258,7 @@ export const readFileBytesReturn = t.object('FileBytes', { // Owner-gated byte read. HTTP handlers never see the caller's identity, so // private files can only be read here, over the authenticated connection. -export function readFileBytesImpl( +export function readFileBytes( rawCtx: unknown, args: InferTypeOfParams, owner: string @@ -280,7 +280,7 @@ export const setFileVisibilityParams = { visibility: t.string(), }; -export function setFileVisibilityImpl( +export function setFileVisibility( rawCtx: unknown, args: InferTypeOfParams, owner: string diff --git a/spacetime-grid-ts/README.md b/spacetime-grid-ts/README.md index 457ddff719e..c09d9fd94f3 100644 --- a/spacetime-grid-ts/README.md +++ b/spacetime-grid-ts/README.md @@ -42,8 +42,7 @@ export const init = spacetimedb.init(ctx => { export const create_player_grid = spacetimedb.procedure( grid.createGridParams, t.u64(), - (ctx, args) => - grid.createGridImpl(ctx.as.grid, args, ctx.sender.toHexString()) + (ctx, args) => grid.createGrid(ctx.as.grid, args, ctx.sender.toHexString()) ); ``` diff --git a/spacetime-grid-ts/example/README.md b/spacetime-grid-ts/example/README.md index 0f0a0a0a882..ece7e5dcc09 100644 --- a/spacetime-grid-ts/example/README.md +++ b/spacetime-grid-ts/example/README.md @@ -9,8 +9,8 @@ statistics, turns, and combat rules. - Mounting the Grid and Auth submodules in one host module. - Authenticated match membership and caller-scoped subscriptions. -- Hex-grid pathfinding with `computePathImpl`. -- Movement and attack ranges with `cellsInRangeImpl`. +- Hex-grid pathfinding with `computePath`. +- Movement and attack ranges with `cellsInRange`. - Layering application rules over submodule-owned spatial state. - Human-versus-human matchmaking and a solo match against the built-in Xeno Garrison actor. diff --git a/spacetime-grid-ts/example/spacetimedb/src/index.ts b/spacetime-grid-ts/example/spacetimedb/src/index.ts index 4ac0b4ffee6..916e1e92e7a 100644 --- a/spacetime-grid-ts/example/spacetimedb/src/index.ts +++ b/spacetime-grid-ts/example/spacetimedb/src/index.ts @@ -7,8 +7,8 @@ import { GRID_KIND_HEX, GRID_ORIENTATION_FLAT, GRID_MODE_COLLABORATIVE, - computePathImpl, - cellsInRangeImpl, + computePath, + cellsInRange, } from '@spacetimedb/grid'; import { distance } from '@spacetimedb/grid/math'; @@ -403,7 +403,7 @@ export const move_unit = spacetimedb.procedure( const userId = requireUserId(ctx); // Validate ownership and turn state, then capture coordinates for pathfinding. - // computePathImpl opens its own transaction, so run it after this transaction. + // computePath opens its own transaction, so run it after this transaction. let entityX = 0, entityY = 0; let gridId = 0n; @@ -434,7 +434,7 @@ export const move_unit = spacetimedb.procedure( typeMovement = type.movement; }); - const path = computePathImpl( + const path = computePath( ctx.as.grid, { gridId, @@ -784,7 +784,7 @@ export const ai_take_turn = spacetimedb.procedure( if (!aiUnit.hasMoved && enemyUnits.length > 0) { const cells = ( - cellsInRangeImpl( + cellsInRange( ctx.as.grid, { gridId, @@ -823,7 +823,7 @@ export const ai_take_turn = spacetimedb.procedure( // Capture the A* path BEFORE the move so the client can animate it. const fromX = aiUnit.x, fromY = aiUnit.y; - const pathRes = computePathImpl( + const pathRes = computePath( ctx.as.grid, { gridId, @@ -923,7 +923,7 @@ export const get_cells_in_range = spacetimedb.procedure( }), (ctx, args) => { const userId = requireUserId(ctx); - return cellsInRangeImpl(ctx.as.grid, args, userId) as { + return cellsInRange(ctx.as.grid, args, userId) as { cells: Array<{ x: number; y: number; cost: number }>; }; } diff --git a/spacetime-grid-ts/example/src/app.ts b/spacetime-grid-ts/example/src/app.ts index e241e547911..3d879c6fb55 100644 --- a/spacetime-grid-ts/example/src/app.ts +++ b/spacetime-grid-ts/example/src/app.ts @@ -90,19 +90,22 @@ let reconnectAttempt = 0; let reconnectTimer: ReturnType | null = null; const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; -function dispatch(name: string, detail: unknown): void { +function emitAppEvent(name: string, detail: unknown): void { window.dispatchEvent(new CustomEvent(name, { detail })); } -function broadcastConn(state: ConnState, detail?: string): void { - dispatch('grid:conn', { state, detail }); +function emitConnectionState(state: ConnState, detail?: string): void { + emitAppEvent('grid:conn', { state, detail }); } -function broadcastAuth(): void { - dispatch('grid:auth', { user: currentUser, sessionExpiresAt: currentExp }); +function emitAuthState(): void { + emitAppEvent('grid:auth', { + user: currentUser, + sessionExpiresAt: currentExp, + }); } -function broadcastState(): void { +function emitGridState(): void { const myUserId = currentUser?.userId; if (!currentConn) { - dispatch('grid:state', { + emitAppEvent('grid:state', { myUserId, matches: [], activeMatchId, @@ -140,7 +143,7 @@ function broadcastState(): void { const activeCells = activeGrid ? [...c.db.myCellStates.iter()].filter(c2 => c2.gridId === activeGrid.id) : []; - dispatch('grid:state', { + emitAppEvent('grid:state', { myUserId, matches: matchList, participants: [...c.db.myMatchParticipants.iter()], @@ -202,14 +205,14 @@ function connect(uri: string, databaseName: string): Promise { .withDatabaseName(databaseName) .onConnect(c => resolve(c)) .onDisconnect((_ctx, err) => { - broadcastConn('error', err?.message ?? 'disconnected'); + emitConnectionState('error', err?.message ?? 'disconnected'); currentConn = null; globalSub = null; matchSub = null; if (currentUser) scheduleReconnect(); }) .onConnectError((_ctx, err) => { - broadcastConn('error', err?.message ?? 'connect failed'); + emitConnectionState('error', err?.message ?? 'connect failed'); reject(err); }) .build(); @@ -254,7 +257,7 @@ function setActiveMatch(matchId: bigint | null): void { } if (matchId === null || !currentConn) { - broadcastState(); + emitGridState(); return; } @@ -262,7 +265,7 @@ function setActiveMatch(matchId: bigint | null): void { ? [...currentConn.db.myMatches.iter()].find(x => x.matchId === matchId) : undefined; if (!m) { - broadcastState(); + emitGridState(); return; } @@ -271,7 +274,7 @@ function setActiveMatch(matchId: bigint | null): void { // row + unit_type rows + auth_user rows are already in the global sub. matchSub = currentConn .subscriptionBuilder() - .onApplied(() => broadcastState()) + .onApplied(() => emitGridState()) .onError((ctx: ErrorContext) => console.error('match sub error', ctx.event)) .subscribe([ tables.myPlayerUnits.where(row => row.matchId.eq(matchId)), @@ -294,16 +297,16 @@ function registerRowCallbacks(connection: DbConnection): void { connection.db.lobbyOpenMatches, ]; for (const t of tableAccessors) { - t.onInsert(() => broadcastState()); - t.onUpdate(() => broadcastState()); - t.onDelete(() => broadcastState()); + t.onInsert(() => emitGridState()); + t.onUpdate(() => emitGridState()); + t.onDelete(() => emitGridState()); } } function subscribeToTables(connection: DbConnection): SubscriptionHandle { return connection .subscriptionBuilder() - .onApplied(() => broadcastState()) + .onApplied(() => emitGridState()) .onError((ctx: ErrorContext) => console.error('global sub error', ctx.event) ) @@ -327,7 +330,7 @@ async function bindSession( if (!serverCfg) serverCfg = await loadServerConfig(); if (!currentConn) { - broadcastConn('connecting'); + emitConnectionState('connecting'); try { const conn = await connect( serverCfg.spacetimeUri, @@ -335,9 +338,9 @@ async function bindSession( ); currentConn = conn; reconnectAttempt = 0; - broadcastConn('connected'); + emitConnectionState('connected'); - broadcastState(); + emitGridState(); registerRowCallbacks(conn); @@ -349,7 +352,10 @@ async function bindSession( matchSub = null; if (previousActive !== null) setActiveMatch(previousActive); } catch (err) { - broadcastConn('error', err instanceof Error ? err.message : String(err)); + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); return; } } @@ -360,8 +366,8 @@ async function bindSession( console.warn('link_connection failed', err); } - broadcastAuth(); - broadcastState(); + emitAuthState(); + emitGridState(); } async function restoreSession(): Promise { @@ -412,8 +418,8 @@ async function logout(): Promise { currentUser = null; currentExp = undefined; activeMatchId = null; - broadcastAuth(); - broadcastState(); + emitAuthState(); + emitGridState(); } function oauthStart(provider: 'google' | 'github'): void { window.location.href = `/auth/${provider}/start?redirectTo=/`; @@ -498,7 +504,7 @@ async function main(): Promise { const result = await requireConn().procedures.aiTakeTurn({ matchId }); // Hand the events to the renderer so it can sequence: // move animation → pause → attack flash → target HP drop / death. - dispatch('grid:ai-events', { events: result.events }); + emitAppEvent('grid:ai-events', { events: result.events }); } catch (err) { console.error('ai_take_turn failed:', err); } @@ -515,13 +521,16 @@ async function main(): Promise { }, }; - broadcastConn('idle'); + emitConnectionState('idle'); serverCfg = await loadServerConfig(); await restoreSession(); - dispatch('grid:ready', {}); + emitAppEvent('grid:ready', {}); } main().catch(err => { console.error(err); - broadcastConn('error', err instanceof Error ? err.message : String(err)); + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); }); diff --git a/spacetime-grid-ts/src/index.ts b/spacetime-grid-ts/src/index.ts index c464a222a15..a4638b1f4d1 100644 --- a/spacetime-grid-ts/src/index.ts +++ b/spacetime-grid-ts/src/index.ts @@ -16,23 +16,23 @@ export { export { createGridParams, - createGridImpl, + createGrid, deleteGridParams, - deleteGridImpl, + deleteGrid, setCellCostParams, - setCellCostImpl, + setCellCost, paintCellsParams, - paintCellsImpl, + paintCells, placeEntityParams, - placeEntityImpl, + placeEntity, moveEntityParams, - moveEntityImpl, + moveEntity, computePathParams, computePathReturn, - computePathImpl, + computePath, cellsInRangeParams, cellsInRangeReturn, - cellsInRangeImpl, + cellsInRange, } from './procedures.ts'; export * from './math/index.ts'; diff --git a/spacetime-grid-ts/src/procedures.ts b/spacetime-grid-ts/src/procedures.ts index d0faabce219..9a874ed0d84 100644 --- a/spacetime-grid-ts/src/procedures.ts +++ b/spacetime-grid-ts/src/procedures.ts @@ -63,7 +63,7 @@ export const createGridParams = { mode: t.string(), }; -export function createGridImpl( +export function createGrid( ctx: ProcedureModuleCtx, args: InferTypeOfParams, owner: string @@ -127,7 +127,7 @@ export const deleteGridParams = { gridId: t.u64(), }; -export function deleteGridImpl( +export function deleteGrid( ctx: ProcedureModuleCtx, args: InferTypeOfParams, owner: string @@ -153,7 +153,7 @@ export const setCellCostParams = { terrain: t.option(t.string()), }; -export function setCellCostImpl( +export function setCellCost( ctx: ProcedureModuleCtx, args: InferTypeOfParams, owner: string @@ -180,7 +180,7 @@ export const paintCellsParams = { ), }; -export function paintCellsImpl( +export function paintCells( ctx: ProcedureModuleCtx, args: InferTypeOfParams, owner: string @@ -209,7 +209,7 @@ export const placeEntityParams = { label: t.option(t.string()), }; -export function placeEntityImpl( +export function placeEntity( ctx: ProcedureModuleCtx, args: InferTypeOfParams, owner: string @@ -249,7 +249,7 @@ export const moveEntityParams = { toY: t.i32(), }; -export function moveEntityImpl( +export function moveEntity( ctx: ProcedureModuleCtx, args: InferTypeOfParams, owner: string @@ -295,7 +295,7 @@ export const computePathParams = { export const computePathReturn = pathResult; -export function computePathImpl( +export function computePath( ctx: ProcedureModuleCtx, args: InferTypeOfParams, owner: string @@ -389,7 +389,7 @@ export const cellsInRangeReturn = t.object('CellsInRangeResult', { cells: t.array(reachableCell), }); -export function cellsInRangeImpl( +export function cellsInRange( ctx: ProcedureModuleCtx, args: InferTypeOfParams, owner: string diff --git a/spacetime-lobby-ts/example/src/app.ts b/spacetime-lobby-ts/example/src/app.ts index 0b6e1dcc9bc..703c16b568b 100644 --- a/spacetime-lobby-ts/example/src/app.ts +++ b/spacetime-lobby-ts/example/src/app.ts @@ -43,7 +43,7 @@ let animatedRound = -1; const prevVitals = new Map(); // Maneuver cards appear on hover or focus for elements with [data-maneuver-id]. -function setupTooltip(): void { +function registerTooltipHandlers(): void { const tip = $('tooltip'); let current: Element | null = null; let hideTimer: ReturnType | null = null; @@ -293,7 +293,7 @@ function tokenKey(config: ServerConfig): string { return `${TOKEN_KEY_PREFIX}:${config.spacetimeUri}:${config.databaseName}`; } -function loadToken(config: ServerConfig): string | undefined { +function loadStdbToken(config: ServerConfig): string | undefined { try { return sessionStorage.getItem(tokenKey(config)) ?? undefined; } catch { @@ -301,7 +301,7 @@ function loadToken(config: ServerConfig): string | undefined { } } -function saveToken(config: ServerConfig, token: string): void { +function saveStdbToken(config: ServerConfig, token: string): void { try { sessionStorage.setItem(tokenKey(config), token); } catch { @@ -309,7 +309,7 @@ function saveToken(config: ServerConfig, token: string): void { } } -function clearToken(config: ServerConfig): void { +function clearStdbToken(config: ServerConfig): void { try { sessionStorage.removeItem(tokenKey(config)); } catch { @@ -344,7 +344,7 @@ function connectOnce( .onConnect((connection, identity, token) => { conn = connection; me = identity.toHexString(); - if (token) saveToken(config, token); + if (token) saveStdbToken(config, token); resolve(connection); }) .onDisconnect((_ctx, err) => { @@ -356,12 +356,12 @@ function connectOnce( } async function connect(config: ServerConfig): Promise { - const token = loadToken(config); + const token = loadStdbToken(config); try { return await connectOnce(config, token); } catch (err) { if (!token || !isStaleTokenError(err)) throw err; - clearToken(config); + clearStdbToken(config); showToast('Session expired. Reconnecting.', 'error'); return connectOnce(config); } @@ -1064,7 +1064,7 @@ async function main(): Promise { registerRowCallbacks(); subscribeToTables(connection); registerUiHandlers(); - setupTooltip(); + registerTooltipHandlers(); showToast('Connected.'); render(); } diff --git a/spacetime-posthog-ts/scripts/test.ts b/spacetime-posthog-ts/scripts/test.ts index 22a82476327..a781f620c88 100644 --- a/spacetime-posthog-ts/scripts/test.ts +++ b/spacetime-posthog-ts/scripts/test.ts @@ -3,7 +3,7 @@ import { isOkStatus, toStatusCode, truncateForLog, -} from '../src/submodule/value-utils.ts'; +} from '../src/submodule/http.ts'; import { MAX_DELIVERY_ATTEMPTS, claimHasExpired, diff --git a/spacetime-posthog-ts/src/submodule/auth.ts b/spacetime-posthog-ts/src/submodule/auth.ts index d8508c7b41b..a30567d272d 100644 --- a/spacetime-posthog-ts/src/submodule/auth.ts +++ b/spacetime-posthog-ts/src/submodule/auth.ts @@ -4,7 +4,7 @@ import { type ProcedureModuleCtx, type WriteCtx, } from './schema'; -import { throwSenderError } from './utils'; +import { throwSenderError } from './validation'; type Sender = WriteCtx['sender']; type AdminReadableCtx = { diff --git a/spacetime-posthog-ts/src/submodule/config.ts b/spacetime-posthog-ts/src/submodule/config.ts index 9036b815cb4..84818e8c5ae 100644 --- a/spacetime-posthog-ts/src/submodule/config.ts +++ b/spacetime-posthog-ts/src/submodule/config.ts @@ -5,7 +5,7 @@ import { type WriteCtx, } from './schema'; import { requireAdmin } from './auth'; -import { normalizeHost, throwSenderError } from './utils'; +import { normalizeHost, throwSenderError } from './validation'; export type PostHogConfig = { host: string; diff --git a/spacetime-posthog-ts/src/submodule/http.ts b/spacetime-posthog-ts/src/submodule/http.ts index 4b787cc97f2..04c695f7a0c 100644 --- a/spacetime-posthog-ts/src/submodule/http.ts +++ b/spacetime-posthog-ts/src/submodule/http.ts @@ -1,6 +1,22 @@ import type { ProcedureModuleCtx } from './schema'; import type { PostHogConfig } from './config'; -import { isOkStatus, toStatusCode, truncateForLog } from './utils'; + +const MAX_LOG_BODY_LENGTH = 2048; + +export function truncateForLog(body: string): string { + return body.length <= MAX_LOG_BODY_LENGTH + ? body + : `${body.slice(0, MAX_LOG_BODY_LENGTH)}...`; +} + +export function toStatusCode(status: number): number { + if (!Number.isInteger(status) || status < 0 || status > 0xffff) return 0; + return status; +} + +export function isOkStatus(status: number): boolean { + return status >= 200 && status < 300; +} export type PostHogHttpResult = { ok: boolean; diff --git a/spacetime-posthog-ts/src/submodule/operations.ts b/spacetime-posthog-ts/src/submodule/operations.ts index e44b6dd6d96..02441e78662 100644 --- a/spacetime-posthog-ts/src/submodule/operations.ts +++ b/spacetime-posthog-ts/src/submodule/operations.ts @@ -13,9 +13,9 @@ import { type WriteCtx, } from './schema'; import { loadConfigOrThrowFromProcedure } from './config'; -import { posthogFetch, type PostHogHttpResult } from './http'; +import { isOkStatus, posthogFetch, type PostHogHttpResult } from './http'; import { isAdmin, requireAdmin } from './auth'; -import { isOkStatus, parseJsonObject, throwSenderError } from './utils'; +import { parseJsonObject, throwSenderError } from './validation'; import { claimHasExpired, claimOutboxRow, diff --git a/spacetime-posthog-ts/src/submodule/utils.ts b/spacetime-posthog-ts/src/submodule/validation.ts similarity index 91% rename from spacetime-posthog-ts/src/submodule/utils.ts rename to spacetime-posthog-ts/src/submodule/validation.ts index 204e63aa4dc..19eb4333ef3 100644 --- a/spacetime-posthog-ts/src/submodule/utils.ts +++ b/spacetime-posthog-ts/src/submodule/validation.ts @@ -1,5 +1,4 @@ import { SenderError } from 'spacetimedb/server'; -export { isOkStatus, toStatusCode, truncateForLog } from './value-utils'; export function throwSenderError(message: string): never { throw new SenderError(message); diff --git a/spacetime-posthog-ts/src/submodule/value-utils.ts b/spacetime-posthog-ts/src/submodule/value-utils.ts deleted file mode 100644 index 51793498eba..00000000000 --- a/spacetime-posthog-ts/src/submodule/value-utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -const MAX_LOG_BODY = 2048; - -export function truncateForLog(body: string): string { - return body.length <= MAX_LOG_BODY - ? body - : `${body.slice(0, MAX_LOG_BODY)}...`; -} - -export function toStatusCode(status: number): number { - if (!Number.isInteger(status) || status < 0 || status > 0xffff) return 0; - return status; -} - -export function isOkStatus(status: number): boolean { - return status >= 200 && status < 300; -} diff --git a/spacetime-presence-ts/example/spacetimedb/src/index.ts b/spacetime-presence-ts/example/spacetimedb/src/index.ts index 244fad570ff..70d61b51705 100644 --- a/spacetime-presence-ts/example/spacetimedb/src/index.ts +++ b/spacetime-presence-ts/example/spacetimedb/src/index.ts @@ -1104,7 +1104,7 @@ export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => githubCallbackHandler(ctx.as.auth, req) ); -const fileServeHandler = files.makeFileServeImpl({ +const fileServeHandler = files.createFileHttpHandler({ getOwner: (ctx, req) => ctx.withTx((tx: TransactionCtx) => { const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( diff --git a/spacetime-presence-ts/example/src/app.ts b/spacetime-presence-ts/example/src/app.ts index fa3a87a24c9..ce638860d25 100644 --- a/spacetime-presence-ts/example/src/app.ts +++ b/spacetime-presence-ts/example/src/app.ts @@ -148,7 +148,7 @@ function normalizeError(err: unknown): string { return String(err); } -function emitConn( +function emitConnectionState( state: 'connecting' | 'connected' | 'error', detail?: string ): void { @@ -157,7 +157,7 @@ function emitConn( ); } -function emitAuth(): void { +function emitAuthState(): void { window.dispatchEvent( new CustomEvent('chat:auth', { detail: { @@ -169,7 +169,7 @@ function emitAuth(): void { ); } -function emitData(): void { +function emitPresenceState(): void { if (!conn) { window.dispatchEvent( new CustomEvent('chat:data', { @@ -363,7 +363,7 @@ function scheduleReconnect(): void { reconnectTimer = null; reconnectAttempt++; main().catch(err => { - emitConn('error', normalizeError(err)); + emitConnectionState('error', normalizeError(err)); scheduleReconnect(); }); }, delay); @@ -418,7 +418,7 @@ function connect(cfg: ServerConfig): Promise { .onDisconnect((_ctx, err) => { conn = null; clearHeartbeat(); - emitConn('error', err?.message ?? 'disconnected'); + emitConnectionState('error', err?.message ?? 'disconnected'); scheduleReconnect(); }) .onConnectError((_ctx, err) => reject(err)) @@ -429,7 +429,7 @@ function connect(cfg: ServerConfig): Promise { function subscribeToTables(connection: DbConnection): void { connection .subscriptionBuilder() - .onApplied(() => emitData()) + .onApplied(() => emitPresenceState()) .onError((ctx: ErrorContext) => console.error('subscription error', ctx.event) ) @@ -452,7 +452,7 @@ function subscribeToTables(connection: DbConnection): void { } function registerRowCallbacks(connection: DbConnection): void { - const reRender = () => emitData(); + const reRender = () => emitPresenceState(); const tableAccessors = [ connection.db.myChatUsers, connection.db.myRooms, @@ -480,7 +480,7 @@ function registerRowCallbacks(connection: DbConnection): void { activeServerId = null; activeRoomId = null; } - emitData(); + emitPresenceState(); }); const syncUserFromRow = (row: AuthUserRow) => { @@ -492,7 +492,7 @@ function registerRowCallbacks(connection: DbConnection): void { name: row.name ?? undefined, image: row.image ?? undefined, }; - emitAuth(); + emitAuthState(); }; connection.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) => syncUserFromRow(row) @@ -505,8 +505,8 @@ function registerRowCallbacks(connection: DbConnection): void { if (!authUser || row.userId !== authUser.userId) return; authUser = null; sessionExpiresAt = undefined; - emitAuth(); - emitData(); + emitAuthState(); + emitPresenceState(); }); } @@ -528,8 +528,8 @@ async function bindSession( authUser = refreshedUser; sessionExpiresAt = exp; await c.reducers.heartbeat({}); - emitAuth(); - emitData(); + emitAuthState(); + emitPresenceState(); scheduleHeartbeat(); } @@ -549,7 +549,7 @@ async function restoreSession(): Promise { authUser = null; sessionExpiresAt = undefined; clearAuthToken(); - emitAuth(); + emitAuthState(); clearHeartbeat(); return false; } @@ -587,7 +587,7 @@ function installApi(): void { setActiveServer: (serverId: bigint | null) => { activeServerId = serverId; activeRoomId = null; - emitData(); + emitPresenceState(); }, createRoom: ( serverId: bigint, @@ -680,7 +680,7 @@ function installApi(): void { }, setActiveRoom: (roomId: bigint | null) => { activeRoomId = roomId; - emitData(); + emitPresenceState(); }, heartbeat: () => { return requireConn().reducers.heartbeat({}); @@ -701,8 +701,8 @@ function installApi(): void { sessionExpiresAt = undefined; clearAuthToken(); clearHeartbeat(); - emitAuth(); - emitData(); + emitAuthState(); + emitPresenceState(); }, oauthStart, forgotPassword, @@ -713,7 +713,7 @@ function installApi(): void { whoami: async () => { const r = await requireConn().procedures.whoami({}); meHex = r.senderIdentityHex; - emitAuth(); + emitAuthState(); return { userId: r.userId, senderIdentityHex: r.senderIdentityHex, @@ -773,7 +773,7 @@ function derivePresenceSnapshot() { return { global, typingByRoom }; } -async function initializeIdentity(connection: DbConnection): Promise { +async function loadCurrentIdentity(connection: DbConnection): Promise { const me = await connection.procedures.whoami({}); meHex = me.senderIdentityHex; const snap = derivePresenceSnapshot(); @@ -787,26 +787,26 @@ async function initializeIdentity(connection: DbConnection): Promise { }, }) ); - emitAuth(); + emitAuthState(); } async function main(): Promise { - emitConn('connecting'); + emitConnectionState('connecting'); if (!config) config = await loadServerConfig(); const connection = await connect(config); conn = connection; reconnectAttempt = 0; - emitConn('connected'); + emitConnectionState('connected'); registerRowCallbacks(connection); subscribeToTables(connection); installApi(); - await initializeIdentity(connection); + await loadCurrentIdentity(connection); await restoreSession(); window.dispatchEvent(new CustomEvent('chat:ready')); } main().catch(err => { - emitConn('error', normalizeError(err)); + emitConnectionState('error', normalizeError(err)); scheduleReconnect(); }); diff --git a/spacetime-rate-limit-ts/example/src/app.ts b/spacetime-rate-limit-ts/example/src/app.ts index b5817a15549..817eccd57df 100644 --- a/spacetime-rate-limit-ts/example/src/app.ts +++ b/spacetime-rate-limit-ts/example/src/app.ts @@ -151,7 +151,7 @@ const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; const CONNECT_TIMEOUT_MS = 8000; const TOKEN_STORAGE_PREFIX = 'reactor-clicker.stdb-token'; -function broadcastConn( +function emitConnectionState( state: 'connecting' | 'connected' | 'error', detail?: string ): void { @@ -160,7 +160,7 @@ function broadcastConn( ); } -function broadcastState(): void { +function emitReactorState(): void { if (!currentConn) { window.dispatchEvent( new CustomEvent('reactor:data', { @@ -202,7 +202,7 @@ function broadcastState(): void { ); } -function broadcastEventInsert(row: ReactorEvent): void { +function emitReactorEvent(row: ReactorEvent): void { window.dispatchEvent( new CustomEvent('reactor:eventInserted', { detail: { event: row } }) ); @@ -219,7 +219,7 @@ function isStoredTokenAuthError(err: unknown): boolean { return /unauthorized|verify token|websocket-token/i.test(message); } -function openConnection( +function connectOnce( cfg: ServerConfig, tokenKey: string, token?: string @@ -255,7 +255,7 @@ function openConnection( .onDisconnect((_ctx, err) => { currentConn = null; window.reactor = undefined; - broadcastConn('error', err?.message ?? 'disconnected'); + emitConnectionState('error', err?.message ?? 'disconnected'); scheduleReconnect(); }) .onConnectError((_ctx, err) => settle(() => reject(err))); @@ -268,11 +268,11 @@ async function connect(cfg: ServerConfig): Promise { const tokenKey = `${TOKEN_STORAGE_PREFIX}.${cfg.spacetimeUri}.${cfg.databaseName}`; const token = window.localStorage.getItem(tokenKey) ?? undefined; try { - return await openConnection(cfg, tokenKey, token); + return await connectOnce(cfg, tokenKey, token); } catch (err) { if (token && isStoredTokenAuthError(err)) { window.localStorage.removeItem(tokenKey); - return openConnection(cfg, tokenKey); + return connectOnce(cfg, tokenKey); } throw err; } @@ -288,7 +288,10 @@ function scheduleReconnect(): void { reconnectTimer = null; reconnectAttempt++; main().catch(err => { - broadcastConn('error', err instanceof Error ? err.message : String(err)); + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); scheduleReconnect(); }); }, delay); @@ -311,7 +314,7 @@ function subscribeToTables( state.seenEventIds.add(row.id.toString()); } state.subscriptionApplied = true; - broadcastState(); + emitReactorState(); }) .onError((ctx: ErrorContext) => console.error('subscription error', ctx.event) @@ -331,31 +334,31 @@ function registerRowCallbacks( state: TableSyncState ): void { const db = connection.db as NamespacedDb; - db.reactorState.onInsert(() => broadcastState()); - db.reactorState.onUpdate(() => broadcastState()); - db.reactorState.onDelete(() => broadcastState()); + db.reactorState.onInsert(() => emitReactorState()); + db.reactorState.onUpdate(() => emitReactorState()); + db.reactorState.onDelete(() => emitReactorState()); db.reactorEvents.onInsert((_ctx, row) => { const id = row.id.toString(); const isNewLiveEvent = state.subscriptionApplied && !state.seenEventIds.has(id); state.seenEventIds.add(id); - if (isNewLiveEvent) broadcastEventInsert(row); - broadcastState(); + if (isNewLiveEvent) emitReactorEvent(row); + emitReactorState(); }); - db.reactorEvents.onUpdate(() => broadcastState()); - db.reactorEvents.onDelete(() => broadcastState()); - db.reactorLimitStatus.onInsert(() => broadcastState()); - db.reactorLimitStatus.onUpdate(() => broadcastState()); - db.reactorLimitStatus.onDelete(() => broadcastState()); - db.reactorPlayers.onInsert(() => broadcastState()); - db.reactorPlayers.onUpdate(() => broadcastState()); - db.reactorPlayers.onDelete(() => broadcastState()); - db.reactorShop.onInsert(() => broadcastState()); - db.reactorShop.onUpdate(() => broadcastState()); - db.reactorShop.onDelete(() => broadcastState()); - db.rateLimitDemoConfig.onInsert(() => broadcastState()); - db.rateLimitDemoConfig.onUpdate(() => broadcastState()); - db.rateLimitDemoConfig.onDelete?.(() => broadcastState()); + db.reactorEvents.onUpdate(() => emitReactorState()); + db.reactorEvents.onDelete(() => emitReactorState()); + db.reactorLimitStatus.onInsert(() => emitReactorState()); + db.reactorLimitStatus.onUpdate(() => emitReactorState()); + db.reactorLimitStatus.onDelete(() => emitReactorState()); + db.reactorPlayers.onInsert(() => emitReactorState()); + db.reactorPlayers.onUpdate(() => emitReactorState()); + db.reactorPlayers.onDelete(() => emitReactorState()); + db.reactorShop.onInsert(() => emitReactorState()); + db.reactorShop.onUpdate(() => emitReactorState()); + db.reactorShop.onDelete(() => emitReactorState()); + db.rateLimitDemoConfig.onInsert(() => emitReactorState()); + db.rateLimitDemoConfig.onUpdate(() => emitReactorState()); + db.rateLimitDemoConfig.onDelete?.(() => emitReactorState()); } function requireConn(): DbConnection { @@ -393,7 +396,7 @@ function installReactorActions(): ReactorActions { async function main(): Promise { window.reactor = undefined; - broadcastConn('connecting'); + emitConnectionState('connecting'); if (!serverConfig) { serverConfig = await loadServerConfig(); } @@ -409,9 +412,12 @@ async function main(): Promise { subscribeToTables(conn, tableSyncState); const reactor = installReactorActions(); window.dispatchEvent(new CustomEvent('reactor:ready')); - broadcastConn('connected'); + emitConnectionState('connected'); reactor.start().catch((err: unknown) => { - broadcastConn('error', err instanceof Error ? err.message : String(err)); + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); }); } catch (err) { currentConn = null; @@ -422,6 +428,9 @@ async function main(): Promise { main().catch(err => { console.error('reactor connection failed', err); - broadcastConn('error', err instanceof Error ? err.message : String(err)); + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); scheduleReconnect(); }); diff --git a/spacetime-resend-ts/example/src/app.ts b/spacetime-resend-ts/example/src/app.ts index 2f1dec1bd09..1193f9e73f4 100644 --- a/spacetime-resend-ts/example/src/app.ts +++ b/spacetime-resend-ts/example/src/app.ts @@ -170,17 +170,17 @@ async function connect(config: ServerConfig): Promise { }); } -function table(name: string): TableAccessor | undefined { +function getTableAccessor(name: string): TableAccessor | undefined { const db = (conn?.db ?? {}) as Record | undefined>; return db[name]; } function emails(): ResendEmail[] { - return [...(table('myDispatchEmails')?.iter() ?? [])].sort( - (a, b) => { - return timestampMs(b.createdAt) - timestampMs(a.createdAt); - } - ); + return [ + ...(getTableAccessor('myDispatchEmails')?.iter() ?? []), + ].sort((a, b) => { + return timestampMs(b.createdAt) - timestampMs(a.createdAt); + }); } type Node = { @@ -487,8 +487,8 @@ function scheduleRender() { }, 0); } -function registerTableCallbacks(name: string): void { - const accessor = table(name); +function registerCallbacksForTable(name: string): void { + const accessor = getTableAccessor(name); if (!accessor) throw new Error(`missing table accessor: ${name}`); accessor.onInsert(() => scheduleRender()); accessor.onUpdate(() => scheduleRender()); @@ -497,7 +497,7 @@ function registerTableCallbacks(name: string): void { function registerRowCallbacks(): void { for (const name of ['myDispatchEmails', 'myDispatchDeliveryEvents']) { - registerTableCallbacks(name); + registerCallbacksForTable(name); } } diff --git a/spacetime-resend-ts/src/submodule/auth.ts b/spacetime-resend-ts/src/submodule/auth.ts index 894fbcfd826..44f2a577385 100644 --- a/spacetime-resend-ts/src/submodule/auth.ts +++ b/spacetime-resend-ts/src/submodule/auth.ts @@ -4,7 +4,7 @@ import { type ProcedureModuleCtx, type WriteCtx, } from './schema'; -import { throwSenderError } from './utils'; +import { throwSenderError } from './validation'; // Admin gate. Fresh publishes seed the owner via init. Public submodule calls // never bootstrap admin state from "first caller wins". Procedure callers must diff --git a/spacetime-resend-ts/src/submodule/config.ts b/spacetime-resend-ts/src/submodule/config.ts index aab7c530f47..8f1334afee2 100644 --- a/spacetime-resend-ts/src/submodule/config.ts +++ b/spacetime-resend-ts/src/submodule/config.ts @@ -5,7 +5,7 @@ import { type WriteCtx, } from './schema'; import { adminVerdict, denyIfNotAdmin } from './auth'; -import { throwSenderError } from './utils'; +import { throwSenderError } from './validation'; export type ResendConfig = { apiKey: string; diff --git a/spacetime-resend-ts/src/submodule/http.ts b/spacetime-resend-ts/src/submodule/http.ts index c71b70167ca..9d60bcef084 100644 --- a/spacetime-resend-ts/src/submodule/http.ts +++ b/spacetime-resend-ts/src/submodule/http.ts @@ -1,5 +1,5 @@ import { type ProcedureModuleCtx, vResendErrorBody } from './schema'; -import { attemptToParse, safeJsonParse, throwSenderError } from './utils'; +import { parseWithSchema, safeJsonParse, throwSenderError } from './validation'; import { buildResendHttpRequest } from './request'; export type ResendHttpResponse = { @@ -48,7 +48,7 @@ export function ensureOkOrThrow( export function resendErrorSuffix(body: string): string { const parsed = safeJsonParse(body); if (parsed !== undefined) { - const result = attemptToParse(vResendErrorBody, parsed); + const result = parseWithSchema(vResendErrorBody, parsed); if (result.kind === 'success') { const parts: string[] = []; if (result.data.name) parts.push(`name=${result.data.name}`); diff --git a/spacetime-resend-ts/src/submodule/operations.ts b/spacetime-resend-ts/src/submodule/operations.ts index d9f63cbf7a0..88fceeebcac 100644 --- a/spacetime-resend-ts/src/submodule/operations.ts +++ b/spacetime-resend-ts/src/submodule/operations.ts @@ -13,11 +13,11 @@ import { } from './schema'; import { callResend, ensureOkOrThrow } from './http'; import { - attemptToParse, + parseWithSchema, safeJsonParse, summarizeIssues, throwSenderError, -} from './utils'; +} from './validation'; import { upsertEmail } from './email_writes'; import { loadConfigOrThrowFromProcedure } from './config'; import { adminVerdict, denyIfNotAdmin } from './auth'; @@ -51,7 +51,7 @@ function extractTagFieldsFromJson(tagsJson: string | undefined): { if (!tagsJson) return { userId: undefined, orgId: undefined }; const parsed = safeJsonParse(tagsJson); if (parsed === undefined) return { userId: undefined, orgId: undefined }; - const result = attemptToParse(vTagsForExtraction, parsed); + const result = parseWithSchema(vTagsForExtraction, parsed); if (result.kind === 'error') { return { userId: undefined, orgId: undefined }; } @@ -214,7 +214,7 @@ export function sendEmail(ctx: ProcedureModuleCtx, args: SendEmailArgs) { const parsed = safeJsonParse(response.body); if (parsed === undefined) throwSenderError('resend.send_email_invalid_response'); - const result = attemptToParse(vSendEmailResponse, parsed); + const result = parseWithSchema(vSendEmailResponse, parsed); if (result.kind === 'error') { throwSenderError( `resend.send_email_invalid_response:${summarizeIssues(result.issues)}` diff --git a/spacetime-resend-ts/src/submodule/utils.ts b/spacetime-resend-ts/src/submodule/validation.ts similarity index 94% rename from spacetime-resend-ts/src/submodule/utils.ts rename to spacetime-resend-ts/src/submodule/validation.ts index c792151a0cc..e6947be4bc1 100644 --- a/spacetime-resend-ts/src/submodule/utils.ts +++ b/spacetime-resend-ts/src/submodule/validation.ts @@ -5,7 +5,7 @@ export type ParseResult = | { kind: 'success'; data: T } | { kind: 'error'; issues: v.BaseIssue[] }; -export function attemptToParse( +export function parseWithSchema( schema: TSchema, input: unknown ): ParseResult> { diff --git a/spacetime-resend-ts/src/submodule/webhooks.ts b/spacetime-resend-ts/src/submodule/webhooks.ts index db00308e334..16131c421b1 100644 --- a/spacetime-resend-ts/src/submodule/webhooks.ts +++ b/spacetime-resend-ts/src/submodule/webhooks.ts @@ -21,11 +21,11 @@ import { } from 'spacetimedb/server'; import { assertExhaustive, - attemptToParse, + parseWithSchema, safeJsonParse, summarizeIssues, throwSenderError, -} from './utils'; +} from './validation'; import { parseResendEventType } from './webhook-metadata'; type ResendTags = EmailEvent['data']['tags']; @@ -209,7 +209,7 @@ function applyResendEvent( return { status: WebhookEventStatus.Failed, error: 'invalid JSON payload' }; } - const result = attemptToParse(vEmailEvent, parsed); + const result = parseWithSchema(vEmailEvent, parsed); if (result.kind === 'error') { return { status: WebhookEventStatus.Failed, diff --git a/spacetime-retry-ts/src/handler.ts b/spacetime-retry-ts/src/handler.ts index f78ded50b89..0d376bc61f8 100644 --- a/spacetime-retry-ts/src/handler.ts +++ b/spacetime-retry-ts/src/handler.ts @@ -43,9 +43,9 @@ export function makeRetryDispatch>( ctx: Tx, args: { tag: keyof H & string; value?: unknown } ): RetryResult { - const h = (handlers as Record)[args.tag]; - if (!h) throw new Error(`unknown retry handler: ${args.tag}`); - const run = h[RUN_KEY]; + const handler = (handlers as Record)[args.tag]; + if (!handler) throw new Error(`unknown retry handler: ${args.tag}`); + const run = handler[RUN_KEY]; if ('value' in args) { return (run as (c: Tx, v: unknown) => RetryResult)(ctx, args.value); } diff --git a/spacetime-stripe-ts/README.md b/spacetime-stripe-ts/README.md index a89a93870d6..04c312d2ee8 100644 --- a/spacetime-stripe-ts/README.md +++ b/spacetime-stripe-ts/README.md @@ -223,7 +223,7 @@ ephemeral listener secret. ## Architecture notes -- **valibot for runtime validation.** `vStripeEvent` is a `v.variant('type', [...])` over the 12 supported event types. `attemptToParse` returns a tagged result; `assertExhaustive` makes the typed `switch` compiler-checked. +- **valibot for runtime validation.** `vStripeEvent` is a `v.variant('type', [...])` over the 12 supported event types. `parseWithSchema` returns a tagged result; `assertExhaustive` makes the typed `switch` compiler-checked. - **SDK types, sync HTTP.** The `stripe` npm package supplies event types such as `Stripe.CustomerCreatedEvent`. Procedures use the synchronous `ctx.http.fetch` API through the request boundary in `submodule/http.ts`. - **Compile-time SDK alignment.** `_align*` checks in `schema.ts` assert valibot output is structurally assignable to `Stripe.*Event`. If Stripe ships a breaking change, typecheck fails. - **Idempotency.** Each webhook event is keyed by `event.id`; re-ingest is a no-op. `replay_webhook_event` applies the stored event state again. diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts index 01e6b2840c6..7ee3028b935 100644 --- a/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts @@ -4,7 +4,7 @@ import { type ProcedureModuleCtx, type WriteCtx, } from './schema'; -import { throwSenderError } from './utils'; +import { throwSenderError } from './validation'; // Admin gate. Fresh publishes seed the owner via init. Public reducers never // bootstrap admin state from "first caller wins". diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts index 921e4d311c7..71cb55bc74c 100644 --- a/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts @@ -8,7 +8,7 @@ import { } from './schema'; import * as stripe from '@spacetimedb/stripe/submodule'; import { requireAdmin } from './auth'; -import { stringArrayFromJson, throwSenderError } from './utils'; +import { stringArrayFromJson, throwSenderError } from './validation'; const DEFAULT_STORE_PRODUCTS: Array<{ productId: string; diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/utils.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/validation.ts similarity index 100% rename from spacetime-stripe-ts/example/spacetimedb/src/store/utils.ts rename to spacetime-stripe-ts/example/spacetimedb/src/store/validation.ts diff --git a/spacetime-stripe-ts/example/src/app.ts b/spacetime-stripe-ts/example/src/app.ts index 4b84b365aee..f3023c24577 100644 --- a/spacetime-stripe-ts/example/src/app.ts +++ b/spacetime-stripe-ts/example/src/app.ts @@ -68,7 +68,7 @@ function parsePerks(json: string | undefined): string[] { } } -function broadcastCatalog() { +function emitCatalog() { const sorted = [...products.values()] .filter(p => p.active) .sort((a, b) => { @@ -162,19 +162,19 @@ function registerRowCallbacks(connection: DbConnection): void { connection.db.storeProduct.onInsert( (_ctx: EventContext, row: StoreProductRow) => { products.set(row.productId, row); - broadcastCatalog(); + emitCatalog(); } ); connection.db.storeProduct.onUpdate( (_ctx: EventContext, _oldRow: StoreProductRow, row: StoreProductRow) => { products.set(row.productId, row); - broadcastCatalog(); + emitCatalog(); } ); connection.db.storeProduct.onDelete( (_ctx: EventContext, row: StoreProductRow) => { products.delete(row.productId); - broadcastCatalog(); + emitCatalog(); } ); } @@ -187,7 +187,7 @@ function subscribeToTables(connection: DbConnection): void { for (const row of connection.db.storeProduct.iter() as Iterable) { products.set(row.productId, row); } - broadcastCatalog(); + emitCatalog(); updateConnState('connected'); window.dispatchEvent(new CustomEvent('stdb:ready')); }) diff --git a/spacetime-stripe-ts/src/submodule/auth.ts b/spacetime-stripe-ts/src/submodule/auth.ts index cdaa3e2dbad..00397bccaed 100644 --- a/spacetime-stripe-ts/src/submodule/auth.ts +++ b/spacetime-stripe-ts/src/submodule/auth.ts @@ -4,7 +4,7 @@ import { type ProcedureModuleCtx, type WriteCtx, } from './schema'; -import { throwSenderError } from './utils'; +import { throwSenderError } from './validation'; // Admin gate. Fresh publishes seed the owner via init. Public submodule calls // never bootstrap admin state from "first caller wins". diff --git a/spacetime-stripe-ts/src/submodule/config.ts b/spacetime-stripe-ts/src/submodule/config.ts index 38fc0d211d8..d20131632f8 100644 --- a/spacetime-stripe-ts/src/submodule/config.ts +++ b/spacetime-stripe-ts/src/submodule/config.ts @@ -5,7 +5,7 @@ import { type WriteCtx, } from './schema'; import { adminVerdict, denyIfNotAdmin } from './auth'; -import { throwSenderError } from './utils'; +import { throwSenderError } from './validation'; export type StripeConfig = { secretKey: string; diff --git a/spacetime-stripe-ts/src/submodule/operations.ts b/spacetime-stripe-ts/src/submodule/operations.ts index 552432d964d..ddd8663eb5a 100644 --- a/spacetime-stripe-ts/src/submodule/operations.ts +++ b/spacetime-stripe-ts/src/submodule/operations.ts @@ -27,10 +27,10 @@ import { } from './limits'; import { assertExhaustive, - attemptToParse, + parseWithSchema, safeJsonParse, summarizeIssues, -} from './utils'; +} from './validation'; export function requireProcedureAdmin(ctx: ProcedureModuleCtx): void { const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); @@ -492,7 +492,7 @@ export function applyStripeEvent( return { status: WebhookEventStatus.Failed, error: 'invalid JSON payload' }; } - const result = attemptToParse(vStripeEvent, parsedJson); + const result = parseWithSchema(vStripeEvent, parsedJson); if (result.kind === 'error') { // Distinguish unhandled type (ignore) from handled-but-malformed (fail). const eventTypeRaw = @@ -732,7 +732,7 @@ export function createCustomerInStripeAndSync( } const parsedBody = safeJsonParse(result.body); - const idResult = attemptToParse(vStripeIdResponse, parsedBody); + const idResult = parseWithSchema(vStripeIdResponse, parsedBody); if (idResult.kind === 'error') { throwSenderError( `stripe.create_customer_invalid_response:${summarizeIssues(idResult.issues)}` diff --git a/spacetime-stripe-ts/src/submodule/operations/billing.ts b/spacetime-stripe-ts/src/submodule/operations/billing.ts index 5079f0bc065..86eb4b0a79b 100644 --- a/spacetime-stripe-ts/src/submodule/operations/billing.ts +++ b/spacetime-stripe-ts/src/submodule/operations/billing.ts @@ -13,7 +13,7 @@ import { } from '../schema'; import { loadConfigOrThrowFromProcedure } from '../config'; import { adminVerdict, denyIfNotAdmin } from '../auth'; -import { attemptToParse, safeJsonParse, summarizeIssues } from '../utils'; +import { parseWithSchema, safeJsonParse, summarizeIssues } from '../validation'; import { requireProcedureAdmin, @@ -413,7 +413,7 @@ export const create_checkout_session = spacetimedb.procedure( ); } - const sessionResult = attemptToParse( + const sessionResult = parseWithSchema( vStripeCheckoutSessionResponse, safeJsonParse(response.body) ); @@ -455,7 +455,7 @@ export const create_customer_portal_session = spacetimedb.procedure( ); } - const portalResult = attemptToParse( + const portalResult = parseWithSchema( vStripeBillingPortalSessionResponse, safeJsonParse(response.body) ); diff --git a/spacetime-stripe-ts/src/submodule/utils.ts b/spacetime-stripe-ts/src/submodule/validation.ts similarity index 94% rename from spacetime-stripe-ts/src/submodule/utils.ts rename to spacetime-stripe-ts/src/submodule/validation.ts index c792151a0cc..e6947be4bc1 100644 --- a/spacetime-stripe-ts/src/submodule/utils.ts +++ b/spacetime-stripe-ts/src/submodule/validation.ts @@ -5,7 +5,7 @@ export type ParseResult = | { kind: 'success'; data: T } | { kind: 'error'; issues: v.BaseIssue[] }; -export function attemptToParse( +export function parseWithSchema( schema: TSchema, input: unknown ): ParseResult> { From 42652a2670ebc3b0dcec3ed64da0ec08293cbb35 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 12:28:11 -0400 Subject: [PATCH 25/33] updates --- .github/workflows/ci.yml | 9 +- package.json | 2 +- spacetime-agents-ts/README.md | 8 +- spacetime-api-keys-ts/README.md | 8 +- spacetime-auth-ts/README.md | 13 +- spacetime-auth-ts/example/README.md | 2 +- spacetime-auth-ts/example/server.ts | 2 +- spacetime-auth-ts/spacetimedb/src/index.ts | 4 +- spacetime-auth-ts/src/context.ts | 2 +- spacetime-auth-ts/src/submodule.ts | 19 +- .../src/{mounted => submodule}/index.ts | 89 +---- .../src/{mounted => submodule}/install.ts | 0 spacetime-cron-ts/DESIGN.md | 351 ------------------ spacetime-cron-ts/README.md | 4 +- spacetime-files-ts/README.md | 12 +- spacetime-files-ts/example/README.md | 4 +- spacetime-files-ts/src/procedures.ts | 2 +- spacetime-files-ts/src/rows.ts | 2 +- spacetime-grid-ts/README.md | 6 +- spacetime-grid-ts/example/README.md | 6 +- spacetime-lobby-ts/README.md | 12 +- spacetime-lobby-ts/example/README.md | 6 +- spacetime-posthog-ts/README.md | 12 +- spacetime-posthog-ts/example/README.md | 4 +- spacetime-presence-ts/README.md | 2 +- spacetime-presence-ts/example/README.md | 2 +- .../spacetimedb/src/index.ts | 4 +- spacetime-presence-ts/src/submodule.ts | 6 +- .../src/{mounted => submodule}/index.ts | 0 .../src/{mounted => submodule}/install.ts | 2 +- spacetime-rate-limit-ts/README.md | 10 +- spacetime-rate-limit-ts/example/README.md | 10 +- spacetime-resend-ts/README.md | 6 +- spacetime-resend-ts/example/README.md | 2 +- spacetime-resend-ts/example/server.ts | 2 +- spacetime-resend-ts/src/submodule/webhooks.ts | 6 +- spacetime-retry-ts/README.md | 2 +- spacetime-stripe-ts/README.md | 6 +- spacetime-stripe-ts/example/README.md | 6 +- 39 files changed, 95 insertions(+), 550 deletions(-) rename spacetime-auth-ts/src/{mounted => submodule}/index.ts (54%) rename spacetime-auth-ts/src/{mounted => submodule}/install.ts (100%) delete mode 100644 spacetime-cron-ts/DESIGN.md rename spacetime-presence-ts/src/{mounted => submodule}/index.ts (100%) rename spacetime-presence-ts/src/{mounted => submodule}/install.ts (96%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f79a044ba54..6f6d826f845 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1712,10 +1712,9 @@ jobs: "$RUNNER_TEMP/spacetime/bin/spacetime" version install 2.8.3 --use --yes - name: Check submodules - run: pnpm submodules:check + run: | + pnpm -r -F "./spacetime-*-ts/**" run typecheck + pnpm -r -F "./spacetime-*-ts/**" run test - name: Build submodule packages and examples - run: pnpm submodules:build - - - name: Audit packed production dependencies - run: pnpm submodules:audit:prod + run: pnpm -r -F "./spacetime-*-ts/**" run build diff --git a/package.json b/package.json index ea402ecedaa..eee6d27f423 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ }, "type": "module", "scripts": { - "run-all": "pnpm -r -F ./crates/bindings-typescript -F ./crates/bindings-typescript/examples/quickstart-chat -F ./crates/bindings-typescript/test-app -F ./docs -F \"./templates/**\" -F \"./spacetime-*-ts/**\" run", + "run-all": "pnpm -r -F ./crates/bindings-typescript -F ./crates/bindings-typescript/test-app -F ./docs -F \"./templates/**\" -F \"./spacetime-*-ts/**\" run", "format": "pnpm run-all format && prettier eslint.config.js --write", "lint": "pnpm run-all lint && prettier eslint.config.js --check", "build": "pnpm run-all build", diff --git a/spacetime-agents-ts/README.md b/spacetime-agents-ts/README.md index ca00c389f10..52a4d528263 100644 --- a/spacetime-agents-ts/README.md +++ b/spacetime-agents-ts/README.md @@ -1,6 +1,6 @@ # @spacetimedb/agents -A ready-to-mount agent submodule and lower-level tools for custom SpacetimeDB +A prebuilt agent submodule and lower-level tools for custom SpacetimeDB TypeScript modules. ## Install @@ -17,7 +17,7 @@ For the install-to-publish workflow, see ## Quick start -Mount the standard submodule when you want an identity-owned chat backend with +Register the standard submodule when you want an identity-owned chat backend with private provider keys, caller-scoped views, typed tools, summaries, embeddings, and stale-lock cleanup. @@ -34,7 +34,7 @@ export const init = spacetimedb.init(ctx => { ``` `installAgents` makes the installing identity the first Agents administrator -and schedules stale-lock cleanup. Configure provider keys through the mounted +and schedules stale-lock cleanup. Configure provider keys through the submodule administration operations after publishing the host module. ## Custom integration @@ -140,7 +140,7 @@ views. Package entrypoints: - `@spacetimedb/agents` exports the complete public surface. -- `@spacetimedb/agents/submodule` exports the ready-to-mount Agents schema and +- `@spacetimedb/agents/submodule` exports the prebuilt Agents schema and installer. - `@spacetimedb/agents` exports typed agents, tools, and dispatch. - `@spacetimedb/agents/providers` exports provider adapters. diff --git a/spacetime-api-keys-ts/README.md b/spacetime-api-keys-ts/README.md index f8986eefe30..024affd10de 100644 --- a/spacetime-api-keys-ts/README.md +++ b/spacetime-api-keys-ts/README.md @@ -21,7 +21,7 @@ For the install-to-publish workflow, see ### Integrate into an application -Mount the submodule in the host schema and install its private state from the +Register the submodule in the host schema and install its private state from the host lifecycle hook: ```ts @@ -81,7 +81,7 @@ Key lifecycle operations: - `sweep_api_key_usage({ maxAgeSeconds, maxRows })` removes a bounded audit batch. - `createApiKey`, `rotateApiKey`, `revokeApiKey`, and `verifyApiKey` are host - helper functions for mounted applications. + helper functions for host applications. - `add_admin_identity({ identity })` and `remove_admin_identity({ identity })` manage the administrator allowlist. @@ -90,7 +90,7 @@ Each owner may have up to 50 active, unexpired keys. Expiration may be set up to ## Verify in a host app -Mounted apps can use the transactional helper directly: +Host applications can use the transactional helper directly: ```ts const result = apiKeys.verifyApiKey(ctx.as.apiKeys, { @@ -149,7 +149,7 @@ uses the verified subject from the key record as its owner. Package entrypoints: - `@spacetimedb/api-keys` supports a standalone API-key database. -- `@spacetimedb/api-keys/submodule` supplies the mountable namespace, +- `@spacetimedb/api-keys/submodule` supplies the submodule namespace, helpers, operations, and views for host applications. ## Tables and views diff --git a/spacetime-auth-ts/README.md b/spacetime-auth-ts/README.md index 0c59d34f276..974ee647d6f 100644 --- a/spacetime-auth-ts/README.md +++ b/spacetime-auth-ts/README.md @@ -21,7 +21,7 @@ The host module owns HTTP route registration and any mail-delivery adapter. ### Integrate into an application -Import the mountable namespace, register the handlers your application needs, +Import the submodule namespace, register the handlers your application needs, then install Auth from the host `init` hook. Auth mounts and initializes its Rate Limit dependency. @@ -103,8 +103,9 @@ await conn.reducers.updateProfile({ name: 'Ada', image: undefined }); The root entrypoint exports table builders, password and OAuth handlers, JWT and key helpers, connection-binding procedures, and caller helpers. The -`./submodule` entrypoint exports the mountable schema, registered operations, -views, handlers, and `installAuth`. The host module owns `init`. +`./submodule` entrypoint exports the submodule schema, registered database +operations, views, handler factories, and `installAuth`. The host module owns +`init`, HTTP routing, cookie policy, and mail delivery. Supported flows: @@ -116,14 +117,12 @@ Supported flows: - Caller profile reads and updates - Fixed-window limits for authentication endpoints -Mounted operations: +Submodule operations: - Configuration and keys: `set_auth_config`, `get_auth_public_key`. - Connection binding: `link_connection`, `unlink_connection`, and `whoami`. - Profiles and sessions: `update_profile`, `list_my_sessions`, `revoke_my_session`, and administrative `revoke_session`. -- HTTP handlers: password signup/login, session refresh, current user, logout, - Google and GitHub OAuth, password reset, and email verification. - Caller helpers: `getCallerUserId` and the `my_auth_user` scoped view. The handler exports are `passwordSignupHandler`, `passwordLoginHandler`, @@ -171,7 +170,7 @@ pnpm run typecheck ``` The unit suite covers key generation, JWT validation, password hashing, PKCE, -tokens, and UUID generation. Build the example module to validate mounted +tokens, and UUID generation. Build the example module to validate submodule schema integration. ## License diff --git a/spacetime-auth-ts/example/README.md b/spacetime-auth-ts/example/README.md index 936701a01f5..dd0f6e5fe2a 100644 --- a/spacetime-auth-ts/example/README.md +++ b/spacetime-auth-ts/example/README.md @@ -187,7 +187,7 @@ spacetime sql --server http://127.0.0.1:3000 spacetime-auth-example "SELECT * FR ## Important files -- `spacetimedb/src/index.ts` - auth mount, scoped views, notes, and HTTP handlers. +- `spacetimedb/src/index.ts` - Auth registration, scoped views, notes, and HTTP handlers. - `server.ts` - startup configuration, static serving, and auth proxy. - `src/app.ts` - auth calls, connection linking, subscriptions, and reconnects. - `public/index.html` - notes and account-management interface. diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts index 546ed9087e6..2758b506f19 100644 --- a/spacetime-auth-ts/example/server.ts +++ b/spacetime-auth-ts/example/server.ts @@ -104,7 +104,7 @@ app.get('/auth/password/reset', (_req: Request, res: Response) => { // Using app.use as middleware since Express 4's `app.all('/auth/*', ...)` does // not match nested paths reliably. app.use('/auth', async (req, res) => { - const fullPath = `/auth${req.url}`; // req.url here is relative to /auth mount + const fullPath = `/auth${req.url}`; // req.url is relative to the /auth route prefix const qIdx = fullPath.indexOf('?'); const path = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); const query = qIdx < 0 ? '' : fullPath.slice(qIdx); diff --git a/spacetime-auth-ts/spacetimedb/src/index.ts b/spacetime-auth-ts/spacetimedb/src/index.ts index ddd02a77d65..160a0b54719 100644 --- a/spacetime-auth-ts/spacetimedb/src/index.ts +++ b/spacetime-auth-ts/spacetimedb/src/index.ts @@ -1,2 +1,2 @@ -export { default } from '../../src/mounted/index'; -export * from '../../src/mounted/index'; +export { default } from '../../src/submodule/index'; +export * from '../../src/submodule/index'; diff --git a/spacetime-auth-ts/src/context.ts b/spacetime-auth-ts/src/context.ts index 4abb09b4978..c1df949fbc1 100644 --- a/spacetime-auth-ts/src/context.ts +++ b/spacetime-auth-ts/src/context.ts @@ -21,7 +21,7 @@ const authSweeperTick = table( ); // This schema exists only to derive the context types shared by the package's -// reducer, procedure, view, and HTTP implementations. Runtime modules mount the +// reducer, procedure, view, and HTTP implementations. Runtime modules register the // same auth tables and the rate-limit submodule under their own schema. const _authContextSchema = schema({ ...authTables, diff --git a/spacetime-auth-ts/src/submodule.ts b/spacetime-auth-ts/src/submodule.ts index d0c1f6400ca..44a293f37d8 100644 --- a/spacetime-auth-ts/src/submodule.ts +++ b/spacetime-auth-ts/src/submodule.ts @@ -1,19 +1,6 @@ -export { default } from './mounted/index'; -export { installAuth } from './mounted/install'; +export { default } from './submodule/index'; +export { installAuth } from './submodule/install'; export { - authEmailVerify, - authEmailVerifyRequest, - authGithubCallback, - authGithubStart, - authGoogleCallback, - authGoogleStart, - authLogout, - authMe, - authPasswordForgot, - authPasswordLogin, - authPasswordReset, - authPasswordSignup, - authRefresh, auth_sweep, get_auth_public_key, link_connection, @@ -25,7 +12,7 @@ export { unlink_connection, update_profile, whoami, -} from './mounted/index'; +} from './submodule/index'; export { setAuthConfigParams, diff --git a/spacetime-auth-ts/src/mounted/index.ts b/spacetime-auth-ts/src/submodule/index.ts similarity index 54% rename from spacetime-auth-ts/src/mounted/index.ts rename to spacetime-auth-ts/src/submodule/index.ts index d7508b8cdc7..0dd0e965da8 100644 --- a/spacetime-auth-ts/src/mounted/index.ts +++ b/spacetime-auth-ts/src/submodule/index.ts @@ -1,4 +1,4 @@ -import { schema, t, table, Router } from 'spacetimedb/server'; +import { schema, t, table } from 'spacetimedb/server'; import * as rateLimit from '@spacetimedb/rate-limit/submodule'; import { installAuth } from './install'; import { @@ -29,31 +29,9 @@ import { listMySessions, revokeMySessionParams, revokeMySession, - passwordSignupHandler, - passwordLoginHandler, - meHandler, - logoutHandler, - refreshHandler, - googleStartHandler, - googleCallbackHandler, - githubStartHandler, - githubCallbackHandler, - makeForgotPasswordHandler, - resetPasswordHandler, - makeEmailVerifyRequestHandler, - makeEmailVerifyHandler, getCallerUserId, - type SendMailFn, - type MailParams, } from '../index'; -// Development mailer that logs messages to the SpacetimeDB console. -const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { - console.log( - `[mail] to=${params.to} subject=${params.subject}\n${params.text}` - ); -}; - const authSweeperTick = table( { name: 'auth_sweeper_tick' }, { @@ -181,68 +159,3 @@ export const whoami = spacetimedb.procedure( }; } ); - -const localAuthHttp = { secureCookies: false } as const; - -export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => - passwordSignupHandler(ctx, req, localAuthHttp) -); -export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => - passwordLoginHandler(ctx, req, localAuthHttp) -); -export const authMe = spacetimedb.httpHandler(meHandler); -export const authLogout = spacetimedb.httpHandler((ctx, req) => - logoutHandler(ctx, req, localAuthHttp) -); -export const authRefresh = spacetimedb.httpHandler((ctx, req) => - refreshHandler(ctx, req, localAuthHttp) -); -export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => - googleStartHandler(ctx, req, localAuthHttp) -); -export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => - googleCallbackHandler(ctx, req, localAuthHttp) -); -export const authGithubStart = spacetimedb.httpHandler((ctx, req) => - githubStartHandler(ctx, req, localAuthHttp) -); -export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => - githubCallbackHandler(ctx, req, localAuthHttp) -); - -const forgotHandler = makeForgotPasswordHandler({ - sendMail: consoleSendMail, - appName: 'auth-ts', -}); -const verifyRequestHandler = makeEmailVerifyRequestHandler({ - sendMail: consoleSendMail, - appName: 'auth-ts', -}); -const verifyHandler = makeEmailVerifyHandler({ - successRedirect: '/?verified=1', -}); - -export const authPasswordForgot = spacetimedb.httpHandler(forgotHandler); -export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => - resetPasswordHandler(ctx, req, localAuthHttp) -); -export const authEmailVerifyRequest = - spacetimedb.httpHandler(verifyRequestHandler); -export const authEmailVerify = spacetimedb.httpHandler(verifyHandler); - -export const router = spacetimedb.httpRouter( - new Router() - .post('/auth/password/signup', authPasswordSignup) - .post('/auth/password/login', authPasswordLogin) - .post('/auth/session/refresh', authRefresh) - .get('/auth/me', authMe) - .post('/auth/logout', authLogout) - .get('/auth/google/start', authGoogleStart) - .get('/auth/google/callback', authGoogleCallback) - .get('/auth/github/start', authGithubStart) - .get('/auth/github/callback', authGithubCallback) - .post('/auth/password/forgot', authPasswordForgot) - .post('/auth/password/reset', authPasswordReset) - .post('/auth/email/verify-request', authEmailVerifyRequest) - .get('/auth/email/verify', authEmailVerify) -); diff --git a/spacetime-auth-ts/src/mounted/install.ts b/spacetime-auth-ts/src/submodule/install.ts similarity index 100% rename from spacetime-auth-ts/src/mounted/install.ts rename to spacetime-auth-ts/src/submodule/install.ts diff --git a/spacetime-cron-ts/DESIGN.md b/spacetime-cron-ts/DESIGN.md deleted file mode 100644 index fe463fd5a3a..00000000000 --- a/spacetime-cron-ts/DESIGN.md +++ /dev/null @@ -1,351 +0,0 @@ -# Cron architecture - -This document defines the runtime invariants and transaction behavior for -`@spacetimedb/cron`. - -## Design goals - -The package provides: - -1. Stable job identity in ordinary database state. -2. Calendar scheduling with time zones and daylight-saving transitions. -3. Native fixed intervals. -4. Statically registered reducer and procedure handlers. -5. Typed arguments stored with each configured job. -6. Rollback of partial reducer writes when a handler fails. -7. Generation-safe rescheduling and cancellation. -8. Detectable and repairable loss of volatile recovery work. -9. Bounded operational history. -10. A direct path to nested transactions when the platform supports them. - -The current reducer failure path uses -`volatile_nonatomic_schedule_immediate`. That host API is an unstable, -best-effort bridge for code that needs rollback plus follow-up work before -SpacetimeDB supports nested transactions. The invariant reconciler bounds its -crash limitation without adding another application-work hop. - -## Tables - -### `cron_job` - -One private row represents each configured job. The row contains the schedule, -typed arguments, enabled state, failure policy, generation, fire count, last -outcome time, next logical occurrence, and disable reason. - -The job name is the stable primary key. Scheduling, rescheduling, disabling, -and re-enabling preserve the row. - -The argument column is a tagged union generated from the static handles passed -to `createCron()`. Argumentless jobs use a unit payload. Rescheduling replaces -the schedule and argument value atomically. - -### `cron_jobs` view - -The optional public anonymous view projects operational fields from -`cron_job`. It omits the argument column so clients can subscribe to schedule -and health state without receiving private application payloads. Detailed -disablement errors also remain private. The view maps them to stable reason -codes for operator disablement, failure thresholds, lost-fire thresholds, -invalid schedule state, and otherwise unspecified disablement. - -### `_fire` - -Each statically declared job owns one schedule table bound directly to its -`_cron` reducer or procedure. - -An enabled job owns exactly one row in its fire table: - -- Calendar jobs use a chain of one-shot `ScheduleAt.time` rows. -- Fixed-rate jobs use one persistent native `ScheduleAt.interval` row. - -The row carries the job generation. Calendar rows also carry `targetAt`, the -logical occurrence represented by the trigger. The physical `scheduledAt` -may be an earlier checkpoint when the target exceeds the host timer horizon. -The optional `recovery` field is empty in stored rows. A volatile invocation -sets it to the failed sequence, logical occurrence, and bounded error. - -### `cron_run` - -The run table contains completed `Ok` and `Failed` outcomes. Each row carries -the stable invocation ID, job name, generation, sequence, logical scheduled -time, completion time, and bounded error text. - -History is pruned synchronously by per-job sequence. No sweeper or retention -schedule is required. - -### `cron_reconcile_tick` - -When `reconcileEverySeconds` is configured, this schedule table contains one -native interval row bound to `cron_reconcile`. The sweep scans the statically -registered jobs and repairs broken fire invariants. It is recovery machinery, -not run-history retention. - -## Registration model - -`cronTable()` creates a typed job handle. `createCron()` builds `cron_job`, -`cron_run`, and one fire table for each handle. `cronReducer()` or -`cronProcedure()` binds one application handler directly to that job's fire -table. The reducer wrapper also handles volatile recovery calls for that job. -`cron.reconcileReducer()` registers the optional interval reconciler. -`cron.publicViews()` registers the optional sanitized job-state view. - -An argument-bearing `cronTable()` carries its SpacetimeDB type builder. -`createCron()` combines those builders into the private `CronJobArgsValue` -union. Variants are ordered by job name so changing the order of handles passed -to `createCron()` does not change the generated schema. - -Every job must register exactly one handler. Scheduling rejects a core with any -missing handler. When `reconcileEverySeconds` is configured, the consumer also -exports `cron.reconcileReducer()`. Registration and construction reject missing -handlers, duplicate handlers, duplicate jobs, foreign handles, multiple cores, -invalid names, and table-key collisions. - -The package reserves: - -- `cron_job` -- `cron_run` -- `cron_jobs` -- `cron_reconcile` -- `cron_reconcile_tick` -- every `_fire` table -- every `_cron` scheduled function - -The factory receives `table`, `t`, `ScheduleAt`, `Timestamp`, and -`SenderError` from the consumer. Table builders contain SDK-private -registration symbols, so using the consumer's SDK values keeps all generated -tables on the same SDK instance as the host schema. - -## Scheduling and generations - -`schedule()` validates the schedule, time zone, interval, failure policy, and -argument presence before changing state. It ensures the optional reconciliation -interval exists, repairs broken fire invariants for configured jobs, and then -performs the requested state change in the same transaction: - -1. Delete the current fire row, if one exists. -2. Increment the job generation. -3. Upsert `cron_job` with the new schedule and typed arguments. -4. Insert one new fire row. -5. Store the logical next occurrence. - -`unschedule()` runs the same opportunistic reconciliation before deleting the -target fire row, incrementing its generation, disabling it, and retaining its -job state and history. - -Every fire row carries the generation that created it. Normal and recovery -invocations compare that generation with the current job row. Delayed work from -an earlier configuration cannot restore or modify a replacement schedule. - -## Reducer execution - -A reducer job executes in one scheduled transaction on the success path: - -1. Verify the job exists, is enabled, and matches the fire generation. -2. Verify the tagged argument variant matches the job. -3. For a calendar job, delete the consumed row and insert its successor. -4. Build the invocation metadata and read the typed argument value. -5. Execute the application handler. -6. Record `Ok`, update health, and prune history. - -The successor, application writes, job health, and run record commit together. -A successful calendar fire therefore advances atomically with its application -work. Native interval rows persist without rearming. - -Reducers must complete synchronously. Returning a thenable is treated as a -handler failure. - -## Reducer failure and volatile recovery - -SpacetimeDB reducers do not currently support nested transactions or -savepoints. If application work fails after making writes, those writes must be -rolled back. Rethrowing the error accomplishes that, but also rolls back the -calendar successor inserted earlier in the same transaction. - -The middleware uses this temporary recovery sequence: - -1. Catch the handler error. -2. Copy the fire argument and set its private `recovery` field to the sequence, - scheduled time, and bounded error. -3. Serialize that row with the fire table's SDK row serializer and submit it to - the same `_cron` reducer through - `volatile_nonatomic_schedule_immediate`. -4. Rethrow the original error. -5. Let the fire transaction roll back. -6. Run the same reducer in a fresh transaction. Its recovery branch does not - call the application handler. -7. Restore the calendar chain, record `Failed`, update failure state, and apply - automatic disablement. - -The recovery branch validates the sender, job name, generation, and expected -sequence. It ignores stale or duplicate work. The explicit payload also works -for native interval jobs, whose schedule row remains present after failure. - -The direct host ABI remains isolated behind the recovery adapter. The package -uses the SDK fire-row builder and `BinaryWriter` for BSATN serialization. A -future SDK wrapper can replace `sys-abi.d.ts` and the direct host import without -changing the public cron API. - -### Volatile crash gap - -The volatile call is best effort and is not stored in the commit log. A process -crash, uncatchable trap, or lost volatile message can prevent the recovery -invocation from executing. For a failed calendar reducer, that can temporarily -leave an enabled job without a pending fire. A native interval row persists -independently, although its failure outcome can still be lost. - -The stable `cron_job` row makes this state machine-detectable. Every scheduling -or cancellation operation opportunistically scans configured jobs. If -`reconcileEverySeconds` is configured, one native interval sweep performs the -same scan at a bounded cadence. An enabled job with no valid current-generation -fire is disarmed, rearmed from the current transaction time, and assigned one -`Failed` run with error `lost_fire`. Normal failure policy, history pruning, and -automatic disablement apply to that outcome. - -The reconciler also treats a current-generation fire with the wrong trigger -shape as lost. A calendar job requires `targetAt`; an interval job must not have -it. This prevents a malformed row from satisfying the invariant while being -unable to execute correctly. - -The interval sweep is optional. Without it, repair occurs on the next -`schedule()` or `unschedule()` call. With it, the crash gap is bounded by the -configured interval and scheduler availability. This remains a temporary -best-effort design rather than crash-proof execution. - -### Recovery dispatch - -The private `recovery` field determines which branch runs. Recovery does not -depend on schedule-row presence, sender heuristics, or cleanup timing. This is -required for native interval rows because they persist after a failed fire. - -Calendar recovery replaces the visible fire state before it inserts the next -occurrence. Generation and sequence guards make stale or duplicate recovery -messages no-ops. - -## Procedure execution - -Procedures are not single transactions, so they do not need volatile recovery -to roll back application database work. A scheduled procedure executes in -three phases: - -1. A `withTx` callback verifies the generation, advances a calendar chain, - snapshots the typed arguments, and reserves the invocation sequence. -2. The handler performs procedure work. -3. A second `withTx` callback records `Ok` or `Failed`, updates health, and - prunes history. - -The first phase commits before external work begins, so a process failure during -a procedure does not remove the next calendar fire. It can lose the current run -record or leave an external operation with an unknown outcome. Handlers should -use `CronInvocation.id` as an idempotency key when the external service -supports one. - -No mutable value captured outside a `withTx` callback influences that -transaction's database decisions. The callback returns the prepared invocation -and argument snapshot directly. - -## Calendar chains and checkpoints - -Cron expressions represent calendar occurrences and cannot be reduced to fixed -durations. After an actual calendar fire, the middleware computes the first -occurrence strictly after the transaction timestamp. - -SpacetimeDB 2.8 has a finite timer horizon. A valid expression such as February -29 can produce a gap beyond that horizon. The package schedules a checkpoint at -most 365 days away while retaining the logical target in `targetAt`. A -checkpoint that fires before the target inserts another bounded trigger without -executing application work. - -This repeats until the logical occurrence is within range. - -## Downtime and time zones - -An overdue calendar row fires once when the host resumes. The successor is -computed after the recovery timestamp, so intermediate missed occurrences are -skipped. - -Occurrence calculation uses `cron-parser` 5.x with an IANA time zone. Tests -cover spring-forward gaps, fall-back repetition, strictly-after behavior, -impossible dates, and date bounds. - -Native interval rows follow SpacetimeDB interval behavior. -`cron_job.nextRunAt` is an estimate updated after each interval fire or -failure recovery. - -## Failure policy - -`consecutiveFailures` counts recorded `Failed` outcomes for the active -generation. `Ok` resets the counter. When a positive `maxFailures` threshold -is reached, the package removes the fire row, disables the job, and stores a -bounded reason. - -If volatile recovery work is lost, the later reconciler records `lost_fire` as a -normal `Failed` outcome. It advances the failure counter and can trigger the -same automatic disable policy. - -## Storage bounds - -The default history cap is five completed runs per job. The configured range is -0 through 1,000. - -Errors and disable reasons are capped at 1,024 characters. Job names are -lowercase snake_case with a 48-character limit. Interval values are whole -seconds from 1 through 31,536,000. - -## Security - -Scheduling helpers perform state transitions and validation. Host reducers -remain responsible for application authorization. - -Every `_cron` function and the scheduled `cron_reconcile` reducer -accept calls only when the sender is the database identity. Generation and -sequence checks prevent stale or duplicate recovery messages from changing -current job state. - -`cron_job` is always private. Applications can register the public -`cron_jobs` view to expose operational state without arguments. -`publicTables: true` exposes `cron_run`, the per-job fire tables, and the -optional reconciliation tick, including run error strings. - -## Nested-transaction migration - -Nested transactions are the intended long-term replacement for the volatile -recovery path. With platform support, reducer execution can become: - -1. Advance the calendar chain in the parent transaction. -2. Run application work in a child transaction. -3. Commit the child on success or roll it back on failure. -4. Record the outcome in the parent transaction. -5. Commit the parent with the successor and outcome. - -That model removes the volatile ABI and its crash gap without reintroducing a -second schedule table or scheduler hop. The public `cronTable()`, -`schedule()`, and handler APIs do not need to change. - -## Verification - -The package test suite covers: - -- parser boundaries and time zones -- daylight-saving transitions -- one-catch-up behavior -- sparse-expression checkpointing -- input limits -- module schema builds -- typed reducer and procedure arguments -- argument replacement -- reducer rollback -- same-reducer volatile recovery and failure accounting -- calendar and native interval recovery -- opportunistic `lost_fire` repair -- interval-sweep `lost_fire` repair -- reducer and procedure automatic disablement -- generation changes -- rescheduling and cancellation -- bounded history -- scheduled-function authorization -- procedure calendar-chain continuity and at-most-one catch-up across host restart -- example module publication - -Release verification also runs repository lint, formatting, typechecking, -module builds, client generation, consumer installation, and package tarball -inspection. diff --git a/spacetime-cron-ts/README.md b/spacetime-cron-ts/README.md index 8d1093a98bf..21f12bff947 100644 --- a/spacetime-cron-ts/README.md +++ b/spacetime-cron-ts/README.md @@ -382,9 +382,7 @@ and the example module. The recovery suite verifies that a procedure commits its next calendar fire before external work, survives a host stop, and performs at most one catch-up invocation after downtime. -See the -[browser example](./example/) -for a complete integration and [`DESIGN.md`](./DESIGN.md) for the transaction model and invariants. +See the [browser example](./example/) for a complete integration. ## License diff --git a/spacetime-files-ts/README.md b/spacetime-files-ts/README.md index 6ea06d855b2..7d29e04e279 100644 --- a/spacetime-files-ts/README.md +++ b/spacetime-files-ts/README.md @@ -23,7 +23,7 @@ Bytes live in the module's `file` table as transactional application state. ### Integrate into an application -For a new application, mount the submodule first. The host must derive an owner +For a new application, register the submodule first. The host must derive an owner from its own identity or session model and expose narrow wrappers around the file helpers. Keep the file table private. @@ -77,7 +77,7 @@ lookups, `HEAD`, and conditional `304` responses therefore avoid reading or copying the blob. `GET` and authenticated byte procedures load it after access checks pass. -The mounted table is private. Host views should return `fileSummary` rows so +The submodule table is private. Host views should return `fileSummary` rows so subscriptions carry safe metadata fields. The package also exports `fileSummary`, a safe metadata shape that omits @@ -102,11 +102,11 @@ conn.subscriptionBuilder().subscribe([tables.myFileSummaries]); ## API -Each `*Impl` takes `(ctx, args, owner)` so the submodule stays identity-scheme-agnostic. Wrap them with thin reducers in your app module that derive `owner` however you want (caller `Identity`, a session lookup through a mounted auth namespace, etc). +Each `*Impl` takes `(ctx, args, owner)` so the submodule stays identity-scheme-agnostic. Wrap them with thin reducers in your app module that derive `owner` however you want (caller `Identity`, a session lookup through an Auth submodule namespace, etc). Package entrypoints: -- `@spacetimedb/files/submodule` supplies the mountable namespace and all +- `@spacetimedb/files/submodule` supplies the submodule namespace and all host integration helpers. - `@spacetimedb/files` exports the lower-level rows, validation, procedures, constants, and HTTP handler. @@ -201,7 +201,7 @@ const serveFile = createFileHttpHandler({ }); ``` -Mount it under a route like `/files/*` from your module. The handler: +Register it under a route such as `/files/*` from your module. The handler: - Accepts `GET` and `HEAD` only; everything else 405s. - Reads the stable file ID from `?id=`. @@ -250,7 +250,7 @@ pnpm run typecheck Build the [example host module](./example/spacetimedb/) to verify the -mounted submodule and generated bindings together. +registered submodule and generated bindings together. ## License diff --git a/spacetime-files-ts/example/README.md b/spacetime-files-ts/example/README.md index e27aa0a40ab..bb6b91a60f5 100644 --- a/spacetime-files-ts/example/README.md +++ b/spacetime-files-ts/example/README.md @@ -1,7 +1,7 @@ # Vault files example Vault is a small Drive-style file manager built with -[`@spacetimedb/files`](../). File bytes and file records live in the mounted +[`@spacetimedb/files`](../). File bytes and file records live in the namespaced Files submodule; the host module adds identity-owned folder metadata and scoped views. @@ -157,7 +157,7 @@ For a release smoke test, use two independent browser identities and verify: ## Important files -- `spacetimedb/src/index.ts` - Files mount, folders, scoped views, and private reads. +- `spacetimedb/src/index.ts` - Files registration, folders, scoped views, and private reads. - `src/app.ts` - file-manager state, uploads, previews, downloads, and subscriptions. - `server.ts` - static development server and public-file proxy. - `public/index.html` - Vault interface. diff --git a/spacetime-files-ts/src/procedures.ts b/spacetime-files-ts/src/procedures.ts index 69fd889e5d0..e72ab592fe6 100644 --- a/spacetime-files-ts/src/procedures.ts +++ b/spacetime-files-ts/src/procedures.ts @@ -55,7 +55,7 @@ const VALID_VISIBILITIES = new Set([ FILE_VISIBILITY_PUBLIC, ]); -// Direct `file` table or mounted-submodule layout, as in handlers.ts. +// Direct `file` table or submodule namespace layout, as in handlers.ts. function fileTable(db: FileDbLike): FileTable { const table = db.file ?? db.files?.file; if (!table) throw new Error('files.file table is unavailable'); diff --git a/spacetime-files-ts/src/rows.ts b/spacetime-files-ts/src/rows.ts index cab4e3853e3..2dc1df13820 100644 --- a/spacetime-files-ts/src/rows.ts +++ b/spacetime-files-ts/src/rows.ts @@ -4,7 +4,7 @@ export const FILE_VISIBILITY_OWNER = 'owner'; export const FILE_VISIBILITY_PUBLIC = 'public'; // Canonical submodule row shape. Applications with a custom file-like table may -// reuse these fields; standard integrations mount @spacetimedb/files/submodule. +// reuse these fields; standard integrations register @spacetimedb/files/submodule. export const fileRow = { id: t.u64().primaryKey().autoInc(), ownerPathKey: t.string().unique(), diff --git a/spacetime-grid-ts/README.md b/spacetime-grid-ts/README.md index c09d9fd94f3..2cc52b20dbe 100644 --- a/spacetime-grid-ts/README.md +++ b/spacetime-grid-ts/README.md @@ -25,7 +25,7 @@ changes like any other table. ### Integrate into an application -Mount the grid namespace, initialize it from the host lifecycle hook, and wrap +Register the Grid namespace, initialize it from the host lifecycle hook, and wrap its helpers with the application's ownership rules: ```ts @@ -132,7 +132,7 @@ Each `*Impl` takes `(ctx, args, owner)`. Wrap them with thin reducers in your mo Package entrypoints: -- `@spacetimedb/grid/submodule` supplies the mounted tables and helpers. +- `@spacetimedb/grid/submodule` supplies the submodule tables and helpers. - `@spacetimedb/grid` exports the lower-level rows, procedures, and math helpers. - `@spacetimedb/grid/procedures` exports operation parameters, @@ -226,7 +226,7 @@ pnpm run typecheck Build the [example host module](./example/spacetimedb/) to verify the -mounted schema, procedures, and generated bindings. +submodule schema, procedures, and generated bindings. ## License diff --git a/spacetime-grid-ts/example/README.md b/spacetime-grid-ts/example/README.md index ece7e5dcc09..5114167d8d9 100644 --- a/spacetime-grid-ts/example/README.md +++ b/spacetime-grid-ts/example/README.md @@ -1,7 +1,7 @@ # Grid tactics example This example is a turn-based hex-grid tactics game built with -[`@spacetimedb/grid`](../). The mounted Grid submodule owns grids, cell +[`@spacetimedb/grid`](../). The Grid submodule owns grids, cell state, and entity positions; the host module owns matches, participants, unit statistics, turns, and combat rules. @@ -103,12 +103,12 @@ blocked cells, exceed movement range, attack outside range, or act out of turn. ## Architecture and visibility ```text -Browser -> /auth/* proxy -> mounted Auth HTTP handlers +Browser -> /auth/* proxy -> Auth submodule HTTP handlers Browser -> linked SpacetimeDB connection -> my_matches / my_match_participants -> match-scoped my_player_units / my_grid_entities / my_cell_states -Host match rules -> mounted Grid tables and helpers +Host match rules -> Grid submodule tables and helpers ``` The browser first subscribes to caller-scoped match views. It creates a second, diff --git a/spacetime-lobby-ts/README.md b/spacetime-lobby-ts/README.md index a64d6b3d09b..49e5ef1c517 100644 --- a/spacetime-lobby-ts/README.md +++ b/spacetime-lobby-ts/README.md @@ -4,7 +4,7 @@ SpacetimeDB lobby and matchmaking submodule. This package provides queue tickets, deterministic same-pool matchmaking, ranked two-player results with Elo ratings, rooms, seats, lifecycle state, -admin observability, and mountable helpers for host modules. Host applications +admin observability, and submodule helpers for host modules. Host applications define parties, backfill, and product-specific match rules. ## Install @@ -22,7 +22,7 @@ For the install-to-publish workflow, see ### Integrate into an application -For a host application, mount the namespace and keep the lifecycle hook in the +For a host application, register the namespace and keep the lifecycle hook in the host module: ```ts @@ -38,7 +38,7 @@ export const init = spacetimedb.init(ctx => { export default spacetimedb; ``` -Mounted host modules can call helpers with an explicit subject after they have +Host modules can call submodule helpers with an explicit subject after they have validated auth or mapped the SpacetimeDB identity to an application user ID: ```ts @@ -133,13 +133,13 @@ Host helper API: - Room lifecycle: `joinRoom`, `leaveRoom`, and `closeRoom`. - Ranking: `reportMatchResult`. -Mounted administrator operations include `set_rating`, `expire_tickets`, and +Submodule administrator operations include `set_rating`, `expire_tickets`, and `update_config`. Package entrypoints: - `@spacetimedb/lobby` can run as a standalone Lobby database. -- `@spacetimedb/lobby/submodule` supplies the mounted namespace and host +- `@spacetimedb/lobby/submodule` supplies the submodule namespace and host helpers. ## Matching @@ -156,7 +156,7 @@ Matching is deterministic: the metadata when applying product-specific rules. Ranked queues use a 1,000 starting rating and a widening rating band: 100 -points initially, 50 more for each 10 seconds waited, capped at 800. A mounted +points initially, 50 more for each 10 seconds waited, capped at 800. A host host reports a two-player result through `reportMatchResult` after validating its game-specific completion rules. Results are idempotent per room and update both players with Elo K=32. The room must be active. Result reporting is a host diff --git a/spacetime-lobby-ts/example/README.md b/spacetime-lobby-ts/example/README.md index cb5c5ec1da8..defb0b3fd3d 100644 --- a/spacetime-lobby-ts/example/README.md +++ b/spacetime-lobby-ts/example/README.md @@ -1,7 +1,7 @@ # Starclash lobby example Starclash is a ranked one-on-one spaceship duel built with -[`@spacetimedb/lobby`](../). The mounted Lobby submodule owns queue tickets, +[`@spacetimedb/lobby`](../). The Lobby submodule owns queue tickets, rooms, seats, and ratings; the host module owns ship selection, duel state, maneuvers, combat resolution, and round logs. @@ -109,7 +109,7 @@ choose for the opponent, resolve an unrelated room, or read another room merely by changing a client query. This example uses anonymous SpacetimeDB identities. Display names are profile -metadata. Applications that need verified accounts can mount Auth. +metadata. Applications that need verified accounts can register Auth. ## Security and deployment boundaries @@ -157,7 +157,7 @@ For a release smoke test: ## Important files -- `spacetimedb/src/index.ts` - Lobby mount, scoped views, matchmaking, combat, +- `spacetimedb/src/index.ts` - Lobby registration, scoped views, matchmaking, combat, ratings, and AI fallback. - `spacetimedb/src/catalog.ts` - ship and maneuver definitions used to seed the public catalogs. diff --git a/spacetime-posthog-ts/README.md b/spacetime-posthog-ts/README.md index 2654d301487..e369e56698e 100644 --- a/spacetime-posthog-ts/README.md +++ b/spacetime-posthog-ts/README.md @@ -23,7 +23,7 @@ This submodule can be published directly as its own SpacetimeDB module from the ### Integrate into an application -Mount PostHog in the host schema. Configure its private credentials through an +Register PostHog in the host schema. Configure its private credentials through an administrator-only startup path, enqueue events from reducers, and perform network delivery from procedures: @@ -114,21 +114,21 @@ The submodule stores operational state in private tables and exposes admin-gated - `posthog_outbox_admin` and `posthog_delivery_log_admin` expose bounded, administrator-scoped operational views. -Mounted state exports include `posthogOutbox`, `posthogDeliveryLog`, +Submodule state exports include `posthogOutbox`, `posthogDeliveryLog`, `posthogDeliveryStats`, and `OutboxStatus` for host-defined views and operator workflows. -These mounted operations are admin-only because they can spend provider quota. +These submodule operations are admin-only because they can spend provider quota. Expose product-specific host operations that derive the distinct ID and event or flag name from authorized application state. **Reducer-safe queueing** - `enqueue_event({ distinctId, event, propertiesJson, idempotencyKey })` writes a - durable event intent inside a reducer transaction. The mounted reducer is + durable event intent inside a reducer transaction. The submodule reducer is admin-only; host reducers should call `enqueueEvent` after authorization. -For mounted modules, import `@spacetimedb/posthog/submodule` and call `enqueueEvent(ctx.as.posthog, ...)` from reducers or `captureNow(ctx.as.posthog, ...)` / `flushOutbox(ctx.as.posthog, ...)` from procedures. +For host modules, import `@spacetimedb/posthog/submodule` and call `enqueueEvent(ctx.as.posthog, ...)` from reducers or `captureNow(ctx.as.posthog, ...)` / `flushOutbox(ctx.as.posthog, ...)` from procedures. The client calls the business operation. Analytics remain a server-side concern: @@ -144,7 +144,7 @@ generic event names inside the module. Package entrypoints: - `@spacetimedb/posthog` can run as a standalone analytics database. -- `@spacetimedb/posthog/submodule` supplies mounted state, configuration, +- `@spacetimedb/posthog/submodule` supplies submodule state, configuration, delivery helpers, and admin views. ## Architecture notes diff --git a/spacetime-posthog-ts/example/README.md b/spacetime-posthog-ts/example/README.md index 03f03e0f0ec..04e5310263f 100644 --- a/spacetime-posthog-ts/example/README.md +++ b/spacetime-posthog-ts/example/README.md @@ -1,6 +1,6 @@ # Context Cafe -Context Cafe is a small robot café simulator that demonstrates the mounted +Context Cafe is a small robot café simulator that demonstrates the `@spacetimedb/posthog/submodule`. SpacetimeDB owns the catalog, simulation, per-browser café state, metrics, and analytics outbox. A dedicated local server identity delivers queued events to PostHog; the browser never receives submodule @@ -97,7 +97,7 @@ and preserves the delivery identity across restarts. ```text Browser -> caller-scoped café reducers and views - -> analytics events queued in the mounted posthog namespace + -> analytics events queued in the posthog submodule namespace Authorized example server -> subscribes to the admin-scoped outbox view diff --git a/spacetime-presence-ts/README.md b/spacetime-presence-ts/README.md index 00843f582a0..bab1c55ea9c 100644 --- a/spacetime-presence-ts/README.md +++ b/spacetime-presence-ts/README.md @@ -138,7 +138,7 @@ Package entrypoints: - `@spacetimedb/presence` exports the full standalone helper surface. - `@spacetimedb/presence/presence` exports presence operations. - `@spacetimedb/presence/tables` exports table builders. -- `@spacetimedb/presence/submodule` exports the ready-made mounted +- `@spacetimedb/presence/submodule` exports the ready-made submodule namespace. The ready-made namespace publishes `presence_entry` rows. The `activity` and diff --git a/spacetime-presence-ts/example/README.md b/spacetime-presence-ts/example/README.md index 259ba41639d..748846d9326 100644 --- a/spacetime-presence-ts/example/README.md +++ b/spacetime-presence-ts/example/README.md @@ -176,7 +176,7 @@ For a release smoke test, use two accounts and verify: ## Important files - `spacetimedb/src/index.ts` - host schema, scoped views, chat operations, and - mounted submodule wiring. + submodule registration. - `server.ts` - environment loading, auth bootstrap, and HTTP proxy. - `src/app.ts` - browser connection, linked-session setup, and subscriptions. - `public/index.html` - the example interface. diff --git a/spacetime-presence-ts/spacetimedb/src/index.ts b/spacetime-presence-ts/spacetimedb/src/index.ts index ddd02a77d65..160a0b54719 100644 --- a/spacetime-presence-ts/spacetimedb/src/index.ts +++ b/spacetime-presence-ts/spacetimedb/src/index.ts @@ -1,2 +1,2 @@ -export { default } from '../../src/mounted/index'; -export * from '../../src/mounted/index'; +export { default } from '../../src/submodule/index'; +export * from '../../src/submodule/index'; diff --git a/spacetime-presence-ts/src/submodule.ts b/spacetime-presence-ts/src/submodule.ts index 193195aae6a..c8ee56669d1 100644 --- a/spacetime-presence-ts/src/submodule.ts +++ b/spacetime-presence-ts/src/submodule.ts @@ -1,5 +1,5 @@ -export { default } from './mounted/index'; -export { installPresence } from './mounted/install'; +export { default } from './submodule/index'; +export { installPresence } from './submodule/install'; export { add_presence_admin, clear_presence, @@ -8,4 +8,4 @@ export { presence_sweep, run_sweep, update_config, -} from './mounted/index'; +} from './submodule/index'; diff --git a/spacetime-presence-ts/src/mounted/index.ts b/spacetime-presence-ts/src/submodule/index.ts similarity index 100% rename from spacetime-presence-ts/src/mounted/index.ts rename to spacetime-presence-ts/src/submodule/index.ts diff --git a/spacetime-presence-ts/src/mounted/install.ts b/spacetime-presence-ts/src/submodule/install.ts similarity index 96% rename from spacetime-presence-ts/src/mounted/install.ts rename to spacetime-presence-ts/src/submodule/install.ts index 61b4280e5e2..0be7f639ace 100644 --- a/spacetime-presence-ts/src/mounted/install.ts +++ b/spacetime-presence-ts/src/submodule/install.ts @@ -4,7 +4,7 @@ import { DEFAULT_PRESENCE_SWEEP_BATCH, DEFAULT_PRESENCE_TTL_SECONDS, installPresenceConfig, -} from '@spacetimedb/presence'; +} from '../index'; import type spacetimedb from './index'; const ONE_SECOND_MICROS = 1_000_000n; diff --git a/spacetime-rate-limit-ts/README.md b/spacetime-rate-limit-ts/README.md index 5b70edb2715..5fa33c4dfd9 100644 --- a/spacetime-rate-limit-ts/README.md +++ b/spacetime-rate-limit-ts/README.md @@ -15,7 +15,7 @@ For the install-to-publish workflow, see This package gives you: -- a mountable `./submodule` with submodule-owned bucket/config/admin tables +- a `./submodule` namespace with submodule-owned bucket/config/admin tables - standalone helper functions for direct host integration - bounded sweep helpers for expired buckets - admin-gated procedures for diagnostics and maintenance @@ -24,7 +24,7 @@ This package gives you: ### Integrate into an application -Mount the namespace, install its scheduled cleanup and admin state, then call +Register the namespace, install its scheduled cleanup and admin state, then call `consume` from the host operation before performing the protected action: ```ts @@ -92,7 +92,7 @@ The generated client calls the product-facing operation: await conn.procedures.createPost({ body: 'Hello' }); ``` -The submodule owns these tables under the mounted namespace: +The submodule owns these tables under its namespace: - `rateLimit.rate_limit_bucket` - `rateLimit.rate_limit_admin_identity` @@ -114,7 +114,7 @@ implementations: - `sweepRateLimits` - `resolveRateLimitSweepBatch` -The mounted `consume`, `runSweep`, and `reset_buckets` operations are admin-only. +The submodule `consume`, `runSweep`, and `reset_buckets` operations are admin-only. Application-facing operations should enforce a fixed policy in host code and use `consumeRateLimit` as shown above. `reset_buckets({ maxRows })` removes 1,000 rows by default and accepts a maximum of 10,000 per call, so destructive @@ -125,7 +125,7 @@ use `@spacetimedb/rate-limit/submodule`. Package entrypoints: -- `@spacetimedb/rate-limit/submodule` supplies the mounted namespace, +- `@spacetimedb/rate-limit/submodule` supplies the submodule namespace, maintenance operations, and host helpers. - `@spacetimedb/rate-limit/limit` exports standalone policy functions. - `@spacetimedb/rate-limit` re-exports the supported helper surface. diff --git a/spacetime-rate-limit-ts/example/README.md b/spacetime-rate-limit-ts/example/README.md index 5c4ef82aae1..0701813bd2c 100644 --- a/spacetime-rate-limit-ts/example/README.md +++ b/spacetime-rate-limit-ts/example/README.md @@ -2,7 +2,7 @@ Powerhouse is an arcade-style reactor game built with [`@spacetimedb/rate-limit`](../). The browser requests actions; SpacetimeDB -owns energy, heat, upgrades, events, and the fixed-window limiter buckets mounted +owns energy, heat, upgrades, events, and the fixed-window limiter buckets registered under the `rateLimit` namespace. ## What this demonstrates @@ -86,7 +86,7 @@ Each protected action calls `rateLimit.consumeRateLimit(ctx.as.rateLimit, ...)` with a server-selected scope, an actor key derived from `ctx.sender`, a limit, a window, and an optional cost. The returned result includes remaining capacity, reset time, and retry delay. -The mounted `consume` procedure is reserved for administrators; normal gameplay +The submodule `consume` procedure is reserved for administrators; normal gameplay uses the lower-level helper inside the host procedure's transaction. The submodule implements fixed-window limiting. Application heat and cooldown @@ -111,7 +111,7 @@ subscribed server timestamps are authoritative. ## Administration -A fresh publish seeds the publisher as the initial mounted Rate Limit +A fresh publish seeds the publisher as the initial Rate Limit submodule administrator. The debug drawer remains empty and maintenance calls fail for an ordinary browser identity. To exercise those controls locally, grant the browser identity from the logged-in owner identity: @@ -160,7 +160,7 @@ For a release smoke test: - **Actions fail immediately:** inspect both limiter status and reactor heat; they are independent rejection paths. -- **Debug data is empty:** grant the connected browser identity mounted Rate Limit +- **Debug data is empty:** grant the connected browser identity Rate Limit submodule administrator access. - **State is stale:** confirm `STDB_URI` targets the database published by the `local` server registration. @@ -169,7 +169,7 @@ For a release smoke test: ## Important files -- `spacetimedb/src/index.ts` - submodule mount, reactor rules, scoped views, and +- `spacetimedb/src/index.ts` - submodule registration, reactor rules, scoped views, and bounded maintenance operations. - `src/app.ts` - connection, procedures, subscriptions, and UI bridge. - `server.ts` - static development server and browser-safe configuration. diff --git a/spacetime-resend-ts/README.md b/spacetime-resend-ts/README.md index 05987075414..12e3274ccb1 100644 --- a/spacetime-resend-ts/README.md +++ b/spacetime-resend-ts/README.md @@ -23,7 +23,7 @@ This submodule can be published directly as its own SpacetimeDB module from the ### Integrate into an application -Mount Resend in the host schema, initialize its private state, and expose only +Register Resend in the host schema, initialize its private state, and expose only application-authorized send procedures and caller-scoped delivery views: ```ts @@ -104,7 +104,7 @@ fields are capped at 320 characters, subjects at 998 characters, HTML and text at 200,000 characters each, and tag or header JSON at 16 KiB. Control characters in address, subject, and schedule fields are rejected before provider HTTP. -Mounted host modules should prefer the helper export: +Host modules should prefer the submodule helper export: ```ts import * as resend from '@spacetimedb/resend/submodule'; @@ -158,7 +158,7 @@ caller-scoped, paginated views for product-facing history. Package entrypoints: - `@spacetimedb/resend` can run as a standalone email database. -- `@spacetimedb/resend/submodule` supplies mounted configuration, delivery, +- `@spacetimedb/resend/submodule` supplies submodule configuration, delivery, webhook, and query helpers. ## Webhook events handled diff --git a/spacetime-resend-ts/example/README.md b/spacetime-resend-ts/example/README.md index 29f10cf4b8a..da29091f8c2 100644 --- a/spacetime-resend-ts/example/README.md +++ b/spacetime-resend-ts/example/README.md @@ -123,7 +123,7 @@ Delivered, Opened, Clicked, Bounced, and Complaint transitions cannot arrive. Browser -> send_dispatch host procedure -> recipient allowlist and caller/global quotas - -> mounted resend namespace + -> resend submodule namespace -> Resend API Resend webhook diff --git a/spacetime-resend-ts/example/server.ts b/spacetime-resend-ts/example/server.ts index 019f419248c..0ba5a7e371d 100644 --- a/spacetime-resend-ts/example/server.ts +++ b/spacetime-resend-ts/example/server.ts @@ -114,7 +114,7 @@ function requireStdb(): DbConnection { const app = express(); -// Webhook uses RAW body (svix signs raw bytes); mounted before express.json(). +// Webhook uses the raw body because Svix signs raw bytes. Register it before express.json(). app.post( '/webhook/resend', express.raw({ type: '*/*', limit: '512kb' }), diff --git a/spacetime-resend-ts/src/submodule/webhooks.ts b/spacetime-resend-ts/src/submodule/webhooks.ts index 16131c421b1..b43fcd61900 100644 --- a/spacetime-resend-ts/src/submodule/webhooks.ts +++ b/spacetime-resend-ts/src/submodule/webhooks.ts @@ -338,10 +338,10 @@ function webhookJson(body: unknown, status: number): SyncResponse { }); } -// Native SpacetimeDB HTTP route handler. Host modules mount this on a router so -// Resend posts directly to the database. No external relay, no shim. +// Native SpacetimeDB HTTP route handler. Host modules register it on a router so +// Resend can post directly to the database. export function makeResendWebhookHandler() { - // The host passes the mounted context, so this handler remains schema-agnostic. + // The host passes the submodule-scoped context, so this handler remains schema-agnostic. return function resendWebhook( ctx: HandlerContext, req: Request diff --git a/spacetime-retry-ts/README.md b/spacetime-retry-ts/README.md index 29d02c06dd4..abfab976ec9 100644 --- a/spacetime-retry-ts/README.md +++ b/spacetime-retry-ts/README.md @@ -27,7 +27,7 @@ example below is a module-definition skeleton: replace `sendReceipt` with an idempotent application handler. Keep the registration casts at this SDK/factory boundary; application code stays typed through the handler map. -Create the factory before the schema so its tables can be mounted. Register the +Create the factory before the schema so its tables can be registered. Register the scheduled reducer afterward to resolve the scheduled-table reference. ```ts diff --git a/spacetime-stripe-ts/README.md b/spacetime-stripe-ts/README.md index 04c312d2ee8..6cdc31b5a9f 100644 --- a/spacetime-stripe-ts/README.md +++ b/spacetime-stripe-ts/README.md @@ -24,7 +24,7 @@ This submodule can be published directly as its own SpacetimeDB module from the ### Integrate into an application -Mount Stripe in the application schema and initialize its private tables. The +Register Stripe in the application schema and initialize its private tables. The host must place authorization in front of customer, Checkout, portal, and billing procedures and expose only caller-scoped billing views: @@ -104,7 +104,7 @@ spacetime call --server http://127.0.0.1:3000 stripe-ts get_stripe_config_status The Stripe secret stays in private module state. Every provider-backed, billing-state, configuration, and query procedure is admin-gated. A host module can perform application-specific authorization and then call the helpers through -its mounted `ctx.as.stripe` context. +its submodule-scoped `ctx.as.stripe` context. ## Private tables @@ -174,7 +174,7 @@ views in the host module when a UI needs a larger history. Package entrypoints: - `@spacetimedb/stripe` can run as a standalone billing database. -- `@spacetimedb/stripe/submodule` supplies mounted billing, webhook, +- `@spacetimedb/stripe/submodule` supplies submodule billing, webhook, configuration, and query operations. ## Webhook events handled diff --git a/spacetime-stripe-ts/example/README.md b/spacetime-stripe-ts/example/README.md index 6a8f078564f..327f6cce3b9 100644 --- a/spacetime-stripe-ts/example/README.md +++ b/spacetime-stripe-ts/example/README.md @@ -97,7 +97,7 @@ webhook route. The product catalog and storefront are demonstration code. When no server token is supplied, the server persists one in the ignored `.stdb-server-token` file. The logged-in publishing identity registers that server -identity in both the host store and mounted Stripe administrator registries. The +identity in both the host store and Stripe submodule administrator registries. The browser identity is never granted either role. ## Startup behavior @@ -121,13 +121,13 @@ Browser storefront -> same-origin /api checkout/customer/validation routes -> authorized server identity -> host checkout/customer/validation procedures - -> mounted stripe namespace + -> stripe submodule namespace -> Stripe API Stripe -> POST /route/stripe/webhook on the SpacetimeDB database -> host router - -> mounted stripe webhook handler + -> stripe submodule webhook handler Authorized example server -> private configuration and catalog setup during startup From d902103141f4a614cc10284abcf6adbc5ceee837 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 12:31:38 -0400 Subject: [PATCH 26/33] cleanup --- pnpm-lock.yaml | 6 ------ .../example/spacetimedb/src/agents/chat.ts | 2 -- spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts | 8 -------- .../example/spacetimedb/src/tools/getTime.ts | 2 +- .../example/spacetimedb/src/tools/index.ts | 1 - spacetime-agents-ts/src/submodule/index.ts | 7 ------- spacetime-api-keys-ts/example/spacetimedb/package.json | 1 - spacetime-crypto-ts/src/timing.ts | 2 +- spacetime-grid-ts/example/spacetimedb/package.json | 1 - spacetime-posthog-ts/src/submodule/operations.ts | 2 +- spacetime-resend-ts/src/submodule/operations.ts | 2 +- 11 files changed, 4 insertions(+), 30 deletions(-) delete mode 100644 spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts delete mode 100644 spacetime-agents-ts/example/spacetimedb/src/tools/index.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38f92aebe6d..1ffba6f1332 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -494,9 +494,6 @@ importers: '@spacetimedb/api-keys': specifier: workspace:* version: link:../.. - '@spacetimedb/crypto': - specifier: workspace:* - version: link:../../../spacetime-crypto-ts '@spacetimedb/grid': specifier: workspace:* version: link:../../../spacetime-grid-ts @@ -871,9 +868,6 @@ importers: '@spacetimedb/grid': specifier: workspace:* version: link:../.. - '@spacetimedb/rate-limit': - specifier: workspace:* - version: link:../../../spacetime-rate-limit-ts spacetimedb: specifier: workspace:* version: link:../../../crates/bindings-typescript diff --git a/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts b/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts index 61cd43569eb..54953340be8 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts @@ -1,6 +1,5 @@ import { defineAgent } from '@spacetimedb/agents'; import getTime from '../tools/getTime'; -import echo from '../tools/echo'; export default defineAgent({ defaultModel: 'anthropic/claude-haiku-4.5', @@ -15,6 +14,5 @@ export default defineAgent({ ragTopK: 4, tools: { get_time: getTime, - echo, }, }); diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts deleted file mode 100644 index 1ffb164b74d..00000000000 --- a/spacetime-agents-ts/example/spacetimedb/src/tools/echo.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { t } from 'spacetimedb/server'; -import { agentTool } from '@spacetimedb/agents'; - -export default agentTool( - 'echoes the given message back to the caller', - t.object('EchoArgs', { message: t.string() }), - (_ctx, args) => `echo: ${args.message}` -); diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts index c9acf204208..33f2df50904 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts @@ -6,7 +6,7 @@ export default agentTool( 'returns the current server time as an ISO-8601 string', t.unit(), ctx => { - // This cast breaks the circular type dependency between the tool and module schema. + // The Tx cast avoids a circular type reference between the tool and schema. const tx = ctx as Tx; const micros = tx.timestamp.microsSinceUnixEpoch as bigint; return new Date(Number(micros / 1000n)).toISOString(); diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts deleted file mode 100644 index cb0ff5c3b54..00000000000 --- a/spacetime-agents-ts/example/spacetimedb/src/tools/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/spacetime-agents-ts/src/submodule/index.ts b/spacetime-agents-ts/src/submodule/index.ts index 0793ba7ef5b..e9b35336772 100644 --- a/spacetime-agents-ts/src/submodule/index.ts +++ b/spacetime-agents-ts/src/submodule/index.ts @@ -45,12 +45,6 @@ function throwSenderError(msg: string): never { throw new SenderError(msg); } -const echo = agentTool( - 'echoes the given message back to the caller', - t.object('EchoArgs', { message: t.string() }), - (_ctx, args) => `echo: ${args.message}` -); - const getTime = agentTool( 'returns the current server time as an ISO-8601 string', t.unit(), @@ -74,7 +68,6 @@ const chatAgent = defineAgent({ ragTopK: 4, tools: { get_time: getTime, - echo, }, }); diff --git a/spacetime-api-keys-ts/example/spacetimedb/package.json b/spacetime-api-keys-ts/example/spacetimedb/package.json index 4960c019f20..41bf800908c 100644 --- a/spacetime-api-keys-ts/example/spacetimedb/package.json +++ b/spacetime-api-keys-ts/example/spacetimedb/package.json @@ -12,7 +12,6 @@ "@spacetimedb/api-keys": "workspace:*", "@spacetimedb/grid": "workspace:*", "@spacetimedb/presence": "workspace:*", - "@spacetimedb/crypto": "workspace:*", "spacetimedb": "workspace:*" }, "devDependencies": { diff --git a/spacetime-crypto-ts/src/timing.ts b/spacetime-crypto-ts/src/timing.ts index ab1d604afe1..a0dae825856 100644 --- a/spacetime-crypto-ts/src/timing.ts +++ b/spacetime-crypto-ts/src/timing.ts @@ -70,7 +70,7 @@ export function base64ToBytes(b64: string): Uint8Array { 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const lookup = new Int8Array(256).fill(-1); for (let i = 0; i < ALPHABET.length; i++) lookup[ALPHABET.charCodeAt(i)] = i; - lookup[0x3d /* '=' */] = 0; // pad treated as 0; we trim afterwards + lookup[0x3d /* '=' */] = 0; // Padding is removed after decoding. // Count pad to compute output length. let pad = 0; diff --git a/spacetime-grid-ts/example/spacetimedb/package.json b/spacetime-grid-ts/example/spacetimedb/package.json index 320a2c4876f..fcd1a07f78f 100644 --- a/spacetime-grid-ts/example/spacetimedb/package.json +++ b/spacetime-grid-ts/example/spacetimedb/package.json @@ -11,7 +11,6 @@ "dependencies": { "@spacetimedb/auth": "workspace:*", "@spacetimedb/grid": "workspace:*", - "@spacetimedb/rate-limit": "workspace:*", "spacetimedb": "workspace:*" }, "devDependencies": { diff --git a/spacetime-posthog-ts/src/submodule/operations.ts b/spacetime-posthog-ts/src/submodule/operations.ts index 02441e78662..a658abc2d76 100644 --- a/spacetime-posthog-ts/src/submodule/operations.ts +++ b/spacetime-posthog-ts/src/submodule/operations.ts @@ -180,7 +180,7 @@ function pruneDeliveryHistory( } // Remove queued and delivered outbox entries plus the delivery log for bounded -// demo and test resets. Events received by PostHog remain at the provider. +// Operator-requested cleanup. Events received by PostHog remain at the provider. export function clearAnalytics( ctx: WriteCtx, maxRows = 1000 diff --git a/spacetime-resend-ts/src/submodule/operations.ts b/spacetime-resend-ts/src/submodule/operations.ts index 88fceeebcac..1e7f16a6f54 100644 --- a/spacetime-resend-ts/src/submodule/operations.ts +++ b/spacetime-resend-ts/src/submodule/operations.ts @@ -68,7 +68,7 @@ function extractTagFieldsFromJson(tagsJson: string | undefined): { return { userId: tags['userId'], orgId: tags['orgId'] }; } -// Build POST /emails body. Resend HTTP API expects snake_case on the wire; SDK converts internally. We hand-roll, so emit snake_case directly. +// Resend expects snake_case fields in the POST /emails request body. type ResendSendEmailBody = { from: string; to: string[]; From 238b4b8ed4172c76470081e460bca6419d93ba0d Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 13:14:44 -0400 Subject: [PATCH 27/33] Address submodule security review findings --- spacetime-agents-ts/example/server.ts | 6 ++- spacetime-api-keys-ts/example/src/app.ts | 8 ++- spacetime-auth-ts/example/server.ts | 6 ++- spacetime-grid-ts/example/server.ts | 6 ++- spacetime-presence-ts/example/public/ui.js | 51 +++++++++++++------ spacetime-presence-ts/example/server.ts | 6 ++- .../scripts/test-resend-smoke.ts | 16 +++++- spacetime-stripe-ts/example/public/ui.js | 2 +- 8 files changed, 73 insertions(+), 28 deletions(-) diff --git a/spacetime-agents-ts/example/server.ts b/spacetime-agents-ts/example/server.ts index c7a2d73f8e6..de2fb600179 100644 --- a/spacetime-agents-ts/example/server.ts +++ b/spacetime-agents-ts/example/server.ts @@ -7,6 +7,8 @@ import dotenv from 'dotenv'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const SPA_HTML = readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); const inheritedEnv = new Set(Object.keys(process.env)); function loadEnv(pathname: string, override: boolean): void { @@ -153,7 +155,7 @@ app.use(express.json({ limit: '256kb' })); // Register this before the /auth proxy so reset links reach the SPA. app.get('/auth/password/reset', (_req: Request, res: Response) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); + res.type('html').send(SPA_HTML); }); function proxyStdbRoute(prefix: string) { @@ -210,7 +212,7 @@ function proxyStdbRoute(prefix: string) { app.use('/auth', proxyStdbRoute('/auth')); app.use('/files', proxyStdbRoute('/files')); -app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.static(PUBLIC_DIR)); app.get('/api/health', (_req: Request, res: Response) => { res.json({ ok: true, databaseName: DB_NAME }); diff --git a/spacetime-api-keys-ts/example/src/app.ts b/spacetime-api-keys-ts/example/src/app.ts index 1776853a874..b8756107d52 100644 --- a/spacetime-api-keys-ts/example/src/app.ts +++ b/spacetime-api-keys-ts/example/src/app.ts @@ -494,7 +494,13 @@ function renderRoleBanner(): void { const role = myRole(); const viewOnly = role === 'Viewer'; banner.className = `role-banner glass ${viewOnly ? 'view-only' : ''}`; - banner.innerHTML = `Joined as ${escapeHtml(role)}${viewOnly ? ' (view only)' : ''}`; + const swatch = document.createElement('span'); + swatch.className = 'swatch'; + swatch.style.color = myColor; + const roleName = document.createElement('b'); + roleName.textContent = role; + banner.replaceChildren(swatch, 'Joined as ', roleName); + if (viewOnly) banner.append(' (view only)'); } function renderFeed(): void { diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts index 2758b506f19..a6dbc4f6d21 100644 --- a/spacetime-auth-ts/example/server.ts +++ b/spacetime-auth-ts/example/server.ts @@ -7,6 +7,8 @@ import dotenv from 'dotenv'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const SPA_HTML = readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); const inheritedEnv = new Set(Object.keys(process.env)); function loadEnv(pathname: string, override: boolean): void { @@ -98,7 +100,7 @@ app.use(express.json({ limit: '256kb' })); // Register this before the /auth proxy so reset links reach the SPA. app.get('/auth/password/reset', (_req: Request, res: Response) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); + res.type('html').send(SPA_HTML); }); // Using app.use as middleware since Express 4's `app.all('/auth/*', ...)` does @@ -172,7 +174,7 @@ app.get('/api/health', (_req: Request, res: Response) => { res.json({ ok: true, databaseName: DB_NAME }); }); -app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.static(PUBLIC_DIR)); try { console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); diff --git a/spacetime-grid-ts/example/server.ts b/spacetime-grid-ts/example/server.ts index 3c66f83c6bb..908f89bf69c 100644 --- a/spacetime-grid-ts/example/server.ts +++ b/spacetime-grid-ts/example/server.ts @@ -7,6 +7,8 @@ import dotenv from 'dotenv'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const SPA_HTML = readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); const inheritedEnv = new Set(Object.keys(process.env)); function loadEnv(pathname: string, override: boolean): void { @@ -98,7 +100,7 @@ app.use(express.json({ limit: '256kb' })); // Register this before the /auth proxy so reset links reach the SPA. app.get('/auth/password/reset', (_req: Request, res: Response) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); + res.type('html').send(SPA_HTML); }); app.use('/auth', async (req, res) => { @@ -143,7 +145,7 @@ app.use('/auth', async (req, res) => { } }); -app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.static(PUBLIC_DIR)); app.get('/api/health', (_req: Request, res: Response) => { res.json({ ok: true, databaseName: DB_NAME }); diff --git a/spacetime-presence-ts/example/public/ui.js b/spacetime-presence-ts/example/public/ui.js index 405b476516e..4ea0af23572 100644 --- a/spacetime-presence-ts/example/public/ui.js +++ b/spacetime-presence-ts/example/public/ui.js @@ -1571,22 +1571,41 @@ function renderPendingAtts() { return; } root.hidden = false; - root.innerHTML = pendingAtts - .map(p => { - const isImg = p.mimeType.startsWith('image/'); - const preview = isImg - ? `${escapeHtml(p.name)}` - : `
          ${escapeHtml(p.name)}
          `; - const meta = isImg - ? `
          ${escapeHtml(p.name)} - ${fmtBytes(p.bytes.length)}
          ` - : `
          ${fmtBytes(p.bytes.length)}
          `; - return `
          - ${preview} - ${meta} - -
          `; - }) - .join(''); + root.replaceChildren(); + for (const attachment of pendingAtts) { + const item = document.createElement('div'); + item.className = 'pending-att'; + item.dataset.pid = String(attachment.id); + + const isImage = attachment.mimeType.startsWith('image/'); + if (isImage) { + const image = document.createElement('img'); + image.src = attachment.previewUrl; + image.alt = attachment.name; + item.append(image); + } else { + const name = document.createElement('div'); + name.className = 'pending-att-meta'; + name.textContent = attachment.name; + item.append(name); + } + + const metadata = document.createElement('div'); + metadata.className = 'pending-att-meta'; + metadata.textContent = isImage + ? `${attachment.name} - ${fmtBytes(attachment.bytes.length)}` + : fmtBytes(attachment.bytes.length); + item.append(metadata); + + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'pending-att-x'; + removeButton.dataset.remove = String(attachment.id); + removeButton.setAttribute('aria-label', 'Remove'); + removeButton.textContent = '×'; + item.append(removeButton); + root.append(item); + } root.querySelectorAll('[data-remove]').forEach(btn => { btn.addEventListener('click', () => { const id = Number(btn.dataset.remove); diff --git a/spacetime-presence-ts/example/server.ts b/spacetime-presence-ts/example/server.ts index cbedbfe365e..2478682e68b 100644 --- a/spacetime-presence-ts/example/server.ts +++ b/spacetime-presence-ts/example/server.ts @@ -7,6 +7,8 @@ import dotenv from 'dotenv'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const SPA_HTML = readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); const inheritedEnv = new Set(Object.keys(process.env)); function loadEnv(pathname: string, override: boolean): void { @@ -89,7 +91,7 @@ const app = express(); app.use(express.json({ limit: '256kb' })); app.get('/auth/password/reset', (_req: Request, res: Response) => { - res.sendFile(path.join(__dirname, 'public', 'index.html')); + res.type('html').send(SPA_HTML); }); function proxyStdbRoute(prefix: string) { @@ -177,7 +179,7 @@ app.get('/api/config', (_req: Request, res: Response) => { }); }); -app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.static(PUBLIC_DIR)); try { console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); diff --git a/spacetime-resend-ts/scripts/test-resend-smoke.ts b/spacetime-resend-ts/scripts/test-resend-smoke.ts index 2c46febdfa6..7ab1615c855 100644 --- a/spacetime-resend-ts/scripts/test-resend-smoke.ts +++ b/spacetime-resend-ts/scripts/test-resend-smoke.ts @@ -342,10 +342,22 @@ async function main() { 'list_delivery_events_for_email', [quote(em('a'))] ); - if (!clickEvents.includes('email.clicked')) { + const clickRows: unknown = JSON.parse(clickEvents); + const clickRow = Array.isArray(clickRows) + ? clickRows.find(row => Array.isArray(row) && row[2] === 'email.clicked') + : undefined; + if (!clickRow) { throw new Error(`expected click event in delivery log: ${clickEvents}`); } - if (!clickEvents.includes('spacetimedb.com')) { + const detailOption = Array.isArray(clickRow) ? clickRow[4] : undefined; + const detailJson = + Array.isArray(detailOption) && + detailOption[0] === 0 && + typeof detailOption[1] === 'string' + ? detailOption[1] + : undefined; + const clickDetail = detailJson ? JSON.parse(detailJson) : undefined; + if (clickDetail?.link !== 'https://spacetimedb.com') { throw new Error(`expected click detail (link) preserved: ${clickEvents}`); } diff --git a/spacetime-stripe-ts/example/public/ui.js b/spacetime-stripe-ts/example/public/ui.js index 08c41ea3540..00c4e802500 100644 --- a/spacetime-stripe-ts/example/public/ui.js +++ b/spacetime-stripe-ts/example/public/ui.js @@ -226,7 +226,7 @@ function buildPostCheckoutUrl(flag) { } function makeDefaultUserId() { - return `pilot_${Math.random().toString(36).slice(2, 8)}`; + return `pilot_${crypto.randomUUID().slice(0, 8)}`; } function seedDefaultBuyerDetails() { From c06f557bbf0885bb58e83b15ffb97c26cb60b405 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 13:23:11 -0400 Subject: [PATCH 28/33] Fix submodule CI setup --- .github/workflows/ci.yml | 4 ++-- spacetime-agents-ts/.npmrc | 1 + spacetime-agents-ts/example/.npmrc | 1 + spacetime-agents-ts/example/spacetimedb/.npmrc | 1 + spacetime-agents-ts/spacetimedb/.npmrc | 1 + spacetime-api-keys-ts/.npmrc | 1 + spacetime-api-keys-ts/example/.npmrc | 1 + spacetime-api-keys-ts/example/spacetimedb/.npmrc | 1 + spacetime-auth-ts/.npmrc | 1 + spacetime-auth-ts/example/.npmrc | 1 + spacetime-auth-ts/example/spacetimedb/.npmrc | 1 + spacetime-auth-ts/spacetimedb/.npmrc | 1 + spacetime-cron-ts/.npmrc | 1 + spacetime-cron-ts/example/.npmrc | 1 + spacetime-cron-ts/example/spacetimedb/.npmrc | 1 + spacetime-cron-ts/spacetimedb/.npmrc | 1 + spacetime-crypto-ts/.npmrc | 1 + spacetime-example-ui-ts/.npmrc | 1 + spacetime-files-ts/.npmrc | 1 + spacetime-files-ts/example/.npmrc | 1 + spacetime-files-ts/example/spacetimedb/.npmrc | 1 + spacetime-grid-ts/.npmrc | 1 + spacetime-grid-ts/example/.npmrc | 1 + spacetime-grid-ts/example/spacetimedb/.npmrc | 1 + spacetime-lobby-ts/.npmrc | 1 + spacetime-lobby-ts/example/.npmrc | 1 + spacetime-lobby-ts/example/spacetimedb/.npmrc | 1 + spacetime-posthog-ts/.npmrc | 1 + spacetime-posthog-ts/example/.npmrc | 1 + spacetime-posthog-ts/example/spacetimedb/.npmrc | 1 + spacetime-presence-ts/.npmrc | 1 + spacetime-presence-ts/example/.npmrc | 1 + spacetime-presence-ts/example/spacetimedb/.npmrc | 1 + spacetime-presence-ts/spacetimedb/.npmrc | 1 + spacetime-rate-limit-ts/.npmrc | 1 + spacetime-rate-limit-ts/example/.npmrc | 1 + spacetime-rate-limit-ts/example/spacetimedb/.npmrc | 1 + spacetime-rate-limit-ts/spacetimedb/.npmrc | 1 + spacetime-resend-ts/.npmrc | 1 + spacetime-resend-ts/example/.npmrc | 1 + spacetime-resend-ts/example/spacetimedb/.npmrc | 1 + spacetime-retry-ts/.npmrc | 1 + spacetime-retry-ts/spacetimedb/.npmrc | 1 + spacetime-stripe-ts/.npmrc | 1 + spacetime-stripe-ts/example/.npmrc | 1 + spacetime-stripe-ts/example/spacetimedb/.npmrc | 1 + 46 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 spacetime-agents-ts/.npmrc create mode 100644 spacetime-agents-ts/example/.npmrc create mode 100644 spacetime-agents-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-agents-ts/spacetimedb/.npmrc create mode 100644 spacetime-api-keys-ts/.npmrc create mode 100644 spacetime-api-keys-ts/example/.npmrc create mode 100644 spacetime-api-keys-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-auth-ts/.npmrc create mode 100644 spacetime-auth-ts/example/.npmrc create mode 100644 spacetime-auth-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-auth-ts/spacetimedb/.npmrc create mode 100644 spacetime-cron-ts/.npmrc create mode 100644 spacetime-cron-ts/example/.npmrc create mode 100644 spacetime-cron-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-cron-ts/spacetimedb/.npmrc create mode 100644 spacetime-crypto-ts/.npmrc create mode 100644 spacetime-example-ui-ts/.npmrc create mode 100644 spacetime-files-ts/.npmrc create mode 100644 spacetime-files-ts/example/.npmrc create mode 100644 spacetime-files-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-grid-ts/.npmrc create mode 100644 spacetime-grid-ts/example/.npmrc create mode 100644 spacetime-grid-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-lobby-ts/.npmrc create mode 100644 spacetime-lobby-ts/example/.npmrc create mode 100644 spacetime-lobby-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-posthog-ts/.npmrc create mode 100644 spacetime-posthog-ts/example/.npmrc create mode 100644 spacetime-posthog-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-presence-ts/.npmrc create mode 100644 spacetime-presence-ts/example/.npmrc create mode 100644 spacetime-presence-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-presence-ts/spacetimedb/.npmrc create mode 100644 spacetime-rate-limit-ts/.npmrc create mode 100644 spacetime-rate-limit-ts/example/.npmrc create mode 100644 spacetime-rate-limit-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-rate-limit-ts/spacetimedb/.npmrc create mode 100644 spacetime-resend-ts/.npmrc create mode 100644 spacetime-resend-ts/example/.npmrc create mode 100644 spacetime-resend-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-retry-ts/.npmrc create mode 100644 spacetime-retry-ts/spacetimedb/.npmrc create mode 100644 spacetime-stripe-ts/.npmrc create mode 100644 spacetime-stripe-ts/example/.npmrc create mode 100644 spacetime-stripe-ts/example/spacetimedb/.npmrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f6d826f845..f6d4575146a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1708,8 +1708,8 @@ jobs: - name: Install released SpacetimeDB toolchain run: | curl -sSf https://install.spacetimedb.com | sh -s -- --root-dir "$RUNNER_TEMP/spacetime" --yes - echo "$RUNNER_TEMP/spacetime/bin" >> "$GITHUB_PATH" - "$RUNNER_TEMP/spacetime/bin/spacetime" version install 2.8.3 --use --yes + echo "$RUNNER_TEMP/spacetime" >> "$GITHUB_PATH" + "$RUNNER_TEMP/spacetime/spacetime" version install 2.8.3 --use --yes - name: Check submodules run: | diff --git a/spacetime-agents-ts/.npmrc b/spacetime-agents-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-agents-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-agents-ts/example/.npmrc b/spacetime-agents-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-agents-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-agents-ts/example/spacetimedb/.npmrc b/spacetime-agents-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-agents-ts/spacetimedb/.npmrc b/spacetime-agents-ts/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-api-keys-ts/.npmrc b/spacetime-api-keys-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-api-keys-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-api-keys-ts/example/.npmrc b/spacetime-api-keys-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-api-keys-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-api-keys-ts/example/spacetimedb/.npmrc b/spacetime-api-keys-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-api-keys-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-auth-ts/.npmrc b/spacetime-auth-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-auth-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-auth-ts/example/.npmrc b/spacetime-auth-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-auth-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-auth-ts/example/spacetimedb/.npmrc b/spacetime-auth-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-auth-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-auth-ts/spacetimedb/.npmrc b/spacetime-auth-ts/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-auth-ts/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-cron-ts/.npmrc b/spacetime-cron-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-cron-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-cron-ts/example/.npmrc b/spacetime-cron-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-cron-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-cron-ts/example/spacetimedb/.npmrc b/spacetime-cron-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-cron-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-cron-ts/spacetimedb/.npmrc b/spacetime-cron-ts/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-cron-ts/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-crypto-ts/.npmrc b/spacetime-crypto-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-crypto-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-example-ui-ts/.npmrc b/spacetime-example-ui-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-example-ui-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-files-ts/.npmrc b/spacetime-files-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-files-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-files-ts/example/.npmrc b/spacetime-files-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-files-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-files-ts/example/spacetimedb/.npmrc b/spacetime-files-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-files-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-grid-ts/.npmrc b/spacetime-grid-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-grid-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-grid-ts/example/.npmrc b/spacetime-grid-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-grid-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-grid-ts/example/spacetimedb/.npmrc b/spacetime-grid-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-grid-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-lobby-ts/.npmrc b/spacetime-lobby-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-lobby-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-lobby-ts/example/.npmrc b/spacetime-lobby-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-lobby-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-lobby-ts/example/spacetimedb/.npmrc b/spacetime-lobby-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-lobby-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-posthog-ts/.npmrc b/spacetime-posthog-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-posthog-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-posthog-ts/example/.npmrc b/spacetime-posthog-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-posthog-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-posthog-ts/example/spacetimedb/.npmrc b/spacetime-posthog-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-presence-ts/.npmrc b/spacetime-presence-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-presence-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-presence-ts/example/.npmrc b/spacetime-presence-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-presence-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-presence-ts/example/spacetimedb/.npmrc b/spacetime-presence-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-presence-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-presence-ts/spacetimedb/.npmrc b/spacetime-presence-ts/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-presence-ts/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-rate-limit-ts/.npmrc b/spacetime-rate-limit-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-rate-limit-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-rate-limit-ts/example/.npmrc b/spacetime-rate-limit-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-rate-limit-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-rate-limit-ts/example/spacetimedb/.npmrc b/spacetime-rate-limit-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-rate-limit-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-rate-limit-ts/spacetimedb/.npmrc b/spacetime-rate-limit-ts/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-rate-limit-ts/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-resend-ts/.npmrc b/spacetime-resend-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-resend-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-resend-ts/example/.npmrc b/spacetime-resend-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-resend-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-resend-ts/example/spacetimedb/.npmrc b/spacetime-resend-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-resend-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-retry-ts/.npmrc b/spacetime-retry-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-retry-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-retry-ts/spacetimedb/.npmrc b/spacetime-retry-ts/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-retry-ts/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-stripe-ts/.npmrc b/spacetime-stripe-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-stripe-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-stripe-ts/example/.npmrc b/spacetime-stripe-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-stripe-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-stripe-ts/example/spacetimedb/.npmrc b/spacetime-stripe-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 From 1916ae0611c4b5e148a71da9eec3462ebe32bf6b Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 13:26:04 -0400 Subject: [PATCH 29/33] Harden pending attachment previews --- spacetime-presence-ts/example/public/ui.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/spacetime-presence-ts/example/public/ui.js b/spacetime-presence-ts/example/public/ui.js index 4ea0af23572..441fa66134d 100644 --- a/spacetime-presence-ts/example/public/ui.js +++ b/spacetime-presence-ts/example/public/ui.js @@ -37,7 +37,7 @@ const REACTIONS = [ let typingTimer = null; let typingRenewTimer = null; -let pendingAtts = []; // { id, file, name, mimeType, bytes, previewUrl } +let pendingAtts = []; // { id, file, name, mimeType, bytes } let pendingAttSeq = 0; let replyTargetId = null; let editTargetId = null; @@ -1580,8 +1580,12 @@ function renderPendingAtts() { const isImage = attachment.mimeType.startsWith('image/'); if (isImage) { const image = document.createElement('img'); - image.src = attachment.previewUrl; + const objectUrl = URL.createObjectURL(attachment.file); + image.src = objectUrl; image.alt = attachment.name; + const releaseObjectUrl = () => URL.revokeObjectURL(objectUrl); + image.addEventListener('load', releaseObjectUrl, { once: true }); + image.addEventListener('error', releaseObjectUrl, { once: true }); item.append(image); } else { const name = document.createElement('div'); @@ -1611,8 +1615,7 @@ function renderPendingAtts() { const id = Number(btn.dataset.remove); const idx = pendingAtts.findIndex(p => p.id === id); if (idx < 0) return; - const removed = pendingAtts.splice(idx, 1)[0]; - if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl); + pendingAtts.splice(idx, 1); renderPendingAtts(); }); }); @@ -1645,16 +1648,12 @@ $('attachInput')?.addEventListener('change', async e => { try { const bytes = await readFileAsBytes(f); const mimeType = f.type || 'application/octet-stream'; - const previewUrl = mimeType.startsWith('image/') - ? URL.createObjectURL(f) - : null; pendingAtts.push({ id: ++pendingAttSeq, file: f, name: f.name, mimeType, bytes, - previewUrl, }); } catch (err) { setResult(`${f.name}: read failed - ${err.message ?? err}`, false); @@ -1664,8 +1663,6 @@ $('attachInput')?.addEventListener('change', async e => { }); function clearPendingAtts() { - for (const p of pendingAtts) - if (p.previewUrl) URL.revokeObjectURL(p.previewUrl); pendingAtts = []; renderPendingAtts(); } From 01c3c5c48e1770e7c504f4a6b078d709c0321f55 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 13:36:16 -0400 Subject: [PATCH 30/33] Fix clean submodule CI validation --- .github/workflows/ci.yml | 3 +++ spacetime-presence-ts/example/public/chat.css | 16 +++++++++--- spacetime-presence-ts/example/public/ui.js | 26 +++++-------------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6d4575146a..dbe000bb26f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1705,6 +1705,9 @@ jobs: with: run_install: true + - name: Build TypeScript SDK + run: pnpm --dir crates/bindings-typescript run build + - name: Install released SpacetimeDB toolchain run: | curl -sSf https://install.spacetimedb.com | sh -s -- --root-dir "$RUNNER_TEMP/spacetime" --yes diff --git a/spacetime-presence-ts/example/public/chat.css b/spacetime-presence-ts/example/public/chat.css index e767fff1f84..9dc5a5a0874 100644 --- a/spacetime-presence-ts/example/public/chat.css +++ b/spacetime-presence-ts/example/public/chat.css @@ -902,11 +902,19 @@ main.shell:not(.signed-out) .chat-body > .main > .composer.hidden { min-width: 80px; max-width: 140px; } -.pending-att img { - max-width: 96px; - max-height: 64px; +.pending-att-preview { + width: 96px; + height: 64px; border-radius: 4px; - object-fit: cover; + display: grid; + place-items: center; + background: var(--color-shade6); + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; } .pending-att-meta { font-size: 10px; diff --git a/spacetime-presence-ts/example/public/ui.js b/spacetime-presence-ts/example/public/ui.js index 441fa66134d..5b854880674 100644 --- a/spacetime-presence-ts/example/public/ui.js +++ b/spacetime-presence-ts/example/public/ui.js @@ -37,7 +37,7 @@ const REACTIONS = [ let typingTimer = null; let typingRenewTimer = null; -let pendingAtts = []; // { id, file, name, mimeType, bytes } +let pendingAtts = []; // { id, name, mimeType, bytes } let pendingAttSeq = 0; let replyTargetId = null; let editTargetId = null; @@ -1578,27 +1578,14 @@ function renderPendingAtts() { item.dataset.pid = String(attachment.id); const isImage = attachment.mimeType.startsWith('image/'); - if (isImage) { - const image = document.createElement('img'); - const objectUrl = URL.createObjectURL(attachment.file); - image.src = objectUrl; - image.alt = attachment.name; - const releaseObjectUrl = () => URL.revokeObjectURL(objectUrl); - image.addEventListener('load', releaseObjectUrl, { once: true }); - image.addEventListener('error', releaseObjectUrl, { once: true }); - item.append(image); - } else { - const name = document.createElement('div'); - name.className = 'pending-att-meta'; - name.textContent = attachment.name; - item.append(name); - } + const preview = document.createElement('div'); + preview.className = 'pending-att-preview'; + preview.textContent = isImage ? 'Image' : 'File'; + item.append(preview); const metadata = document.createElement('div'); metadata.className = 'pending-att-meta'; - metadata.textContent = isImage - ? `${attachment.name} - ${fmtBytes(attachment.bytes.length)}` - : fmtBytes(attachment.bytes.length); + metadata.textContent = `${attachment.name} - ${fmtBytes(attachment.bytes.length)}`; item.append(metadata); const removeButton = document.createElement('button'); @@ -1650,7 +1637,6 @@ $('attachInput')?.addEventListener('change', async e => { const mimeType = f.type || 'application/octet-stream'; pendingAtts.push({ id: ++pendingAttSeq, - file: f, name: f.name, mimeType, bytes, From 0d406edeb1e5449565d295f9d85e3d5b1d7fb64b Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 26 Aug 2026 13:52:10 -0400 Subject: [PATCH 31/33] Remove component wording from agents code --- spacetime-agents-ts/src/embeddings.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spacetime-agents-ts/src/embeddings.ts b/spacetime-agents-ts/src/embeddings.ts index 249c3b78d8f..4541780ee45 100644 --- a/spacetime-agents-ts/src/embeddings.ts +++ b/spacetime-agents-ts/src/embeddings.ts @@ -107,8 +107,7 @@ function postOpenAiEmbeddings( } if ( !row.embedding.every( - component => - typeof component === 'number' && Number.isFinite(component) + value => typeof value === 'number' && Number.isFinite(value) ) ) { throw new Error('embedding contains a non-finite value'); From 31d95c487a891c14e66ea577fea3ef93603c70f1 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Thu, 27 Aug 2026 09:32:34 -0400 Subject: [PATCH 32/33] Share example assets and server support --- eslint.config.js | 5 +- pnpm-lock.yaml | 29 ++++++--- spacetime-agents-ts/example/server.ts | 2 + .../example/public/assets/brand.svg | 17 ----- .../example/public/assets/logo.svg | 5 -- spacetime-auth-ts/example/server.ts | 2 + spacetime-cron-ts/example/package.json | 1 + .../example/public/assets/logo.svg | 5 -- spacetime-cron-ts/example/server.ts | 2 + .../assets/brand.svg | 0 .../assets/logo.svg | 0 spacetime-example-ui-ts/package.json | 4 ++ .../scripts/server.test.ts | 65 +++++++++++++++++++ spacetime-example-ui-ts/src/icons.ts | 36 ++-------- .../src/server.ts | 5 ++ spacetime-files-ts/example/package.json | 1 + .../example/public/assets/brand.svg | 17 ----- spacetime-files-ts/example/server.ts | 2 + .../example/public/assets/brand.svg | 17 ----- .../example/public/assets/logo.svg | 5 -- spacetime-grid-ts/example/server.ts | 2 + spacetime-lobby-ts/example/package.json | 1 + .../example/public/assets/brand.svg | 17 ----- spacetime-lobby-ts/example/server.ts | 2 + spacetime-posthog-ts/example/package.json | 1 + .../example/public/assets/brand.svg | 17 ----- spacetime-posthog-ts/example/server.ts | 8 ++- .../example/public/assets/logo.svg | 5 -- spacetime-presence-ts/example/server.ts | 2 + spacetime-rate-limit-ts/example/package.json | 1 + .../example/public/assets/brand.svg | 17 ----- .../example/public/assets/logo.svg | 5 -- spacetime-rate-limit-ts/example/server.ts | 2 + spacetime-resend-ts/example/package.json | 1 + .../example/public/assets/brand.svg | 17 ----- spacetime-resend-ts/example/server.ts | 6 +- spacetime-stripe-ts/example/package.json | 1 + .../example/public/assets/brand.svg | 17 ----- spacetime-stripe-ts/example/server.ts | 14 ++-- 39 files changed, 143 insertions(+), 213 deletions(-) delete mode 100644 spacetime-auth-ts/example/public/assets/brand.svg delete mode 100644 spacetime-auth-ts/example/public/assets/logo.svg delete mode 100644 spacetime-cron-ts/example/public/assets/logo.svg rename {spacetime-agents-ts/example/public => spacetime-example-ui-ts}/assets/brand.svg (100%) rename {spacetime-agents-ts/example/public => spacetime-example-ui-ts}/assets/logo.svg (100%) create mode 100644 spacetime-example-ui-ts/scripts/server.test.ts rename tools/example-server-identity.ts => spacetime-example-ui-ts/src/server.ts (93%) delete mode 100644 spacetime-files-ts/example/public/assets/brand.svg delete mode 100644 spacetime-grid-ts/example/public/assets/brand.svg delete mode 100644 spacetime-grid-ts/example/public/assets/logo.svg delete mode 100644 spacetime-lobby-ts/example/public/assets/brand.svg delete mode 100644 spacetime-posthog-ts/example/public/assets/brand.svg delete mode 100644 spacetime-presence-ts/example/public/assets/logo.svg delete mode 100644 spacetime-rate-limit-ts/example/public/assets/brand.svg delete mode 100644 spacetime-rate-limit-ts/example/public/assets/logo.svg delete mode 100644 spacetime-resend-ts/example/public/assets/brand.svg delete mode 100644 spacetime-stripe-ts/example/public/assets/brand.svg diff --git a/eslint.config.js b/eslint.config.js index bef1d92015a..b0885dc2613 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -65,6 +65,7 @@ export default tseslint.config( './tsconfig.json', './crates/bindings-typescript/tsconfig.json', './crates/bindings-typescript/test-app/tsconfig.json', + './spacetime-example-ui-ts/tsconfig.json', './templates/react-ts/tsconfig.json', './templates/chat-react-ts/tsconfig.json', './templates/money-exchange-react-ts/tsconfig.json', @@ -73,9 +74,7 @@ export default tseslint.config( './templates/angular-ts/tsconfig.app.json', './docs/tsconfig.json', ], - projectService: { - allowDefaultProject: ['tools/example-server-identity.ts'], - }, + projectService: true, tsconfigRootDir: __dirname, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ffba6f1332..c5bc151ee1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -635,6 +635,9 @@ importers: spacetime-cron-ts/example: dependencies: + '@spacetimedb/example-ui': + specifier: workspace:* + version: link:../../spacetime-example-ui-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -763,6 +766,9 @@ importers: spacetime-files-ts/example: dependencies: + '@spacetimedb/example-ui': + specifier: workspace:* + version: link:../../spacetime-example-ui-ts '@spacetimedb/files': specifier: workspace:* version: link:.. @@ -902,6 +908,9 @@ importers: spacetime-lobby-ts/example: dependencies: + '@spacetimedb/example-ui': + specifier: workspace:* + version: link:../../spacetime-example-ui-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -967,6 +976,9 @@ importers: spacetime-posthog-ts/example: dependencies: + '@spacetimedb/example-ui': + specifier: workspace:* + version: link:../../spacetime-example-ui-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -1125,6 +1137,9 @@ importers: spacetime-rate-limit-ts/example: dependencies: + '@spacetimedb/example-ui': + specifier: workspace:* + version: link:../../spacetime-example-ui-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -1213,6 +1228,9 @@ importers: spacetime-resend-ts/example: dependencies: + '@spacetimedb/example-ui': + specifier: workspace:* + version: link:../../spacetime-example-ui-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -1331,6 +1349,9 @@ importers: spacetime-stripe-ts/example: dependencies: + '@spacetimedb/example-ui': + specifier: workspace:* + version: link:../../spacetime-example-ui-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -10398,11 +10419,6 @@ packages: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -26930,9 +26946,6 @@ snapshots: dependencies: minipass: 7.1.2 - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true diff --git a/spacetime-agents-ts/example/server.ts b/spacetime-agents-ts/example/server.ts index de2fb600179..efbf07e7a8e 100644 --- a/spacetime-agents-ts/example/server.ts +++ b/spacetime-agents-ts/example/server.ts @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -212,6 +213,7 @@ function proxyStdbRoute(prefix: string) { app.use('/auth', proxyStdbRoute('/auth')); app.use('/files', proxyStdbRoute('/files')); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use(express.static(PUBLIC_DIR)); app.get('/api/health', (_req: Request, res: Response) => { diff --git a/spacetime-auth-ts/example/public/assets/brand.svg b/spacetime-auth-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-auth-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-auth-ts/example/public/assets/logo.svg b/spacetime-auth-ts/example/public/assets/logo.svg deleted file mode 100644 index adaf36cbf9c..00000000000 --- a/spacetime-auth-ts/example/public/assets/logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts index a6dbc4f6d21..aa24e4a008d 100644 --- a/spacetime-auth-ts/example/server.ts +++ b/spacetime-auth-ts/example/server.ts @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -174,6 +175,7 @@ app.get('/api/health', (_req: Request, res: Response) => { res.json({ ok: true, databaseName: DB_NAME }); }); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use(express.static(PUBLIC_DIR)); try { diff --git a/spacetime-cron-ts/example/package.json b/spacetime-cron-ts/example/package.json index ea2e7400c11..ae3e62fda24 100644 --- a/spacetime-cron-ts/example/package.json +++ b/spacetime-cron-ts/example/package.json @@ -13,6 +13,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-cron-ts/example/public/assets/logo.svg b/spacetime-cron-ts/example/public/assets/logo.svg deleted file mode 100644 index be9ec6695c2..00000000000 --- a/spacetime-cron-ts/example/public/assets/logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/spacetime-cron-ts/example/server.ts b/spacetime-cron-ts/example/server.ts index d911fdbe4a5..fced927aae4 100644 --- a/spacetime-cron-ts/example/server.ts +++ b/spacetime-cron-ts/example/server.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -34,6 +35,7 @@ const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-cron-example'; const app = express(); app.use(express.json({ limit: '256kb' })); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { diff --git a/spacetime-agents-ts/example/public/assets/brand.svg b/spacetime-example-ui-ts/assets/brand.svg similarity index 100% rename from spacetime-agents-ts/example/public/assets/brand.svg rename to spacetime-example-ui-ts/assets/brand.svg diff --git a/spacetime-agents-ts/example/public/assets/logo.svg b/spacetime-example-ui-ts/assets/logo.svg similarity index 100% rename from spacetime-agents-ts/example/public/assets/logo.svg rename to spacetime-example-ui-ts/assets/logo.svg diff --git a/spacetime-example-ui-ts/package.json b/spacetime-example-ui-ts/package.json index 3c83e5f0cea..db7dc877856 100644 --- a/spacetime-example-ui-ts/package.json +++ b/spacetime-example-ui-ts/package.json @@ -11,6 +11,10 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, + "./server": { + "types": "./src/server.ts", + "default": "./src/server.ts" + }, "./styles.css": "./src/styles/index.css" }, "scripts": { diff --git a/spacetime-example-ui-ts/scripts/server.test.ts b/spacetime-example-ui-ts/scripts/server.test.ts new file mode 100644 index 00000000000..de9df7ebcd8 --- /dev/null +++ b/spacetime-example-ui-ts/scripts/server.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + discardStoredServerToken, + exampleUiAssetsDir, + loadServerToken, + saveServerToken, +} from '../src/server'; + +const temporaryDirectories: string[] = []; + +function temporaryTokenPath(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'stdb-example-ui-')); + temporaryDirectories.push(directory); + return path.join(directory, 'server-token'); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('example server support', () => { + it('resolves the shared SVG assets', () => { + for (const filename of ['brand.svg', 'logo.svg']) { + expect( + readFileSync(path.join(exampleUiAssetsDir, filename), 'utf8') + ).toMatch(/^ { + const tokenPath = temporaryTokenPath(); + writeFileSync(tokenPath, 'stored-token\n'); + + expect(loadServerToken(tokenPath, ' environment-token ')).toEqual({ + token: 'environment-token', + source: 'environment', + }); + }); + + it('saves, loads, and discards a server token', () => { + const tokenPath = temporaryTokenPath(); + + expect(loadServerToken(tokenPath, undefined)).toEqual({ + token: undefined, + source: 'none', + }); + + saveServerToken(tokenPath, ' generated-token '); + expect(loadServerToken(tokenPath, undefined)).toEqual({ + token: 'generated-token', + source: 'file', + }); + + discardStoredServerToken(tokenPath); + expect(loadServerToken(tokenPath, undefined)).toEqual({ + token: undefined, + source: 'none', + }); + }); +}); diff --git a/spacetime-example-ui-ts/src/icons.ts b/spacetime-example-ui-ts/src/icons.ts index d8be3a7b3db..f04ada242fa 100644 --- a/spacetime-example-ui-ts/src/icons.ts +++ b/spacetime-example-ui-ts/src/icons.ts @@ -42,34 +42,10 @@ export function githubIcon(): SVGSVGElement { return svg; } -export function spacetimeMark(): SVGSVGElement { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('viewBox', '0 0 35 32'); - svg.setAttribute('role', 'img'); - svg.setAttribute('aria-label', 'SpacetimeDB'); - svg.classList.add('auth-logo'); - for (const [d, fillRule] of [ - [ - 'M28.8002 15.317C28.5535 9.53226 29.63 6.19046 35 0L24.2649 10.9106C26.8343 14.2552 26.6051 19.0995 23.5774 22.1767C20.5498 25.2538 15.7834 25.4867 12.4925 22.8754L10.5042 24.8962L10.5116 24.9024L7.35285 28.1361C9.73784 26.7321 13.4208 27.1349 15.6425 27.3779C16.2579 27.4452 16.7611 27.5003 17.0937 27.5013C20.1371 27.6534 23.2301 26.5483 25.5544 24.186C27.9465 21.7549 29.0284 18.4965 28.8002 15.317Z', - false, - ], - [ - 'M17.9063 4.49871C18.2389 4.49971 18.7421 4.55476 19.3575 4.62207C21.5792 4.86508 25.2622 5.26792 27.6472 3.86395L24.4884 7.0976L24.4958 7.10383L22.5075 9.12462C19.2166 6.51328 14.4502 6.74618 11.4226 9.82332C8.3949 12.9005 8.16574 17.7448 10.7351 21.0894L0 32C5.36996 25.8095 6.44651 22.4677 6.1998 16.683C5.97163 13.5035 7.05355 10.2451 9.44557 7.81402C11.7699 5.45167 14.8629 4.34657 17.9063 4.49871Z', - false, - ], - [ - 'M24.7486 16C24.7486 20.0687 21.5033 23.367 17.5 23.367C13.4967 23.367 10.2514 20.0687 10.2514 16C10.2514 11.9313 13.4967 8.63292 17.5 8.63292C21.5033 8.63292 24.7486 11.9313 24.7486 16ZM17.5 21.6C20.5752 21.6 23.0682 19.0928 23.0682 16C23.0682 12.9072 20.5752 10.4 17.5 10.4C14.4248 10.4 11.9318 12.9072 11.9318 16C11.9318 19.0928 14.4248 21.6 17.5 21.6Z', - true, - ], - ] as const) { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', d); - path.setAttribute('fill', '#D7D8D9'); - if (fillRule) { - path.setAttribute('fill-rule', 'evenodd'); - path.setAttribute('clip-rule', 'evenodd'); - } - svg.append(path); - } - return svg; +export function spacetimeMark(): HTMLImageElement { + const image = document.createElement('img'); + image.src = '/assets/logo.svg'; + image.alt = 'SpacetimeDB'; + image.classList.add('auth-logo'); + return image; } diff --git a/tools/example-server-identity.ts b/spacetime-example-ui-ts/src/server.ts similarity index 93% rename from tools/example-server-identity.ts rename to spacetime-example-ui-ts/src/server.ts index a1f6c9564f9..c81955b7853 100644 --- a/tools/example-server-identity.ts +++ b/spacetime-example-ui-ts/src/server.ts @@ -1,5 +1,10 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +export const exampleUiAssetsDir = fileURLToPath( + new URL('../assets/', import.meta.url) +); export type StoredServerToken = { token: string | undefined; diff --git a/spacetime-files-ts/example/package.json b/spacetime-files-ts/example/package.json index 2bc198c921b..70c43fc01a7 100644 --- a/spacetime-files-ts/example/package.json +++ b/spacetime-files-ts/example/package.json @@ -14,6 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "@spacetimedb/files": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", diff --git a/spacetime-files-ts/example/public/assets/brand.svg b/spacetime-files-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-files-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-files-ts/example/server.ts b/spacetime-files-ts/example/server.ts index 39a6ac9d943..4111e43d629 100644 --- a/spacetime-files-ts/example/server.ts +++ b/spacetime-files-ts/example/server.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -86,6 +87,7 @@ function proxyStdbRoute(prefix: string) { } app.use('/files', proxyStdbRoute('/files')); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { diff --git a/spacetime-grid-ts/example/public/assets/brand.svg b/spacetime-grid-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-grid-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-grid-ts/example/public/assets/logo.svg b/spacetime-grid-ts/example/public/assets/logo.svg deleted file mode 100644 index adaf36cbf9c..00000000000 --- a/spacetime-grid-ts/example/public/assets/logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/spacetime-grid-ts/example/server.ts b/spacetime-grid-ts/example/server.ts index 908f89bf69c..25d37cc4f5c 100644 --- a/spacetime-grid-ts/example/server.ts +++ b/spacetime-grid-ts/example/server.ts @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -145,6 +146,7 @@ app.use('/auth', async (req, res) => { } }); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use(express.static(PUBLIC_DIR)); app.get('/api/health', (_req: Request, res: Response) => { diff --git a/spacetime-lobby-ts/example/package.json b/spacetime-lobby-ts/example/package.json index f274eb6d40e..3d22553cf64 100644 --- a/spacetime-lobby-ts/example/package.json +++ b/spacetime-lobby-ts/example/package.json @@ -14,6 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-lobby-ts/example/public/assets/brand.svg b/spacetime-lobby-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-lobby-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-lobby-ts/example/server.ts b/spacetime-lobby-ts/example/server.ts index 617eec266cb..6c8b2442399 100644 --- a/spacetime-lobby-ts/example/server.ts +++ b/spacetime-lobby-ts/example/server.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import * as dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -29,6 +30,7 @@ const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-lobby-example'; const app = express(); app.use(express.json({ limit: '128kb' })); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { diff --git a/spacetime-posthog-ts/example/package.json b/spacetime-posthog-ts/example/package.json index 816fb08c308..be92c224adf 100644 --- a/spacetime-posthog-ts/example/package.json +++ b/spacetime-posthog-ts/example/package.json @@ -14,6 +14,7 @@ "test": "tsx scripts/test-economy.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-posthog-ts/example/public/assets/brand.svg b/spacetime-posthog-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-posthog-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-posthog-ts/example/server.ts b/spacetime-posthog-ts/example/server.ts index cc040d07426..7a9de0b2568 100644 --- a/spacetime-posthog-ts/example/server.ts +++ b/spacetime-posthog-ts/example/server.ts @@ -4,14 +4,15 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { DbConnection, tables, type ErrorContext } from './src/module_bindings'; -import { PRODUCTS, SCENARIOS } from './catalog/catalog'; import { discardStoredServerToken, + exampleUiAssetsDir, grantServerIdentity, loadServerToken, saveServerToken, -} from '../../tools/example-server-identity'; +} from '@spacetimedb/example-ui/server'; +import { DbConnection, tables, type ErrorContext } from './src/module_bindings'; +import { PRODUCTS, SCENARIOS } from './catalog/catalog'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -203,6 +204,7 @@ function posthogAppUrl(): string { const app = express(); app.use(express.json({ limit: '256kb' })); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { diff --git a/spacetime-presence-ts/example/public/assets/logo.svg b/spacetime-presence-ts/example/public/assets/logo.svg deleted file mode 100644 index adaf36cbf9c..00000000000 --- a/spacetime-presence-ts/example/public/assets/logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/spacetime-presence-ts/example/server.ts b/spacetime-presence-ts/example/server.ts index 2478682e68b..7ab07a5e539 100644 --- a/spacetime-presence-ts/example/server.ts +++ b/spacetime-presence-ts/example/server.ts @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -179,6 +180,7 @@ app.get('/api/config', (_req: Request, res: Response) => { }); }); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use(express.static(PUBLIC_DIR)); try { diff --git a/spacetime-rate-limit-ts/example/package.json b/spacetime-rate-limit-ts/example/package.json index 2ffbc3b47a8..040123411dd 100644 --- a/spacetime-rate-limit-ts/example/package.json +++ b/spacetime-rate-limit-ts/example/package.json @@ -14,6 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-rate-limit-ts/example/public/assets/brand.svg b/spacetime-rate-limit-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-rate-limit-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-rate-limit-ts/example/public/assets/logo.svg b/spacetime-rate-limit-ts/example/public/assets/logo.svg deleted file mode 100644 index adaf36cbf9c..00000000000 --- a/spacetime-rate-limit-ts/example/public/assets/logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/spacetime-rate-limit-ts/example/server.ts b/spacetime-rate-limit-ts/example/server.ts index 69d238011b9..a869599910a 100644 --- a/spacetime-rate-limit-ts/example/server.ts +++ b/spacetime-rate-limit-ts/example/server.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; dotenv.config(); @@ -16,6 +17,7 @@ const DB_NAME = const app = express(); app.use(express.json({ limit: '256kb' })); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use( express.static(path.join(__dirname, 'public'), { etag: false, diff --git a/spacetime-resend-ts/example/package.json b/spacetime-resend-ts/example/package.json index 6349fb2a266..2dc1f7f6aa2 100644 --- a/spacetime-resend-ts/example/package.json +++ b/spacetime-resend-ts/example/package.json @@ -14,6 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-resend-ts/example/public/assets/brand.svg b/spacetime-resend-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-resend-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-resend-ts/example/server.ts b/spacetime-resend-ts/example/server.ts index 0ba5a7e371d..5232d87182a 100644 --- a/spacetime-resend-ts/example/server.ts +++ b/spacetime-resend-ts/example/server.ts @@ -3,13 +3,14 @@ import { fileURLToPath } from 'node:url'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { DbConnection, type ErrorContext } from './src/module_bindings'; import { discardStoredServerToken, + exampleUiAssetsDir, grantServerIdentity, loadServerToken, saveServerToken, -} from '../../tools/example-server-identity'; +} from '@spacetimedb/example-ui/server'; +import { DbConnection, type ErrorContext } from './src/module_bindings'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -122,6 +123,7 @@ app.post( ); app.use(express.json({ limit: '512kb' })); +app.use('/assets', express.static(exampleUiAssetsDir)); app.use(express.static(path.join(__dirname, 'public'))); app.get('/api/health', (_req: Request, res: Response) => { diff --git a/spacetime-stripe-ts/example/package.json b/spacetime-stripe-ts/example/package.json index 8a301cd12c0..e72a429a289 100644 --- a/spacetime-stripe-ts/example/package.json +++ b/spacetime-stripe-ts/example/package.json @@ -13,6 +13,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { + "@spacetimedb/example-ui": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-stripe-ts/example/public/assets/brand.svg b/spacetime-stripe-ts/example/public/assets/brand.svg deleted file mode 100644 index 77cc436631a..00000000000 --- a/spacetime-stripe-ts/example/public/assets/brand.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts index 54692d367e8..557163346e1 100644 --- a/spacetime-stripe-ts/example/server.ts +++ b/spacetime-stripe-ts/example/server.ts @@ -2,17 +2,18 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { - DbConnection, - tables, - type ErrorContext, -} from './src/module_bindings/app'; import { discardStoredServerToken, + exampleUiAssetsDir, grantServerIdentity, loadServerToken, saveServerToken, -} from '../../tools/example-server-identity'; +} from '@spacetimedb/example-ui/server'; +import { + DbConnection, + tables, + type ErrorContext, +} from './src/module_bindings/app'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -191,6 +192,7 @@ async function configureStripeFromEnv(): Promise< const app = express(); app.use(express.json({ limit: '512kb' })); +app.use('/assets', express.static(exampleUiAssetsDir, staticOptions())); app.use(express.static(path.join(__dirname, 'public'), staticOptions())); function staticOptions() { From 2216c1b2d327213c2b6bb2dea96355c19ec3d1eb Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Thu, 27 Aug 2026 09:41:31 -0400 Subject: [PATCH 33/33] Rename shared submodule example package --- eslint.config.js | 2 +- pnpm-lock.yaml | 88 +++++++++---------- spacetime-agents-ts/example/package.json | 2 +- spacetime-agents-ts/example/server.ts | 2 +- spacetime-agents-ts/example/src/app.ts | 4 +- spacetime-auth-ts/example/package.json | 2 +- spacetime-auth-ts/example/server.ts | 2 +- spacetime-auth-ts/example/src/app.ts | 4 +- spacetime-cron-ts/example/package.json | 2 +- spacetime-cron-ts/example/server.ts | 2 +- spacetime-files-ts/example/package.json | 2 +- spacetime-files-ts/example/server.ts | 2 +- spacetime-grid-ts/example/package.json | 2 +- spacetime-grid-ts/example/server.ts | 2 +- spacetime-grid-ts/example/src/app.ts | 4 +- spacetime-lobby-ts/example/package.json | 2 +- spacetime-lobby-ts/example/server.ts | 2 +- spacetime-posthog-ts/example/package.json | 2 +- spacetime-posthog-ts/example/server.ts | 2 +- spacetime-presence-ts/example/package.json | 2 +- spacetime-presence-ts/example/server.ts | 2 +- spacetime-presence-ts/example/src/app.ts | 4 +- spacetime-rate-limit-ts/example/package.json | 2 +- spacetime-rate-limit-ts/example/server.ts | 2 +- spacetime-resend-ts/example/package.json | 2 +- spacetime-resend-ts/example/server.ts | 2 +- spacetime-stripe-ts/example/package.json | 2 +- spacetime-stripe-ts/example/server.ts | 2 +- .../.npmrc | 0 .../assets/brand.svg | 0 .../assets/logo.svg | 0 .../package.json | 4 +- .../scripts/auth.test.ts | 0 .../scripts/server.test.ts | 2 +- .../src/auth-panel.ts | 0 .../src/icons.ts | 0 .../src/index.ts | 0 .../src/server.ts | 0 .../src/styles/index.css | 0 .../tsconfig.json | 0 40 files changed, 78 insertions(+), 78 deletions(-) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/.npmrc (100%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/assets/brand.svg (100%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/assets/logo.svg (100%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/package.json (85%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/scripts/auth.test.ts (100%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/scripts/server.test.ts (95%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/src/auth-panel.ts (100%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/src/icons.ts (100%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/src/index.ts (100%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/src/server.ts (100%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/src/styles/index.css (100%) rename {spacetime-example-ui-ts => spacetime-submodule-shared-ts}/tsconfig.json (100%) diff --git a/eslint.config.js b/eslint.config.js index b0885dc2613..ab363899e57 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -65,7 +65,7 @@ export default tseslint.config( './tsconfig.json', './crates/bindings-typescript/tsconfig.json', './crates/bindings-typescript/test-app/tsconfig.json', - './spacetime-example-ui-ts/tsconfig.json', + './spacetime-submodule-shared-ts/tsconfig.json', './templates/react-ts/tsconfig.json', './templates/chat-react-ts/tsconfig.json', './templates/money-exchange-react-ts/tsconfig.json', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5bc151ee1b..f113bdeb386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,9 +363,9 @@ importers: spacetime-agents-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -544,9 +544,9 @@ importers: spacetime-auth-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -635,9 +635,9 @@ importers: spacetime-cron-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -718,27 +718,6 @@ importers: specifier: ^5.9.3 version: 5.9.3 - spacetime-example-ui-ts: - devDependencies: - '@types/node': - specifier: ^22.10.2 - version: 22.18.0 - eslint: - specifier: ^9.17.0 - version: 9.33.0(jiti@2.6.1) - jsdom: - specifier: ^26.1.0 - version: 26.1.0 - prettier: - specifier: ^3.3.3 - version: 3.6.2 - typescript: - specifier: ~5.6.2 - version: 5.6.3 - vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) - spacetime-files-ts: dependencies: '@spacetimedb/crypto': @@ -766,12 +745,12 @@ importers: spacetime-files-ts/example: dependencies: - '@spacetimedb/example-ui': - specifier: workspace:* - version: link:../../spacetime-example-ui-ts '@spacetimedb/files': specifier: workspace:* version: link:.. + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -837,9 +816,9 @@ importers: spacetime-grid-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -908,9 +887,9 @@ importers: spacetime-lobby-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -976,9 +955,9 @@ importers: spacetime-posthog-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -1044,9 +1023,9 @@ importers: spacetime-presence-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -1137,9 +1116,9 @@ importers: spacetime-rate-limit-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -1228,9 +1207,9 @@ importers: spacetime-resend-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -1349,9 +1328,9 @@ importers: spacetime-stripe-ts/example: dependencies: - '@spacetimedb/example-ui': + '@spacetimedb/submodule-shared': specifier: workspace:* - version: link:../../spacetime-example-ui-ts + version: link:../../spacetime-submodule-shared-ts dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -1394,6 +1373,27 @@ importers: specifier: ^5.9.3 version: 5.9.3 + spacetime-submodule-shared-ts: + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + prettier: + specifier: ^3.3.3 + version: 3.6.2 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.3)(terser@5.43.1)(tsx@4.23.12)(yaml@2.8.2) + templates/angular-ts: dependencies: '@angular/common': diff --git a/spacetime-agents-ts/example/package.json b/spacetime-agents-ts/example/package.json index 67869a23c2e..ae88eded0cc 100644 --- a/spacetime-agents-ts/example/package.json +++ b/spacetime-agents-ts/example/package.json @@ -14,7 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-agents-ts/example/server.ts b/spacetime-agents-ts/example/server.ts index efbf07e7a8e..b4a10447390 100644 --- a/spacetime-agents-ts/example/server.ts +++ b/spacetime-agents-ts/example/server.ts @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/spacetime-agents-ts/example/src/app.ts b/spacetime-agents-ts/example/src/app.ts index 6e063fe1e21..df0f03016ee 100644 --- a/spacetime-agents-ts/example/src/app.ts +++ b/spacetime-agents-ts/example/src/app.ts @@ -2,8 +2,8 @@ import { authUrlState, clearAuthResultParams, mountAuthPanel, -} from '@spacetimedb/example-ui'; -import '@spacetimedb/example-ui/styles.css'; +} from '@spacetimedb/submodule-shared'; +import '@spacetimedb/submodule-shared/styles.css'; import { DbConnection, tables, diff --git a/spacetime-auth-ts/example/package.json b/spacetime-auth-ts/example/package.json index bad17f39900..709f999f1c9 100644 --- a/spacetime-auth-ts/example/package.json +++ b/spacetime-auth-ts/example/package.json @@ -13,7 +13,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts index aa24e4a008d..9653b5f3e92 100644 --- a/spacetime-auth-ts/example/server.ts +++ b/spacetime-auth-ts/example/server.ts @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/spacetime-auth-ts/example/src/app.ts b/spacetime-auth-ts/example/src/app.ts index 94d6b8817a4..ba9dd45e747 100644 --- a/spacetime-auth-ts/example/src/app.ts +++ b/spacetime-auth-ts/example/src/app.ts @@ -2,8 +2,8 @@ import { authUrlState, clearAuthResultParams, mountAuthPanel, -} from '@spacetimedb/example-ui'; -import '@spacetimedb/example-ui/styles.css'; +} from '@spacetimedb/submodule-shared'; +import '@spacetimedb/submodule-shared/styles.css'; import { DbConnection, tables, diff --git a/spacetime-cron-ts/example/package.json b/spacetime-cron-ts/example/package.json index ae3e62fda24..46b087eebd9 100644 --- a/spacetime-cron-ts/example/package.json +++ b/spacetime-cron-ts/example/package.json @@ -13,7 +13,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-cron-ts/example/server.ts b/spacetime-cron-ts/example/server.ts index fced927aae4..ee49651af4a 100644 --- a/spacetime-cron-ts/example/server.ts +++ b/spacetime-cron-ts/example/server.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/spacetime-files-ts/example/package.json b/spacetime-files-ts/example/package.json index 70c43fc01a7..cdb15d75c86 100644 --- a/spacetime-files-ts/example/package.json +++ b/spacetime-files-ts/example/package.json @@ -14,7 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "@spacetimedb/files": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", diff --git a/spacetime-files-ts/example/server.ts b/spacetime-files-ts/example/server.ts index 4111e43d629..e3b0bf79603 100644 --- a/spacetime-files-ts/example/server.ts +++ b/spacetime-files-ts/example/server.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/spacetime-grid-ts/example/package.json b/spacetime-grid-ts/example/package.json index 651d09e361c..5c2cd23f8e1 100644 --- a/spacetime-grid-ts/example/package.json +++ b/spacetime-grid-ts/example/package.json @@ -14,7 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-grid-ts/example/server.ts b/spacetime-grid-ts/example/server.ts index 25d37cc4f5c..1cd5b6b02f5 100644 --- a/spacetime-grid-ts/example/server.ts +++ b/spacetime-grid-ts/example/server.ts @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/spacetime-grid-ts/example/src/app.ts b/spacetime-grid-ts/example/src/app.ts index 3d879c6fb55..2b9b78f87f8 100644 --- a/spacetime-grid-ts/example/src/app.ts +++ b/spacetime-grid-ts/example/src/app.ts @@ -2,8 +2,8 @@ import { authUrlState, clearAuthResultParams, mountAuthPanel, -} from '@spacetimedb/example-ui'; -import '@spacetimedb/example-ui/styles.css'; +} from '@spacetimedb/submodule-shared'; +import '@spacetimedb/submodule-shared/styles.css'; import { DbConnection, tables, diff --git a/spacetime-lobby-ts/example/package.json b/spacetime-lobby-ts/example/package.json index 3d22553cf64..05b2c54293d 100644 --- a/spacetime-lobby-ts/example/package.json +++ b/spacetime-lobby-ts/example/package.json @@ -14,7 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-lobby-ts/example/server.ts b/spacetime-lobby-ts/example/server.ts index 6c8b2442399..c78b99727ca 100644 --- a/spacetime-lobby-ts/example/server.ts +++ b/spacetime-lobby-ts/example/server.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import * as dotenv from 'dotenv'; -import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/spacetime-posthog-ts/example/package.json b/spacetime-posthog-ts/example/package.json index be92c224adf..83c60d7dc03 100644 --- a/spacetime-posthog-ts/example/package.json +++ b/spacetime-posthog-ts/example/package.json @@ -14,7 +14,7 @@ "test": "tsx scripts/test-economy.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-posthog-ts/example/server.ts b/spacetime-posthog-ts/example/server.ts index 7a9de0b2568..409fc308cf2 100644 --- a/spacetime-posthog-ts/example/server.ts +++ b/spacetime-posthog-ts/example/server.ts @@ -10,7 +10,7 @@ import { grantServerIdentity, loadServerToken, saveServerToken, -} from '@spacetimedb/example-ui/server'; +} from '@spacetimedb/submodule-shared/server'; import { DbConnection, tables, type ErrorContext } from './src/module_bindings'; import { PRODUCTS, SCENARIOS } from './catalog/catalog'; diff --git a/spacetime-presence-ts/example/package.json b/spacetime-presence-ts/example/package.json index 51252eac9fe..5fe3f17058f 100644 --- a/spacetime-presence-ts/example/package.json +++ b/spacetime-presence-ts/example/package.json @@ -14,7 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-presence-ts/example/server.ts b/spacetime-presence-ts/example/server.ts index 7ab07a5e539..bf07145b9c6 100644 --- a/spacetime-presence-ts/example/server.ts +++ b/spacetime-presence-ts/example/server.ts @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/spacetime-presence-ts/example/src/app.ts b/spacetime-presence-ts/example/src/app.ts index ce638860d25..58ec8c1687c 100644 --- a/spacetime-presence-ts/example/src/app.ts +++ b/spacetime-presence-ts/example/src/app.ts @@ -2,8 +2,8 @@ import { authUrlState, clearAuthResultParams, mountAuthPanel, -} from '@spacetimedb/example-ui'; -import '@spacetimedb/example-ui/styles.css'; +} from '@spacetimedb/submodule-shared'; +import '@spacetimedb/submodule-shared/styles.css'; import { DbConnection, tables, diff --git a/spacetime-rate-limit-ts/example/package.json b/spacetime-rate-limit-ts/example/package.json index 040123411dd..d9fd9da62b8 100644 --- a/spacetime-rate-limit-ts/example/package.json +++ b/spacetime-rate-limit-ts/example/package.json @@ -14,7 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-rate-limit-ts/example/server.ts b/spacetime-rate-limit-ts/example/server.ts index a869599910a..5429fba7566 100644 --- a/spacetime-rate-limit-ts/example/server.ts +++ b/spacetime-rate-limit-ts/example/server.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import express, { type Request, type Response } from 'express'; import dotenv from 'dotenv'; -import { exampleUiAssetsDir } from '@spacetimedb/example-ui/server'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; dotenv.config(); diff --git a/spacetime-resend-ts/example/package.json b/spacetime-resend-ts/example/package.json index 2dc1f7f6aa2..d7ced031655 100644 --- a/spacetime-resend-ts/example/package.json +++ b/spacetime-resend-ts/example/package.json @@ -14,7 +14,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-resend-ts/example/server.ts b/spacetime-resend-ts/example/server.ts index 5232d87182a..4d3412e9dde 100644 --- a/spacetime-resend-ts/example/server.ts +++ b/spacetime-resend-ts/example/server.ts @@ -9,7 +9,7 @@ import { grantServerIdentity, loadServerToken, saveServerToken, -} from '@spacetimedb/example-ui/server'; +} from '@spacetimedb/submodule-shared/server'; import { DbConnection, type ErrorContext } from './src/module_bindings'; const __filename = fileURLToPath(import.meta.url); diff --git a/spacetime-stripe-ts/example/package.json b/spacetime-stripe-ts/example/package.json index e72a429a289..90d0a5087b6 100644 --- a/spacetime-stripe-ts/example/package.json +++ b/spacetime-stripe-ts/example/package.json @@ -13,7 +13,7 @@ "dev": "pnpm run build && tsx server.ts" }, "dependencies": { - "@spacetimedb/example-ui": "workspace:*", + "@spacetimedb/submodule-shared": "workspace:*", "dotenv": "^16.4.7", "express": "^4.21.2", "spacetimedb": "workspace:*" diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts index 557163346e1..83dbdd58714 100644 --- a/spacetime-stripe-ts/example/server.ts +++ b/spacetime-stripe-ts/example/server.ts @@ -8,7 +8,7 @@ import { grantServerIdentity, loadServerToken, saveServerToken, -} from '@spacetimedb/example-ui/server'; +} from '@spacetimedb/submodule-shared/server'; import { DbConnection, tables, diff --git a/spacetime-example-ui-ts/.npmrc b/spacetime-submodule-shared-ts/.npmrc similarity index 100% rename from spacetime-example-ui-ts/.npmrc rename to spacetime-submodule-shared-ts/.npmrc diff --git a/spacetime-example-ui-ts/assets/brand.svg b/spacetime-submodule-shared-ts/assets/brand.svg similarity index 100% rename from spacetime-example-ui-ts/assets/brand.svg rename to spacetime-submodule-shared-ts/assets/brand.svg diff --git a/spacetime-example-ui-ts/assets/logo.svg b/spacetime-submodule-shared-ts/assets/logo.svg similarity index 100% rename from spacetime-example-ui-ts/assets/logo.svg rename to spacetime-submodule-shared-ts/assets/logo.svg diff --git a/spacetime-example-ui-ts/package.json b/spacetime-submodule-shared-ts/package.json similarity index 85% rename from spacetime-example-ui-ts/package.json rename to spacetime-submodule-shared-ts/package.json index db7dc877856..83ee4fa12af 100644 --- a/spacetime-example-ui-ts/package.json +++ b/spacetime-submodule-shared-ts/package.json @@ -1,6 +1,6 @@ { - "name": "@spacetimedb/example-ui", - "description": "Shared interface primitives for SpacetimeDB submodule examples.", + "name": "@spacetimedb/submodule-shared", + "description": "Shared UI and server utilities for SpacetimeDB submodule examples.", "version": "0.1.0", "private": true, "type": "module", diff --git a/spacetime-example-ui-ts/scripts/auth.test.ts b/spacetime-submodule-shared-ts/scripts/auth.test.ts similarity index 100% rename from spacetime-example-ui-ts/scripts/auth.test.ts rename to spacetime-submodule-shared-ts/scripts/auth.test.ts diff --git a/spacetime-example-ui-ts/scripts/server.test.ts b/spacetime-submodule-shared-ts/scripts/server.test.ts similarity index 95% rename from spacetime-example-ui-ts/scripts/server.test.ts rename to spacetime-submodule-shared-ts/scripts/server.test.ts index de9df7ebcd8..3162aa88da1 100644 --- a/spacetime-example-ui-ts/scripts/server.test.ts +++ b/spacetime-submodule-shared-ts/scripts/server.test.ts @@ -12,7 +12,7 @@ import { const temporaryDirectories: string[] = []; function temporaryTokenPath(): string { - const directory = mkdtempSync(path.join(tmpdir(), 'stdb-example-ui-')); + const directory = mkdtempSync(path.join(tmpdir(), 'stdb-submodule-shared-')); temporaryDirectories.push(directory); return path.join(directory, 'server-token'); } diff --git a/spacetime-example-ui-ts/src/auth-panel.ts b/spacetime-submodule-shared-ts/src/auth-panel.ts similarity index 100% rename from spacetime-example-ui-ts/src/auth-panel.ts rename to spacetime-submodule-shared-ts/src/auth-panel.ts diff --git a/spacetime-example-ui-ts/src/icons.ts b/spacetime-submodule-shared-ts/src/icons.ts similarity index 100% rename from spacetime-example-ui-ts/src/icons.ts rename to spacetime-submodule-shared-ts/src/icons.ts diff --git a/spacetime-example-ui-ts/src/index.ts b/spacetime-submodule-shared-ts/src/index.ts similarity index 100% rename from spacetime-example-ui-ts/src/index.ts rename to spacetime-submodule-shared-ts/src/index.ts diff --git a/spacetime-example-ui-ts/src/server.ts b/spacetime-submodule-shared-ts/src/server.ts similarity index 100% rename from spacetime-example-ui-ts/src/server.ts rename to spacetime-submodule-shared-ts/src/server.ts diff --git a/spacetime-example-ui-ts/src/styles/index.css b/spacetime-submodule-shared-ts/src/styles/index.css similarity index 100% rename from spacetime-example-ui-ts/src/styles/index.css rename to spacetime-submodule-shared-ts/src/styles/index.css diff --git a/spacetime-example-ui-ts/tsconfig.json b/spacetime-submodule-shared-ts/tsconfig.json similarity index 100% rename from spacetime-example-ui-ts/tsconfig.json rename to spacetime-submodule-shared-ts/tsconfig.json