Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -5,8 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node ../../scripts/admin-web-build.mjs build",
|
||||
"typecheck": "node ../../scripts/admin-web-build.mjs typecheck",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
2. 管理数据、业务规则、权限校验和写操作继续统一走 `server-rs/crates/api-server`。
|
||||
3. v1 只接管已有管理能力:管理员登录、当前管理员信息、服务/数据库概览、受控 API 调试、兑换码管理、注册邀请码管理。
|
||||
4. 保持管理端清爽、可扫读、移动端可用,不在界面堆大段规则说明。
|
||||
5. 发布包内由 Web 网关把独立后台前端挂到同域 `/admin/`;Rust `api-server` 自身仍不恢复旧的 `GET /admin` 内嵌页面。
|
||||
|
||||
## 2. 用户与使用场景
|
||||
|
||||
@@ -66,7 +67,7 @@
|
||||
3. 不新增 SpacetimeDB 表结构。
|
||||
4. 不实现完整用户管理、作品审核、资产审核、充值订单后台。
|
||||
5. 不实现多角色权限体系、管理员 refresh session、多端会话管理。
|
||||
6. 不保留 `GET /admin` 同源内嵌页面作为正式后台入口。
|
||||
6. 不保留 Rust `api-server` 的 `GET /admin` 同源内嵌页面作为正式后台入口;部署态 `/admin/` 只允许由独立后台前端静态产物承接。
|
||||
|
||||
## 4. 页面与交互要求
|
||||
|
||||
@@ -144,7 +145,7 @@ API 调试页是受控接口调试台,不是通用代理工具:
|
||||
5. API 调试页可通过后端调试接口访问 `/healthz`。
|
||||
6. 兑换码管理页可创建/更新、停用兑换码,并展示返回记录。
|
||||
7. 邀请码管理页可创建/更新注册邀请码,并展示返回记录。
|
||||
8. `GET /admin` 保持 404,不恢复旧内嵌页面。
|
||||
8. 直连 Rust `api-server` 时 `GET /admin` 保持 404,不恢复旧内嵌页面;通过发布包 Web 网关访问 `/admin/` 时返回独立后台前端。
|
||||
9. `npm run check:encoding` 通过。
|
||||
|
||||
## 7. 首版任务拆解
|
||||
|
||||
@@ -154,47 +154,11 @@ claims 设计:
|
||||
2. 当前 `SpacetimeDB server/database` 配置。
|
||||
3. `SpacetimeDB` 数据库基础信息。
|
||||
4. 当前 schema 表清单。
|
||||
5. 首批关键表的行数统计。
|
||||
5. schema 表清单对应的逐表行数统计。
|
||||
|
||||
首批关键表固定覆盖:
|
||||
表统计必须以 SpacetimeDB schema 返回的表名为唯一来源,`schemaTableNames` 的数量必须与 `tableStats` 的行数一致。后台服务只对 schema 中符合安全 SQL 标识符格式的表名发起 `SELECT COUNT(*)`,不提供任意 SQL 输入能力。
|
||||
|
||||
1. `runtime_setting`
|
||||
2. `runtime_snapshot`
|
||||
3. `user_browse_history`
|
||||
4. `profile_dashboard_state`
|
||||
5. `profile_wallet_ledger`
|
||||
6. `profile_played_world`
|
||||
7. `profile_save_archive`
|
||||
8. `story_session`
|
||||
9. `story_event`
|
||||
10. `battle_state`
|
||||
11. `inventory_slot`
|
||||
12. `quest_record`
|
||||
13. `quest_log`
|
||||
14. `treasure_record`
|
||||
15. `npc_state`
|
||||
16. `custom_world_profile`
|
||||
17. `custom_world_gallery_entry`
|
||||
18. `custom_world_agent_session`
|
||||
19. `custom_world_agent_message`
|
||||
20. `custom_world_agent_operation`
|
||||
21. `custom_world_draft_card`
|
||||
22. `big_fish_creation_session`
|
||||
23. `big_fish_agent_message`
|
||||
24. `big_fish_asset_slot`
|
||||
25. `big_fish_runtime_run`
|
||||
26. `puzzle_work_profile`
|
||||
27. `puzzle_agent_session`
|
||||
28. `puzzle_agent_message`
|
||||
29. `puzzle_runtime_run`
|
||||
30. `ai_task`
|
||||
31. `ai_task_stage`
|
||||
32. `ai_text_chunk`
|
||||
33. `ai_result_reference`
|
||||
34. `asset_object`
|
||||
35. `asset_entity_binding`
|
||||
|
||||
返回中的计数失败项必须带错误信息,不能静默吞掉。
|
||||
返回中的计数失败项必须带错误信息,不能静默吞掉。SpacetimeDB private 表或当前身份不可见的表可能在 `/sql` 下返回 `no such table` / `marked private`,这类项统一展示为“不可统计(private 或当前身份不可见)”,不作为整页读取失败处理。
|
||||
|
||||
## 8. API 调试设计
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
对应 PRD:[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md)
|
||||
|
||||
落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程,包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。
|
||||
落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程,包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。`2026-05-01` 起,根构建与 Ubuntu 发布包会同步构建后台前端,并在发布包 Web 网关中以同域 `/admin/` 暴露。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染;所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server`。
|
||||
|
||||
本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,旧 `GET /admin` 不再挂载。后续后台入口由独立前端工程部署产物承接。
|
||||
本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,Rust `api-server` 直连时旧 `GET /admin` 不再挂载。部署态后台入口由发布包内 `web-server.mjs` 承接:`/admin/` 返回独立前端静态产物,`/admin/api/*` 继续反代到 `api-server`。
|
||||
|
||||
## 2. 工程结构
|
||||
|
||||
@@ -253,9 +253,13 @@ export interface ProfileInviteCodeAdminResponse {
|
||||
|
||||
后端读取 SpacetimeDB schema 时必须请求 `/v1/database/{database}/schema?version=9`。SpacetimeDB 2.x schema HTTP API 缺少 `version` query 会返回 `400 missing field version`,后台页面只能展示读取异常,不能拿到真实表名。
|
||||
|
||||
`schemaTableNames` 与 `tableStats` 必须采用同一份 schema 表清单生成,不能再用硬编码关键表白名单补齐统计项。后台右上角显示的表数量必须等于统计表格实际行数;schema 读取失败时两者均为空,并通过 `fetchErrors` 暴露读取失败原因。
|
||||
|
||||
后端读取表行数时必须按 SpacetimeDB 2.x `/sql` 响应解析:接口返回 statement result 数组,单条结果内的 `schema.elements` 描述列名,`rows` 是按列顺序排列的数组行,例如 `rows: [[0]]`。后台服务不能再假设响应是 `{ rows: [{ row_count: 0 }] }` 的对象行形状;为了兼容小版本差异,可保留对象行兜底解析。
|
||||
|
||||
`tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。
|
||||
`tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。SpacetimeDB private 表或当前身份不可见的表在 `/sql` 下可能返回 `no such table` / `marked private`,后台服务必须将这类错误归一为“不可统计(private 或当前身份不可见)”,避免把预期的访问边界展示成原始 HTTP 400 故障。
|
||||
|
||||
线上如果大量表都显示“不可统计(private 或当前身份不可见)”,优先检查 `api-server` 启动环境中的 `GENARRATIVE_SPACETIME_TOKEN` / `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN` 是否存在且属于目标库 owner。Jenkins 覆盖发布包时必须保留部署目录已有运行 token;只带迁移 token 不能让后台概览读取 private 表。
|
||||
|
||||
### 4.6 API 调试 contract
|
||||
|
||||
@@ -384,12 +388,14 @@ export interface ProfileInviteCodeAdminResponse {
|
||||
|
||||
### 7.2 构建部署
|
||||
|
||||
首版构建产物由独立后台工程输出到 `apps/admin-web/dist`。部署可以选择:
|
||||
当前发布形态固定为同域 `/admin/`:
|
||||
|
||||
1. 独立静态站点域名,例如 `https://admin.example.com`。
|
||||
2. 与主站同域不同路径,由网关把后台静态资源和 `/admin/api/*` 分别路由到正确目标。
|
||||
1. 本地单独执行 `npm run admin-web:build` 时,后台构建产物默认输出到 `apps/admin-web/dist`。
|
||||
2. 根工程执行 `npm run build` 时,会先构建主前端,再构建后台前端;任一构建失败或输出 warning 都会让构建门禁失败。
|
||||
3. Ubuntu 发布包执行 `npm run deploy:rust:remote` 时,后台前端以 Vite `--base /admin/` 构建到发布包 `web/admin/`。
|
||||
4. 发布包 `web-server.mjs` 对 `/admin` 返回 301 到 `/admin/`,对 `/admin/` 与 `/admin/*` 提供后台 SPA fallback,对 `/admin/api/*` 优先反代到 `api-server`。
|
||||
|
||||
无论哪种方式,`server-rs` 仍然是唯一管理 API 后端。
|
||||
该形态不新增后台静态端口和后台专用后端。`server-rs` 仍然是唯一管理 API 后端,后台前端不直连 SpacetimeDB。
|
||||
|
||||
### 7.3 后台工程脚本
|
||||
|
||||
@@ -399,8 +405,8 @@ export interface ProfileInviteCodeAdminResponse {
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node ../../scripts/admin-web-build.mjs build",
|
||||
"typecheck": "node ../../scripts/admin-web-build.mjs typecheck",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
}
|
||||
}
|
||||
@@ -408,6 +414,8 @@ export interface ProfileInviteCodeAdminResponse {
|
||||
|
||||
如果后续接入根 npm workspace,再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。
|
||||
|
||||
当前工程没有启用 npm workspace,因此后台构建脚本必须从仓库根目录调用 root toolchain。`scripts/admin-web-build.mjs` 统一执行 `tsc --noEmit -p apps/admin-web/tsconfig.json` 与 Vite 构建,避免 `npm --prefix apps/admin-web` 在子目录找不到 `tsc`。
|
||||
|
||||
当前根工程同步提供以下转发脚本:
|
||||
|
||||
1. `npm run admin-web:dev`
|
||||
|
||||
@@ -138,11 +138,11 @@ npm run deploy:rust:remote
|
||||
|
||||
1. 在仓库根目录创建 `build/`。
|
||||
2. 在 `build/` 下创建当前时间命名的目标目录,例如 `build/20260422-153000/`。
|
||||
3. 使用 Vite 构建前端 release 到目标目录的 `web/`。
|
||||
3. 使用 Vite 构建主前端 release 到目标目录的 `web/`,并构建后台管理前端 release 到 `web/admin/`;后台构建固定使用 `/admin/` 作为 Vite base。
|
||||
4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。
|
||||
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
|
||||
6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF,并把 `GENARRATIVE_SPACETIME_DATABASE` 覆盖为本次 `--database` 参数,避免 Jenkins 工作区里残留的旧 `.env.local` 覆盖发布包目标库。
|
||||
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。
|
||||
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 与 `web/admin/`;其中 `/admin` 跳转到 `/admin/`,`/admin/` 提供后台 SPA,`/admin/api/*`、`/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。
|
||||
8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-host`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,其中 Web 默认只监听 `127.0.0.1`;并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir,不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `server ping` 输出包含 `Server is online:`;普通启动先无清库发布,若 publish 输出可判定为 schema 冲突,则自动导出旧库、清库发布新 wasm、导入回灌;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,代表人工确认清库,不触发自动回灌。
|
||||
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
|
||||
|
||||
@@ -158,7 +158,9 @@ build/<timestamp>/
|
||||
├─ .env.local
|
||||
├─ web/
|
||||
│ ├─ .env
|
||||
│ └─ .env.local
|
||||
│ ├─ .env.local
|
||||
│ └─ admin/
|
||||
│ └─ index.html
|
||||
├─ api-server
|
||||
├─ spacetime_module.wasm
|
||||
├─ migration-bootstrap-secret.txt
|
||||
@@ -179,6 +181,8 @@ npm run build:rust:ubuntu -- --database genarrative-dev --web-host 127.0.0.1 --w
|
||||
npm run build:rust:ubuntu -- --skip-upload
|
||||
```
|
||||
|
||||
`--skip-web-build` 会同时跳过主前端和后台管理前端构建,仅用于调试已有发布包内容;正式发布不应使用该参数。
|
||||
|
||||
目标服务器启动:
|
||||
|
||||
```bash
|
||||
@@ -187,7 +191,7 @@ cd build/<timestamp>
|
||||
./stop.sh
|
||||
```
|
||||
|
||||
如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/`、`api-server`、`spacetime_module.wasm`、`migration-bootstrap-secret.txt`、`scripts/`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md` 等发布产物;文件产物使用普通复制,`web/`、`scripts/` 等目录产物递归复制,不会删除部署目录中的 `.spacetimedb/`、`logs/`、`run/`、`deploy-state/`、`database-migrations/` 这类运行态目录。
|
||||
如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/`、`api-server`、`spacetime_module.wasm`、`migration-bootstrap-secret.txt`、`scripts/`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md` 等发布产物;后台管理前端位于 `web/admin/`,随 `web/` 一并覆盖。文件产物使用普通复制,`web/`、`scripts/` 等目录产物递归复制,不会删除部署目录中的 `.spacetimedb/`、`logs/`、`run/`、`deploy-state/`、`database-migrations/` 这类运行态目录。Jenkins 覆盖 `.env.local` 时会保留目标部署目录已有的 `GENARRATIVE_SPACETIME_TOKEN` / `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN`,避免后台表统计在部署后失去读取 private 表所需的 owner 身份。
|
||||
|
||||
安全边界:
|
||||
|
||||
@@ -208,7 +212,7 @@ cd build/<timestamp>
|
||||
1. Ubuntu x86_64。
|
||||
2. 已安装 `node`,用于运行发布包内的 `web-server.mjs`。
|
||||
3. 已安装 `spacetime` CLI,`start.sh` 会启动本地 SpacetimeDB 并发布 wasm。
|
||||
4. 业务密钥通过目标服务器环境变量或发布包同目录 `.env.local` 提供。
|
||||
4. 业务密钥通过目标服务器环境变量或发布包同目录 `.env.local` 提供;后台概览如果需要统计 private 表,`GENARRATIVE_SPACETIME_TOKEN` 必须是目标库 owner 或具备等效读取权限的 token。
|
||||
|
||||
## 4. 与 M7 的关系
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
||||
"dev:web": "node scripts/dev-web-rust.mjs",
|
||||
"admin-web:dev": "npm --prefix apps/admin-web run dev --",
|
||||
"admin-web:build": "npm --prefix apps/admin-web run build --",
|
||||
"admin-web:typecheck": "npm --prefix apps/admin-web run typecheck --",
|
||||
"admin-web:build": "node scripts/admin-web-build.mjs build",
|
||||
"admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck",
|
||||
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
|
||||
"spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh",
|
||||
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
|
||||
|
||||
73
scripts/admin-web-build.mjs
Normal file
73
scripts/admin-web-build.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {dirname, resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, '..');
|
||||
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
|
||||
const adminTsconfigPath = resolve(adminWebDir, 'tsconfig.json');
|
||||
const adminViteConfigPath = resolve(adminWebDir, 'vite.config.ts');
|
||||
const tscBinPath = resolve(repoRoot, 'node_modules/typescript/bin/tsc');
|
||||
const viteCliPath = resolve(scriptDir, 'vite-cli.mjs');
|
||||
|
||||
const command = process.argv[2] ?? 'build';
|
||||
const extraArgs = process.argv.slice(3);
|
||||
|
||||
function usage() {
|
||||
console.error('用法: node scripts/admin-web-build.mjs <typecheck|build> [vite-build-args...]');
|
||||
}
|
||||
|
||||
function runNodeScript(label, args) {
|
||||
console.log(`[admin-web] ${label}`);
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[admin-web] ${label} failed to start: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
console.error(`[admin-web] ${label} was terminated by signal ${result.signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function runTypecheck() {
|
||||
runNodeScript('typecheck', [
|
||||
tscBinPath,
|
||||
'--noEmit',
|
||||
'-p',
|
||||
adminTsconfigPath,
|
||||
]);
|
||||
}
|
||||
|
||||
if (command === 'typecheck') {
|
||||
runTypecheck();
|
||||
} else if (command === 'build') {
|
||||
runTypecheck();
|
||||
runNodeScript('vite build', [
|
||||
viteCliPath,
|
||||
'build',
|
||||
'--config',
|
||||
adminViteConfigPath,
|
||||
...extraArgs,
|
||||
]);
|
||||
} else {
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -2,39 +2,60 @@ import {spawnSync} from 'node:child_process';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
|
||||
const args = [viteCliPath, 'build', ...process.argv.slice(2)];
|
||||
const adminWebBuildPath = fileURLToPath(new URL('./admin-web-build.mjs', import.meta.url));
|
||||
const forwardedArgs = process.argv.slice(2);
|
||||
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const warningPattern = /\bwarn(?:ing)?\b/i;
|
||||
const ignoredWarningPatterns = [
|
||||
/ExperimentalWarning/u,
|
||||
const results = [
|
||||
runBuildStep('web', [viteCliPath, 'build', ...forwardedArgs]),
|
||||
runBuildStep('admin-web', [adminWebBuildPath, 'build']),
|
||||
];
|
||||
|
||||
const warningLines = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
|
||||
.split(/\r?\n/u)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.filter(line => warningPattern.test(line))
|
||||
.filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line)));
|
||||
const failedResult = results.find(result => result.error || result.signal || (result.status ?? 0) !== 0);
|
||||
if (failedResult) {
|
||||
if (failedResult.error) {
|
||||
console.error(`Build gate failed to start a build step: ${failedResult.error.message}`);
|
||||
} else if (failedResult.signal) {
|
||||
console.error(`Build gate step was terminated by signal ${failedResult.signal}`);
|
||||
}
|
||||
process.exit(failedResult.status ?? 1);
|
||||
}
|
||||
|
||||
const warningLines = results.flatMap((result) => collectWarningLines(result));
|
||||
|
||||
if (warningLines.length > 0) {
|
||||
console.error('Build gate failed because warnings were emitted:');
|
||||
[...new Set(warningLines)].forEach(line => console.error(`- ${line}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function runBuildStep(label, args) {
|
||||
console.log(`[build-gate] ${label}`);
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectWarningLines(result) {
|
||||
const warningPattern = /\bwarn(?:ing)?\b/i;
|
||||
const ignoredWarningPatterns = [
|
||||
/ExperimentalWarning/u,
|
||||
];
|
||||
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`
|
||||
.split(/\r?\n/u)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.filter(line => warningPattern.test(line))
|
||||
.filter(line => !ignoredWarningPatterns.some(pattern => pattern.test(line)));
|
||||
}
|
||||
|
||||
@@ -310,6 +310,7 @@ fi
|
||||
|
||||
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
|
||||
WEB_DIR="${TARGET_DIR}/web"
|
||||
ADMIN_WEB_DIR="${WEB_DIR}/admin"
|
||||
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
|
||||
WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
|
||||
|
||||
@@ -364,6 +365,12 @@ if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||
cd "${REPO_ROOT}"
|
||||
node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir
|
||||
)
|
||||
|
||||
echo "[deploy:rust] 构建后台 Vite release -> ${ADMIN_WEB_DIR}"
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
MSYS2_ARG_CONV_EXCL="--base=" node scripts/admin-web-build.mjs build --base=/admin/ --outDir "${ADMIN_WEB_DIR}" --emptyOutDir
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then
|
||||
@@ -421,11 +428,14 @@ import {fileURLToPath} from 'node:url';
|
||||
|
||||
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.join(releaseDir, 'web');
|
||||
const adminWebRoot = path.join(webRoot, 'admin');
|
||||
const webHost = process.env.GENARRATIVE_WEB_HOST || '127.0.0.1';
|
||||
const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
|
||||
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
|
||||
const indexPath = path.join(webRoot, 'index.html');
|
||||
const adminIndexPath = path.join(adminWebRoot, 'index.html');
|
||||
const proxyPrefixes = [
|
||||
'/admin/api',
|
||||
'/api/',
|
||||
'/api',
|
||||
'/generated-character-drafts',
|
||||
@@ -466,11 +476,11 @@ function sendFile(response, filePath) {
|
||||
.pipe(response);
|
||||
}
|
||||
|
||||
function serveStatic(request, response, pathname) {
|
||||
function serveStaticFromRoot(response, pathname, rootDir, fallbackIndexPath) {
|
||||
const decodedPath = decodeURIComponent(pathname);
|
||||
const relativePath = decodedPath === '/' ? '/index.html' : decodedPath;
|
||||
const filePath = path.normalize(path.join(webRoot, relativePath));
|
||||
const safeRelativePath = path.relative(webRoot, filePath);
|
||||
const filePath = path.normalize(path.join(rootDir, relativePath));
|
||||
const safeRelativePath = path.relative(rootDir, filePath);
|
||||
|
||||
if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) {
|
||||
response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'});
|
||||
@@ -480,12 +490,21 @@ function serveStatic(request, response, pathname) {
|
||||
|
||||
const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||
? filePath
|
||||
: indexPath;
|
||||
: fallbackIndexPath;
|
||||
|
||||
response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)});
|
||||
sendFile(response, resolvedFilePath);
|
||||
}
|
||||
|
||||
function serveStatic(request, response, pathname) {
|
||||
serveStaticFromRoot(response, pathname, webRoot, indexPath);
|
||||
}
|
||||
|
||||
function serveAdminStatic(response, pathname) {
|
||||
const adminPath = pathname === '/admin/' ? '/' : pathname.replace(/^\/admin/u, '');
|
||||
serveStaticFromRoot(response, adminPath, adminWebRoot, adminIndexPath);
|
||||
}
|
||||
|
||||
function proxyToApi(request, response) {
|
||||
const targetUrl = new URL(request.url || '/', apiTarget);
|
||||
const proxyRequest = http.request(
|
||||
@@ -522,6 +541,17 @@ const server = http.createServer((request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin') {
|
||||
response.writeHead(301, {location: '/admin/'});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin/' || url.pathname.startsWith('/admin/')) {
|
||||
serveAdminStatic(response, url.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(request, response, url.pathname);
|
||||
});
|
||||
|
||||
@@ -1189,7 +1219,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
|
||||
## 内容
|
||||
|
||||
- \`.env\` / \`.env.local\`:从仓库根目录复制的环境文件,同时各保留一份到 \`web/\`
|
||||
- \`web/\`:Vite release 静态资源
|
||||
- \`web/\`:主前端 Vite release 静态资源,\`web/admin/\` 为后台管理前端静态资源
|
||||
- \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件
|
||||
- \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块
|
||||
- \`migration-bootstrap-secret.txt\`:本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除
|
||||
@@ -1211,6 +1241,11 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
|
||||
|
||||
默认启动会先尝试无清库发布;如果 SpacetimeDB 返回 schema 冲突,\`start.sh\` 会把旧库导出到 \`database-migrations/<database>/\`,随后清库发布新 wasm,并用 \`--replace-existing\` 导入回灌。
|
||||
|
||||
## 入口
|
||||
|
||||
- 主站:\`http://<web-host>:<web-port>/\`
|
||||
- 后台:\`http://<web-host>:<web-port>/admin/\`
|
||||
|
||||
## 环境变量
|
||||
|
||||
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
|
||||
@@ -1220,12 +1255,12 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
|
||||
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
|
||||
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
|
||||
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`
|
||||
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\`
|
||||
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\` / \`GENARRATIVE_SPACETIME_TOKEN\`
|
||||
- \`GENARRATIVE_SPACETIME_ROOT_DIR\`:默认使用发布目录下的 \`.spacetimedb/\`,同时承载本地 SpacetimeDB 运行数据与 CLI 身份。
|
||||
- \`GENARRATIVE_SPACETIME_TIMEOUT_SECONDS\`:等待 SpacetimeDB 就绪的秒数,默认 \`60\`。
|
||||
- \`GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT\`:默认 \`true\`,普通发布遇到 schema 冲突时自动导出、清库发布、导入回灌;设为 \`false\` 时保留原始发布失败。
|
||||
- \`GENARRATIVE_SPACETIME_MIGRATION_DIR\`:自动迁移 JSON 输出目录,默认 \`database-migrations/<database>/\`。
|
||||
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
|
||||
- OSS、LLM、短信、微信、SpacetimeDB owner token 等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理;后台表统计读取 private 表时需要 \`GENARRATIVE_SPACETIME_TOKEN\` 对目标库有 owner 权限。
|
||||
- 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥。
|
||||
EOF
|
||||
replace_placeholder_in_file "${TARGET_DIR}/README.md" "__GENARRATIVE_BUILD_NAME__" "${BUILD_NAME}"
|
||||
|
||||
@@ -10,10 +10,11 @@ usage() {
|
||||
说明:
|
||||
1. 如果部署目录已有旧版本且存在 stop.sh,则先执行旧版本 stop.sh。
|
||||
2. 仅删除并替换发布产物文件或目录,保留部署目录中的运行数据目录。
|
||||
3. 把指定发布目录中的白名单产物复制覆盖到部署目录。
|
||||
3. 把指定发布目录中的白名单产物复制覆盖到部署目录,后台前端随 web/admin/ 一并覆盖。
|
||||
4. 如指定 --clear-database,则以清库模式执行新版本 start.sh。
|
||||
5. 默认允许新版本 start.sh 在 schema 冲突时自动导出、清库发布、导入回灌。
|
||||
6. 最后执行新版本 start.sh。
|
||||
6. 覆盖 .env.local 时保留目标机已有 SpacetimeDB 运行 token,供 api-server 后台概览读取 private 表统计。
|
||||
7. 最后执行新版本 start.sh。
|
||||
|
||||
参数:
|
||||
--source-dir <path> 必填,待部署的发布目录,例如 build/123
|
||||
@@ -179,6 +180,8 @@ MIGRATION_EXPORT_TOKEN=""
|
||||
MIGRATION_IMPORT_TOKEN=""
|
||||
PRESERVED_MIGRATION_EXPORT_TOKEN=""
|
||||
PRESERVED_MIGRATION_IMPORT_TOKEN=""
|
||||
PRESERVED_SPACETIME_TOKEN=""
|
||||
PRESERVED_SPACETIME_MAINCLOUD_TOKEN=""
|
||||
DEPLOY_ITEMS=(
|
||||
".env"
|
||||
".env.local"
|
||||
@@ -364,6 +367,8 @@ fi
|
||||
normalize_release_env_files "${SOURCE_DIR}"
|
||||
PRESERVED_MIGRATION_EXPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
PRESERVED_MIGRATION_IMPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
PRESERVED_SPACETIME_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
PRESERVED_SPACETIME_MAINCLOUD_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
|
||||
if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then
|
||||
echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}"
|
||||
@@ -424,6 +429,16 @@ elif [[ -n "${PRESERVED_MIGRATION_IMPORT_TOKEN}" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${PRESERVED_MIGRATION_IMPORT_TOKEN}"
|
||||
fi
|
||||
if [[ -n "${PRESERVED_SPACETIME_TOKEN}" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_TOKEN" "${PRESERVED_SPACETIME_TOKEN}"
|
||||
fi
|
||||
if [[ -n "${PRESERVED_SPACETIME_MAINCLOUD_TOKEN}" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]] \
|
||||
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
|
||||
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${PRESERVED_SPACETIME_MAINCLOUD_TOKEN}"
|
||||
fi
|
||||
|
||||
DEPLOY_DATABASE="$(read_env_value "GENARRATIVE_SPACETIME_DATABASE" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
|
||||
if [[ -z "${DEPLOY_DATABASE}" ]]; then
|
||||
|
||||
@@ -39,43 +39,6 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[
|
||||
"transfer-encoding",
|
||||
"expect",
|
||||
];
|
||||
// 数据库概览首版只统计受控白名单表,禁止后台页面直接输入任意 SQL。
|
||||
const DATABASE_OVERVIEW_TABLES: &[&str] = &[
|
||||
"runtime_setting",
|
||||
"runtime_snapshot",
|
||||
"user_browse_history",
|
||||
"profile_dashboard_state",
|
||||
"profile_wallet_ledger",
|
||||
"profile_played_world",
|
||||
"profile_save_archive",
|
||||
"story_session",
|
||||
"story_event",
|
||||
"battle_state",
|
||||
"inventory_slot",
|
||||
"quest_record",
|
||||
"quest_log",
|
||||
"treasure_record",
|
||||
"npc_state",
|
||||
"custom_world_profile",
|
||||
"custom_world_gallery_entry",
|
||||
"custom_world_agent_session",
|
||||
"custom_world_agent_message",
|
||||
"custom_world_agent_operation",
|
||||
"custom_world_draft_card",
|
||||
"big_fish_creation_session",
|
||||
"big_fish_agent_message",
|
||||
"big_fish_asset_slot",
|
||||
"puzzle_work_profile",
|
||||
"puzzle_agent_session",
|
||||
"puzzle_agent_message",
|
||||
"puzzle_runtime_run",
|
||||
"ai_task",
|
||||
"ai_task_stage",
|
||||
"ai_text_chunk",
|
||||
"ai_result_reference",
|
||||
"asset_object",
|
||||
"asset_entity_binding",
|
||||
];
|
||||
// SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。
|
||||
// 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。
|
||||
const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9";
|
||||
@@ -283,7 +246,7 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let mut schema_table_names = schema
|
||||
let schema_table_names = schema
|
||||
.as_ref()
|
||||
.and_then(|value| value.tables.as_ref())
|
||||
.map(|tables| {
|
||||
@@ -300,31 +263,33 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut table_stats = Vec::new();
|
||||
for table_name in DATABASE_OVERVIEW_TABLES {
|
||||
for table_name in &schema_table_names {
|
||||
if !is_safe_spacetime_table_name(table_name) {
|
||||
table_stats.push(AdminDatabaseTableStatPayload {
|
||||
table_name: table_name.clone(),
|
||||
row_count: None,
|
||||
error_message: Some("表名不适合 SQL 统计".to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let sql = format!("SELECT COUNT(*) AS row_count FROM {table_name}");
|
||||
match fetch_spacetime_sql_count(&client, server_root, database, token, &sql).await {
|
||||
Ok(row_count) => table_stats.push(AdminDatabaseTableStatPayload {
|
||||
table_name: (*table_name).to_string(),
|
||||
table_name: table_name.clone(),
|
||||
row_count: Some(row_count),
|
||||
error_message: None,
|
||||
}),
|
||||
Err(error) => {
|
||||
table_stats.push(AdminDatabaseTableStatPayload {
|
||||
table_name: (*table_name).to_string(),
|
||||
table_name: table_name.clone(),
|
||||
row_count: None,
|
||||
error_message: Some(error),
|
||||
error_message: Some(normalize_table_count_error(&error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for table_name in DATABASE_OVERVIEW_TABLES {
|
||||
if !schema_table_names.iter().any(|name| name == table_name) {
|
||||
schema_table_names.push((*table_name).to_string());
|
||||
}
|
||||
}
|
||||
schema_table_names.sort();
|
||||
|
||||
AdminDatabaseOverviewPayload {
|
||||
database_identity: database_info
|
||||
.as_ref()
|
||||
@@ -345,6 +310,27 @@ fn build_spacetime_schema_url(server_root: &str, database: &str) -> String {
|
||||
format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}")
|
||||
}
|
||||
|
||||
// 表名来自 schema,但进入 SQL 前仍做最小标识符校验,避免未来 schema 来源变化时扩大风险面。
|
||||
fn is_safe_spacetime_table_name(table_name: &str) -> bool {
|
||||
let mut chars = table_name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if !(first == '_' || first.is_ascii_alphabetic()) {
|
||||
return false;
|
||||
}
|
||||
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
// private 表在 SpacetimeDB SQL 下会表现为不可见,后台只展示可理解状态,不暴露整段 HTTP 噪音。
|
||||
fn normalize_table_count_error(error: &str) -> String {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
if normalized.contains("marked private") || normalized.contains("no such table") {
|
||||
return "不可统计(private 或当前身份不可见)".to_string();
|
||||
}
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
async fn fetch_spacetime_json<T>(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
@@ -662,7 +648,8 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_body_preview, build_debug_base_url, build_spacetime_schema_url, normalize_debug_path,
|
||||
build_body_preview, build_debug_base_url, build_spacetime_schema_url,
|
||||
is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error,
|
||||
parse_spacetime_sql_count_response, trim_preview,
|
||||
};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
@@ -722,6 +709,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_spacetime_table_name_accepts_schema_identifiers() {
|
||||
assert!(is_safe_spacetime_table_name("runtime_setting"));
|
||||
assert!(is_safe_spacetime_table_name("_private_table"));
|
||||
assert!(is_safe_spacetime_table_name("AiTaskStage2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_spacetime_table_name_rejects_sql_fragments() {
|
||||
assert!(!is_safe_spacetime_table_name(""));
|
||||
assert!(!is_safe_spacetime_table_name("bad-name"));
|
||||
assert!(!is_safe_spacetime_table_name("1bad"));
|
||||
assert!(!is_safe_spacetime_table_name("runtime_setting;DROP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_table_count_error_hides_private_table_http_noise() {
|
||||
let error = "HTTP 400:no such table: `runtime_setting`. If the table exists, it may be marked private.";
|
||||
|
||||
assert_eq!(
|
||||
normalize_table_count_error(error),
|
||||
"不可统计(private 或当前身份不可见)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_table_count_error_keeps_other_errors() {
|
||||
let error = "SQL 请求失败:connection refused";
|
||||
|
||||
assert_eq!(normalize_table_count_error(error), error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_spacetime_sql_count_response_accepts_statement_array_rows() {
|
||||
let payload = json!([
|
||||
|
||||
Reference in New Issue
Block a user