@oj-bin/oj-x86_64-pc-windows-msvc 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/devkit/README.md CHANGED
@@ -13,12 +13,13 @@
13
13
  ## 安装(业务项目)
14
14
 
15
15
  ```sh
16
- # agent 用:拷入项目的 Claude Code skill 目录
16
+ # npm 安装(v0.1.13 起):postinstall 把对应平台的 oj / plugins / devkit/ 落盘 <项目根>/bin/
17
+ npm i @oj-bin/oj
17
18
  mkdir -p .claude/skills/oj-api-dev
18
- cp devkit/SKILL.md devkit/api-manual.md .claude/skills/oj-api-dev/
19
+ cp bin/devkit/SKILL.md bin/devkit/api-manual.md .claude/skills/oj-api-dev/ # agent 用
20
+ cp bin/devkit/global.d.ts . # 类型提示
19
21
 
20
- # 类型提示:拷进项目源码根(与 src/ 平级即可)
21
- cp devkit/global.d.ts .
22
+ # 或从发行包解包后取 devkit/,路径同上
22
23
  ```
23
24
 
24
25
  安装后 agent 里说"用 oj-api-dev 开发 xxx 模块",或 Claude Code 里 `/oj-api-dev` 触发。
package/devkit/SKILL.md CHANGED
@@ -23,7 +23,7 @@ description: 在 oj (only-js) 框架业务项目中开发 API 模块时使用—
23
23
  ## 红线(不可违反)
24
24
 
25
25
  - **SQL 注入**:动态标识符(表名/列名)**只**来自 `db.table()` 查询构造器(白名单),
26
- 绝不来自 JS 字符串拼接;值**只**通过绑定参数(`db.query("... where id = ?", [id])`)。
26
+ 绝不来自 JS 字符串拼接;值**只**通过绑定参数(`db.query("... where id = ?", [id])` 或构造器的 `value`)。
27
27
  - **方法名**:DELETE 的方法名是 `del`,不是 `delete`(`get/post/put/del/patch/head/options`)。
28
28
  - **信封**:业务响应只经 `json.ok(data)` / `json.fail(code, msg, data?)` 写回,
29
29
  HTTP 状态 = `code`(0→200);标准协议端点(对外契约 JSON)可用 `json.raw(data)`
@@ -73,6 +73,10 @@ description: 在 oj (only-js) 框架业务项目中开发 API 模块时使用—
73
73
  | WS 路由 404(文件明明在) | 文件名必须小写 `ws.ts`/`ws.js`——`WS.ts` 无效(v0.1.5 约定) |
74
74
  | (v0.1.9 已消除)旧帧循环的 const 重复声明 | 新契约为生命周期钩子:模块每 Worker 预载一次——无需处理;可变跨帧状态放 `sess.state`(模块作用域只是只读缓存),见 api-manual §ws.ts |
75
75
  | WS 帧内 `bus.publish` 自己也收到 | 自回声语义:fan-out 不排除本连接——按字段客户端过滤或发布到别的 topic |
76
+ | 查询被拦截 / 启动报 `tenant_id` 相关错(v0.1.15 sql_guard) | `sql_guard: "deny"` 拦截租户条件不匹配的查询(`"warn"` 只告警):跨租户操作(对账/报表)走 `await db.asSystem()`(请求级 + 审计日志);共享表须 schema.yaml `tenant: false` **且** config `tenant.shared_allow` 列出,双声明才豁免 |
77
+ | 裸 SQL 被 deny 拦「遗漏 tenant_id」 | `db.query`/`db.exec` 的字面检查(best-effort)要求 SQL 显式带租户条件——优先改走 `db.table()` 构造器(自动注入),系统身份走 `db.asSystem()` |
78
+ | WS 二进制帧 `http.body` 是 null | 设计如此(不做 UTF-8 有损转换)——取字节用 `await http.bodyBytes()`(v0.1.16) |
79
+ | 回显二进制协议帧型变成 Text | `ws.send` 帧型由参数类型决定:Uint8Array → Binary(0x2),string → Text(0x1)——别把字节 decode 成 string 再发 |
76
80
 
77
81
  ## 手册
78
82
 
@@ -103,6 +103,8 @@ release(跑预构建 `.js`,不转译);否则 dev(服务 `.ts` 源码
103
103
  **终端默认静默(`server.console_log` 缺省 false)**——启动时会打一行日志路径的提示,
104
104
  随后一切输出只落盘;需要终端同时输出时加 `--console-log` 或配 `server.console_log: true`。
105
105
  **例外**:启动失败的最终退出原因总是直写终端(console 关闭也不例外),便于立即调整。
106
+ **后台运行**:加 `--daemon` 脱离终端(unix setsid / windows DETACHED_PROCESS),stdio 重定向
107
+ 空设备,父进程打印子 pid 后退出;日志照常落 `server.logs_dir`,停机用 `kill <pid>`。
106
108
  **准入门(三态,无静默默认)**:`--api-path` 与静态站点(`server.app_path` / `--app-path`)
107
109
  至少显式指定其一,否则退出;两者皆指定 → 都必须存在;仅指定其一 → 只启用对应功能。
108
110
 
@@ -292,7 +294,8 @@ runtime 会泵 event loop 直到所有 Promise 落定后再写回响应(摘自
292
294
  ```ts
293
295
  function get(): void {
294
296
  const id = Number(http.param("id", 0));
295
- db.query("select id, name, role from account where id = ?", [id])
297
+ db.table("account").select(["id", "name", "role"]).where({ field: "id", op: "eq", value: id })
298
+ .all()
296
299
  .then((r) => json.ok(r))
297
300
  .catch((e) => json.fail(500, String(e)));
298
301
  }
@@ -323,6 +326,19 @@ const b = http.body as { name?: string };
323
326
  if (!b.name) { json.fail(400, "name required"); return; }
324
327
  ```
325
328
 
329
+ **二进制帧(v0.1.16,仅 WS `message` 钩子)**:客户端发 Binary 帧时 `http.body` 为
330
+ `null`(不做 UTF-8 有损转换),原始字节一律走 `http.bodyBytes()`(文本帧同样可用,
331
+ 返回原始帧字节):
332
+
333
+ ```ts
334
+ // ws.ts:二进制回显(可运行案例 sample/src/echo-bin/ws.ts)
335
+ export default {
336
+ async message() {
337
+ ws.send(await http.bodyBytes()); // Uint8Array 原字节 → Binary 帧(0x2)
338
+ },
339
+ };
340
+ ```
341
+
326
342
  ### 路由:目录镜像与 `.route` 参数路由
327
343
 
328
344
  URL = `{base}/{module}/{...path}/{feature}/` → `<dir>/{module}/{...path}/{feature}/api.ts|js`。
@@ -335,7 +351,8 @@ handler 函数挂 `.route` 属性即**替换**目录镜像路由,支持 matchi
335
351
  function detail(): void {
336
352
  const id = Number(http.param("id", 0));
337
353
  if (!(id > 0)) { json.fail(400, "id required"); return; }
338
- db.query("select id, name, role from account where id = ?", [id])
354
+ db.table("account").select(["id", "name", "role"]).where({ field: "id", op: "eq", value: id })
355
+ .all()
339
356
  .then((r) => (r.length ? json.ok(r[0]) : json.fail(404, "no such account")))
340
357
  .catch((e) => json.fail(500, String(e)));
341
358
  }
@@ -547,7 +564,8 @@ json.header("X-Request-Id", "abc");
547
564
  | `http.method` | `string` | 请求方法(`GET`/`POST`/…) |
548
565
  | `http.query` | `Record<string, string>` | query 参数对象(form-urlencoded 解码:`+`→空格、`%XX`) |
549
566
  | `http.headers` | `Record<string, string>` | 请求头对象 |
550
- | `http.body` | `any` | 请求体(解析规则见第 4 章) |
567
+ | `http.body` | `any` | 请求体(解析规则见第 4 章);WS Binary 帧时为 `null` |
568
+ | `http.bodyBytes` | `bodyBytes(): Promise<Uint8Array>` | 原始请求体字节(WS 帧场景 v0.1.16 起;HTTP 大体亦可经此取字节) |
551
569
  | `http.params` | `Record<string, string>` | 路径参数对象(已 percent-decode;目录镜像路由下恒空) |
552
570
  | `http.param` | `param(name: string, def?: unknown): any` | **路径参数优先,query 兜底**,均缺失返回 `def` 原值 |
553
571
  | `http.tenantId` | `string \| null` | 租户 id(`tenant.enable` 时从租户头提取;未启用为 `null`) |
@@ -568,7 +586,8 @@ const page = http.param("page", 1); // 无路径参数 → query 兜底
568
586
  | `db.exec` | `exec(sql: string, params?: unknown[]): Promise<number>` | 参数化执行 → 受影响行数 |
569
587
  | `db.table` | `table(name: string): QueryBuilder` | 安全查询构造器(标识符白名单 + 参数化值) |
570
588
  | `db.tx` | `tx(fn: (tx: DBInstance) => unknown): Promise<unknown>` | 事务(语义见下) |
571
- | `DB(name)` | `(name: string) => DBInstance \| undefined` | 命名库实例;四方法与 `db` 同签名 |
589
+ | `db.asSystem` | `asSystem(): DBInstance` | 本请求以系统身份绕过租户防护(v0.1.15,仅 tenant.sql_guard 活跃时有意义;请求级生效 + 审计日志,业务 handler 禁用) |
590
+ | `DB(name)` | `(name: string) => DBInstance \| undefined` | 命名库实例;全部方法与 `db` 同签名 |
572
591
 
573
592
  **查询构造器**(流式、结构化;SQL 由服务端按库方言生成):
574
593
 
@@ -583,14 +602,109 @@ const rows = await db.table("account")
583
602
  .all(); // → Promise<Json[]>
584
603
  ```
585
604
 
586
- - `WhereCond`:`{ field: string; op?: string; value?: unknown; and?; or? }`。
587
- v0.2 服务端支持的操作符:`eq / ne / gt / gte / lt / lte / in(值须数组)/
588
- like / isnull`;未知操作符直接报错;`and`/`or` 嵌套字段 v0.2 未展开(多 where 即 AND)。
605
+ - `WhereCond`:`{ field: string; op?: string; value?: unknown }`。
606
+ 操作符:`eq / ne / gt / gte / lt / lte / in(值须数组)/ like / isnull`;未知操作符直接报错。
589
607
  - `OrderByItem`:`{ field: string; dir?: "asc" | "desc" | null }`。
590
- - 表名/列名经 SchemaRegistry 白名单校验(启动内省所得)——未知表/列报错;
591
- 排序列另有可排序白名单。`limit` 缺省 100、硬上限 1000。
608
+ - **红线**:标识符(表/列/别名)只来自 SchemaRegistry 白名单(启动内省所得),值一律经绑定参数——
609
+ SQL 注入面为零;未知表/列报错,排序列另有可排序白名单。`limit` 缺省 100、硬上限 1000。
592
610
  - 构造器自动按库方言出 SQL(sqlite/mysql/postgres),业务无需手写方言差异。
593
611
 
612
+ **条件树(where 嵌套)**:
613
+
614
+ ```ts
615
+ await db.table("account").where({
616
+ and: [
617
+ { field: "age", op: "gte", value: 18 },
618
+ { or: [
619
+ { field: "role", op: "eq", value: "admin" },
620
+ { field: "tag", op: "isnull" },
621
+ ]},
622
+ ],
623
+ }).all(); // → WHERE age >= ? AND (role = ? OR tag IS NULL)
624
+ ```
625
+
626
+ - 树节点四形:叶子 `{field, op, value?}`、`{and:[...]}`、`{or:[...]}`、`{not:{...}}`。
627
+ - 深度上限 8、叶子数上限 64(超限报 `condition tree too deep/large`);空 `and`/`or` 组直接拒绝。
628
+
629
+ **条件对象(JS 侧组合/复用)**:`db.leaf(field, op, value)` / `db.and(...)` / `db.or(...)` /
630
+ `db.not(c)` 工厂产出条件对象;`where`/`having` 同时接受条件对象或普通 JSON 树。
631
+
632
+ ```ts
633
+ const scope = db.and(
634
+ db.leaf("tenant_id", "eq", tid),
635
+ db.leaf("deleted", "eq", 0),
636
+ );
637
+ const rows = await db.table("account").where(scope).all();
638
+ if (!scope.has("tenant_id")) throw new Error("tenant scope required"); // 多租户守卫
639
+ ```
640
+
641
+ - 条件对象**不可变**:`c.and(x)` / `c.or(x)` / `c.not()` 返回新对象,不改 `c`。
642
+ - `.tree()` 取纯 JSON 树;`.fields()` 取去重字段名列表;`.has(f)` 判断是否涉及某字段。
643
+
644
+ **join 联表**:
645
+
646
+ ```ts
647
+ await db.table("account")
648
+ .join("user", [{ left: "account.user_id", right: "user.id" }], "left")
649
+ .select(["account.id", "user.name"])
650
+ .all();
651
+ ```
652
+
653
+ - `on` 只支持列对列等值(`{left, right}` 数组,非空);`kind` 省略为 `inner`,可选 `left`。
654
+ - 列可写 `"表.列"`(表 ∈ 基表 ∪ join 表)或裸列名(**只解析基表**——歧义天然拒绝,联表查询建议全限定)。
655
+ - join 表同样过租户/所有权守卫;**自 join 不支持**(无表别名)。
656
+
657
+ **聚合 / groupBy / having / distinct**:
658
+
659
+ ```ts
660
+ await db.table("order")
661
+ .select([
662
+ "user_id",
663
+ { fn: "sum", field: "amount", as: "total" },
664
+ { fn: "count" }, // count(*) 可省 field
665
+ ])
666
+ .groupBy(["user_id"])
667
+ .having({ field: "total", op: "gt", value: 100 })
668
+ .all();
669
+ ```
670
+
671
+ - 聚合列 `{ fn: "count"|"sum"|"avg"|"min"|"max", field?: string, as?: string }`;
672
+ 别名形状 `^[A-Za-z_][A-Za-z0-9_]*$`(违例报 `illegal alias`)。
673
+ - `having` 白名单列优先,未命中按 select 聚合别名展开(`unknown column '...' in having`)——
674
+ PG 不允许 HAVING 引用输出别名,展开消灭方言分叉;orderBy 不支持聚合别名(仍走列白名单)。
675
+ - `.distinct()` 去重(仅 select)。
676
+
677
+ **DML:insert / update / delete**(`.run()` 终执行,返回受影响行数;`.all()` 仅 select):
678
+
679
+ ```ts
680
+ await db.table("user").insert({ name: "neo", age: 1 }).run(); // 单行
681
+ await db.table("user").insert([{ name: "a" }, { name: "b" }]).run(); // 多行(键集须一致)
682
+ await db.table("user").update({ age: 2 }).where({ field: "id", op: "eq", value: 1 }).run();
683
+ await db.table("user").delete().where({ field: "id", op: "in", value: [1, 2] }).run();
684
+ ```
685
+
686
+ - **红线**:`update`/`delete` 必须带 where 且叶子数 ≥ 1。JS 链层早抛(`update requires where`),
687
+ op 侧同样强制——`fromJSON` 可完全绕过 JS 链层,故动词×字段矩阵为 **op 侧权威校验**。
688
+ - 动词×字段矩阵:`insert` 拒 `where/orderBy/limit/offset/joins/distinct/groupBy/having`,
689
+ `values` 非空且多行键集一致;`update`/`delete` 拒 `joins/distinct/groupBy/having/limit/offset`,
690
+ `update` 的 `sets` 非空;`select` 拒 `values/sets`。违例报 `<verb> does not accept <field>`。
691
+ - insert 键 / update 键均过白名单(`unknown column '<k>' in insert values / update sets`)。
692
+ - `db.tx` 内构造的 DML 自动走事务同连接(见下)。
693
+
694
+ **toSQL / toJSON / fromJSON**:
695
+
696
+ ```ts
697
+ db.table("user").select(["id"]).where({ field: "age", op: "gt", value: 18 }).toSQL();
698
+ // → { sql: "SELECT ... WHERE \"age\" > $1", params: [18] } (占位符按目标库方言;不执行)
699
+
700
+ const snap = db.table("user").select(["id"]).where(scope).toJSON(); // 纯 JSON 快照,可直接改
701
+ snap.limit = 50;
702
+ await db.fromJSON(snap).all(); // 复原后继续链/执行;tx 回调对象上同样有 fromJSON
703
+ ```
704
+
705
+ - 快照里的 `db` 字段只是**创建时的 JS 可见名**;`fromJSON` 复原的实例绑定**复原方的 bound db**
706
+ (快照可跨模块传递而不泄漏源模块的库绑定);tx 内执行自动走事务同连接。
707
+
594
708
  **事务 `db.tx`**:
595
709
 
596
710
  ```ts
@@ -602,12 +716,86 @@ await db.tx(async (tx) => {
602
716
  ```
603
717
 
604
718
  - 回调正常返回 → **提交**;throw / reject → **回滚**并把原错误抛给 handler。
605
- - `tx` 与 `db` 的 `query / exec / table` **同签名**——事务内自动走同一连接,
606
- 无需改写其余代码。
719
+ - `tx` 与 `db` 的 `query / exec / table / fromJSON` 及条件工厂(`leaf/and/or/not`)
720
+ **同签名**——事务内自动走同一连接,无需改写其余代码(回调对象不含 `tx`——嵌套事务被拒)。
607
721
  - 每请求**至多一个**活跃事务:嵌套 `db.tx` 报错 `transaction already active`;
608
722
  事务未完结时访问其它库报错(先结当前事务)。
609
723
  - handler 忘记 `await` 或中途崩溃:请求结束时未完结事务**自动回滚**(服务端打 warn 日志)。
610
724
 
725
+ **子查询 / exists**(where 与 having 通用):
726
+
727
+ ```ts
728
+ await db.table("user")
729
+ .select(["id", "name"])
730
+ .where({ field: "id", op: "in", subquery: db.table("order")
731
+ .select(["user_id"]).where({ field: "amount", op: "gt", value: 100 }) })
732
+ .all(); // → WHERE id IN (SELECT user_id FROM order WHERE amount > ?)
733
+
734
+ await db.table("user").select(["id"]).where({
735
+ exists: db.table("order").select(["user_id"])
736
+ .where({ field: "amount", op: "gt", value: 100 }),
737
+ }).all(); // → WHERE EXISTS (SELECT user_id FROM order WHERE amount > ?)(非关联)
738
+ ```
739
+
740
+ - 叶子操作符 `in/eq/ne/gt/gte/lt/lte` 接受 `subquery`(builder 或纯 JSON 请求):
741
+ `in` 渲染 `IN (SELECT ...)`,其余渲染标量子查询 `col = (SELECT ...)`。
742
+ - `value` 与 `subquery` 互斥(`value and subquery are mutually exclusive`);
743
+ `isnull`/`like` 不接受子查询。`{ exists: <子查询> }` 节点不得携带其他键。
744
+ - 子查询同样过租户/所有权守卫;嵌套约束见下。
745
+
746
+ **union / union all**(排序/分页仅顶层):
747
+
748
+ ```ts
749
+ await db.table("vip").select(["id", "name"])
750
+ .union(db.table("black").select(["id", "name"])) // 缺省 distinct
751
+ .union(db.table("tmp").select(["id", "name"]), "all") // union all
752
+ .orderBy([{ field: "id", dir: "asc" }])
753
+ .all();
754
+ ```
755
+
756
+ - 主查询与成员都须**显式列**(`union requires explicit columns`)且列数一致;
757
+ 成员禁 order_by/limit/offset(`union member does not accept order_by/limit/offset`)。
758
+ - 成员同样过租户守卫;`intersect`/`except` 不做。
759
+
760
+ **case 列 / 窗口函数列**(select 列的对象形):
761
+
762
+ ```ts
763
+ await db.table("account").select([
764
+ "id",
765
+ { case: { when: [
766
+ { cond: { field: "balance", op: "lt", value: 0 }, then: "debt" },
767
+ { cond: { field: "vip", op: "eq", value: 1 }, then: "vip" },
768
+ ], else: "normal" }, as: "level" }, // → CASE WHEN ... THEN ? ... ELSE ? END
769
+ { window: { fn: "row_number", partition_by: ["user_id"],
770
+ order_by: [{ field: "amount", dir: "desc" }] }, as: "rn" },
771
+ ]).all();
772
+ ```
773
+
774
+ - case:searched case;`cond` 与 where 条件树同形;`then`/`else` **只允许 JSON 值**(绑定参数,
775
+ 不接受列名/标识符);`as` 必填过别名形状。
776
+ - window:`fn ∈ row_number / rank / dense_rank`;`partition_by` 列过白名单;frame 不做;`as` 必填。
777
+
778
+ **with CTE**(非递归,仅顶层):
779
+
780
+ ```ts
781
+ await db.table("rich")
782
+ .with("rich", ["user_id", "total"], db.table("order")
783
+ .select(["user_id", { fn: "sum", field: "amount", as: "total" }]).groupBy(["user_id"]))
784
+ .select(["user_id", "total"])
785
+ .all(); // → WITH rich(user_id, total) AS (SELECT ...) SELECT ... FROM rich
786
+ ```
787
+
788
+ - `.with(name, columns, query)`:`name`/`columns` 过别名形状(`illegal alias '...'`);
789
+ `columns` 必填非空(`cte needs non-empty columns`)——**声明的输出列即后续解析的白名单**。
790
+ - 基表/join 表名命中 CTE 名时跳过 registry 表校验(CTE 无 owner);CTE 查询递归过守卫。
791
+
792
+ **嵌套通用约束**(子查询 / exists / union 成员 / CTE 一致):
793
+
794
+ - 层数上限 4(`<site>: nested select too deep`,site 为出错位置);嵌套必须 select
795
+ (`<site>: nested select must be select`);嵌套禁 with/unions
796
+ (`<site>: nested select does not accept with/unions (v1)`)。
797
+ - 隐式默认 `limit 100` 只作用于**顶层**查询;嵌套/成员不隐式截断(显式 limit 一律生效)。
798
+
611
799
  ### kv / redis —— KV 存储
612
800
 
613
801
  | API | 签名 | 说明 |
@@ -666,7 +854,7 @@ Content-Type,s3 302 跳 presigned URL)。key 按 `/` 分段白名单校验
666
854
 
667
855
  | API | 签名 | 说明 |
668
856
  |---|---|---|
669
- | `bus.publish` | `publish(topic: string, data?: unknown): Promise<number>` | 广播 JSON 帧 `{"topic":…,"data":…}` 给订阅该 topic 的**全部 WS 会话**,返回接收方数(无订阅返回 0) |
857
+ | `bus.publish` | `publish(topic: string, data?: unknown \| Uint8Array): Promise<number>` | 广播给订阅该 topic 的**全部 WS 会话**,返回接收方数(无订阅返回 0):JSON 数据 → Text 帧 `{"topic":…,"data":…}`;Uint8Array/ArrayBuffer **Binary 帧原字节**(不包信封,v0.1.16 起,wire 约定见第 13 章) |
670
858
  | `bus.subscribe` | `subscribe(topic: string): Promise<void>` | 当前 WS 会话订阅 topic(**HTTP 路径调用报错**——订阅对象是连接本身) |
671
859
  | `bus.kind` | `kind(): Promise<string>` | 活跃 broker 类型:`"local"` / `"kafka"` / `"rabbitmq"`(异步 op,判等须 `await`) |
672
860
 
@@ -734,7 +922,7 @@ if (r.ok) {
734
922
 
735
923
  | API | 签名 | 说明 |
736
924
  |---|---|---|
737
- | `ws.send` | `send(data: string): void` | 向当前连接发一帧(HTTP 路径下 no-op |
925
+ | `ws.send` | `send(data: string \| Uint8Array): void` | 向当前连接发一帧:string → Text 帧(0x1),Uint8Array → Binary 帧(0x2,v0.1.16 起);HTTP 路径下 no-op |
738
926
  | `ws.close` | `close(): void` | 结束当前连接 |
739
927
  | `sess.id` | `number`(只读) | 当前连接 id(路由内自 1 递增) |
740
928
  | `sess.state` | 读写属性 | 连接会话状态(Rust 会话表持久,按连接隔离);**必须可 JSON 序列化**——函数等不可序列化值静默丢失 |
@@ -884,11 +1072,13 @@ globalThis.APP_ENV = "prod";
884
1072
 
885
1073
  | 客户端 | 生产面 | 消费面(**仅任务上下文**) |
886
1074
  |---|---|---|
887
- | `Kafka(name)` | `send(topic, {value, key?, headers?})` | `poll(topics, {max?, timeoutMs?})` → `{messages}`;`commit(m)`(按 m.offset+1 提交) |
888
- | `RabbitMQ(name)` | `publish(exchange, routingKey, value, {headers?})` | `poll(queues, {max?, timeoutMs?})`(= 循环 basic.get);`ack(m)`;`nack(m, requeue?)` |
1075
+ | `Kafka(name)` | `send(topic, {value, key?, headers?})`;`value` 传 Uint8Array/ArrayBuffer → **base64 编码进 `value_b64`**,record 载荷 = 原始字节(v0.1.16) | `poll(topics, {max?, timeoutMs?})` → `{messages}`;`commit(m)`(按 m.offset+1 提交) |
1076
+ | `RabbitMQ(name)` | `publish(exchange, routingKey, value, {headers?})`(`value` 二进制语义同 Kafka) | `poll(queues, {max?, timeoutMs?})`(= 循环 basic.get);`ack(m)`;`nack(m, requeue?)` |
889
1077
  | 两者 | `kind()` / `metadata()` | —— |
890
1078
 
891
- 消息形状(`OjMqMessage`):`{topic, partition?, offset?, key?, value, headers?, ts?}`。
1079
+ 消息形状(`OjMqMessage`):`{topic, partition?, offset?, key?, value, value_b64?, headers?, ts?}`。
1080
+ 载荷为 UTF-8 时 `value` 是解析后的 JSON(解析失败为字符串);**非 UTF-8 载荷** → `value` 为
1081
+ `null`、`value_b64` 为 base64 字符串(v0.1.16 起,自行 `atob`/解码还原字节)。
892
1082
 
893
1083
  **消费门禁(评审 M2)**:`poll/commit/ack/nack` 只在长任务上下文可用——HTTP/WS
894
1084
  handler 里调用直接报错(消费会话归属任务实例,实例级单 poller,第二个并发 poll
@@ -1036,6 +1226,8 @@ export default {
1036
1226
  tenant:
1037
1227
  enable: true
1038
1228
  header_key: "X-TENANT-ID" # 默认即此名
1229
+ sql_guard: deny # 多租户 SQL 防护:false(默认,不改写 SQL)| "warn" | "deny"/true
1230
+ shared_allow: [dict] # 共享表白名单(schema.yaml 里 tenant: false 的表须在此列出才生效)
1039
1231
  ```
1040
1232
 
1041
1233
  启用后所有 `{base}` 请求必须带该 header(缺失/空 → 400),值注入 `http.tenantId`
@@ -1043,8 +1235,15 @@ tenant:
1043
1235
  `/*`」形式,但 tenant 匹配是**严格一层**通配(更深路径需显式列出,如 `/idp/.well-known/*`;
1044
1236
  oj-auth 插件实现为多层前缀),豁免缺失 400——给 OIDC 302 跳转腿用(浏览器带不了自定义头);
1045
1237
  已带的头仍照常注入。
1046
- **框架不自动改写 SQL**——行级过滤归业务(自行在查询里带
1047
- tenant 条件)。启用期间测试请求也必须带头(第 9 章两约束)。
1238
+
1239
+ **`sql_guard`(v0.1.15 起)**:开 `enable` 只是识别租户头;`sql_guard` 才自动防护 SQL——
1240
+ `db.table()` 构造器查询自动注入 `tenant_id` 条件(join 进 ON、子查询递归)、insert 强制
1241
+ 当前租户、update/delete 自动收窄、sets 显式改 tenant_id 拒绝;裸 SQL 查「完全遗漏 tenant_id」
1242
+ warn 告警 / deny 拦截。guard 非 Off 时 schema.yaml 声明的表必须有 `tenant_id` 列
1243
+ (共享表 `tenant: false` + `shared_allow` 双声明豁免),server 启动 / `oj build` /
1244
+ `oj migrate` 三处校验。跨租户操作(对账、运营报表)走 `db.asSystem()`(请求级、打审计日志)。
1245
+ 新人向白话文档见 `docs/tenant-guide.md`;**注意租户头目前是客户端自报,防伪造需 JWT claims
1246
+ 绑定(规划见设计文档 §7)**。
1048
1247
 
1049
1248
  ### auth_demo 走读(sample)
1050
1249
 
@@ -1118,6 +1317,7 @@ describe("user account", () => {
1118
1317
  |---|---|
1119
1318
  | `client.get/post/put/del/patch/head/options(path, opts?)` | 进程内派发;`opts = { headers?, body? }`,返回 `ClientResp { status, headers, body, upgrade }`;`path` 相对 base(如 `"/user/account"`) |
1120
1319
  | `client.login(username, password, headers?)` | POST 业务路由 `/auth/login`(sample/src/auth/)→ 返回 `access_token`(失败抛错;`headers` 透传,如租户头) |
1320
+ | `client.ws(path)`(v0.1.16) | WS 帧测试面:`send(string\|Uint8Array)` / `next(ms?) → {binary,data}\|{closed:true}\|null` / `close()`;首次使用惰性起 127.0.0.1:0 本地服务(真实路由 + 帧循环)。用例见 `sample/tests/ws-bin.test.ts` |
1121
1321
  | `describe(name, fn)` / `it(name, fn)` / `beforeEach(fn)` | vitest 风格子集 |
1122
1322
  | `expect(actual)` | `.toBe / .toEqual / .toBeTruthy / .toBeFalsy / .toContain` |
1123
1323
  | `finish()` | 标记会话结束 |
@@ -1141,7 +1341,7 @@ cd sample && npm run test:unit # 统一入口(等价 cd unit && npm ci && n
1141
1341
 
1142
1342
  结构:`mocks/oj-globals.ts` 提供 `installGlobals(opts?)`(把 `db/json/http/bus/log`
1143
1343
  换成可控桩,返回响应捕获 `{code,msg,data}`;`lastPublished()` 取 `bus.publish` 记录,
1144
- `lastSqlCalls()` 取 `db.query/exec` SQL 与绑定参数——可断言 handler 走了哪个分支);
1344
+ `lastSqlCalls()` 取 `db` 发出的 SQL(query/exec/构造器)与绑定参数——可断言 handler 走了哪个分支);
1145
1345
  `invoke.ts` 提供 `invoke(handler, method, opts?)`(装桩 → 调 handler → flush 微任务 →
1146
1346
  返回 `{ ...capture, published }`);`*.spec.ts` 直接 import 真实 `../src/.../api` 的 handler。
1147
1347
 
@@ -1297,7 +1497,9 @@ V8 runtime 按 Worker 数常驻;单帧超时只断该连接(毒化 Worker
1297
1497
  ### tenant / auth
1298
1498
 
1299
1499
  字段与语义见第 8 章(tenant 默认关闭、header 默认 `X-TENANT-ID`、`anonymous_paths`
1300
- 跳转腿豁免;auth `jwt_secret`(空串启动 fail-fast,生产必改)、`signing_method`
1500
+ 跳转腿豁免;v0.1.15 起增 `sql_guard`(false|"warn"|"deny")与 `shared_allow` 共享表
1501
+ 白名单——语义与建表要求见第 8 章与 `docs/tenant-guide.md`;auth 的 `jwt_secret`(空串
1502
+ 启动 fail-fast,生产必改)、`signing_method`
1301
1503
  (HS256|HS384|HS512,默认 HS256)、`access_token_duration`(默认 60s)、
1302
1504
  `refresh_token_duration`(默认 720h)、`anonymous_paths`——无 `user_table` 配置,
1303
1505
  用户表是业务约定)。
@@ -1449,6 +1651,24 @@ vendored `node_modules/`(不打进 tgz)→ `./oj server -c config.yaml --api
1449
1651
  启动时把模块清单 + 路由表写入日志,可据此核对发布是否完整(终端默认静默:
1450
1652
  `tail -f logs/server-*.log`,或启动时加 `--console-log`)。
1451
1653
 
1654
+ ### npm 分发(@oj-bin/*,v0.1.13)
1655
+
1656
+ 除 tarball 外还有 npm 渠道,最终布局与解包一致:
1657
+
1658
+ ```bash
1659
+ npm i @oj-bin/oj # 主包;optionalDependencies 自动带平台子包 @oj-bin/oj-<triple>
1660
+ ```
1661
+
1662
+ - `postinstall` 按当前平台取子包内容落盘 `<项目根>/bin/`(`oj` / `plugins/<triple>/` /
1663
+ `devkit/` / `lib`),原子替换(rename,可覆盖运行中的旧二进制);**不支持全局安装**
1664
+ (没有确定的项目根,装全局直接报错)。被 `--omit=optional` / `ignore-scripts` 排除
1665
+ 时 fail-fast 并提示。
1666
+ - 发布由仓库 `scripts/npm-publish.sh` 单一来源:装配 + 幂等(`npm view` 已发布即
1667
+ skip)+ 门禁(tag 与 Cargo.toml 版本一致性、同 `(os,cpu)` 撞车、包根布局断言)+
1668
+ 发布后置信断言(os/cpu/tarball 文件清单);`DRY_RUN=1` 只装配断言不发布,本地自检用。
1669
+ - npm 包不可撤回:CI 上 release 草稿期不发 npm(人工核对 release 后幂等补发),
1670
+ 版本一经发布不可复用——改代码必须递增版本。
1671
+
1452
1672
  ## 12. 运维要点
1453
1673
 
1454
1674
  > 何时读我:服务跑起来之后的证书/日志/排障/升级。
@@ -1563,7 +1783,7 @@ RUST_LOG=oj=info ./oj server -c config.yaml --api-path dist
1563
1783
 
1564
1784
  - **动态标识符(表名/列名)只来自 `db.table()` 查询构造器**(SchemaRegistry 白名单),
1565
1785
  **绝不来自 JS 字符串**。
1566
- - **值只通过绑定参数传递**(`db.query("... where id = ?", [id])`),**绝不字符串拼接**。
1786
+ - **值只通过绑定参数传递**(`db.query("... where id = ?", [id])` 或构造器的 `value`),**绝不字符串拼接**。
1567
1787
 
1568
1788
  ```ts
1569
1789
  // 正确:标识符走构造器,值走绑定参数
@@ -1603,6 +1823,8 @@ await db.query("select id from account where id = " + id, []); // 禁止
1603
1823
  | release 下 WS URL 含版本段 | `…/news-0.1.0/ws`;客户端发现 WS 地址时注意拼版本段 |
1604
1824
  | `db.tx` 每请求至多一个;嵌套报错 | 合并事务回调 |
1605
1825
  | `bus` 缺省进程内,跨实例不互通 | 需要跨实例广播配 `broker.kind` |
1826
+ | bus 二进制 wire 约定(v0.1.16) | JSON → record/信封文本帧;字节 → record payload = 原始字节、投递为 Binary 帧。消费侧启发式:UTF-8 且为含 `topic`+`data` 的 JSON 对象才按文本信封,否则按二进制透传——**恰为该形状 JSON 的二进制载荷会以文本帧投递**(无害,自辨) |
1827
+ | WS 二进制状态(Yjs awareness 等)不进 `sess.state` | `sess.state` 必须可 JSON 序列化;二进制状态走 base64 字符串存 `sess.state`/kv,或分片放 kv |
1606
1828
  | `WhereCond.and/or` 嵌套未展开 | 多个 `where()` 即 AND;复杂条件用 `db.query` 参数化 SQL |
1607
1829
  | schema 回滚无自动机制 | 迁移只前向;破坏性变更前备份,反向变更写新 seq 迁移 |
1608
1830
  | fixtures/ 不进 release 产物 | 演示数据走 fixtures(oj test / oj fixture);参考数据走模块 seed.sql |
@@ -1623,6 +1845,9 @@ await db.query("select id from account where id = " + id, []); // 禁止
1623
1845
  - `redis.default` 配置即真连且 fail-fast——CI/离线环境注释掉该段即用内存 KV。
1624
1846
  - 每请求至多一个 `db.tx`;漏 await 会在请求结束时自动回滚并打 warn。
1625
1847
  - `beforeEach` 是单一全局钩子,跨 `describe` 被覆盖——多 describe 文件在各 `it` 内联准备。
1848
+ - WS Binary 帧 `http.body` 是 `null` 不是乱码——取字节用 `http.bodyBytes()`(v0.1.16)。
1849
+ - `ws.send` 的帧型由**参数类型**决定:string → Text、Uint8Array → Binary——回显二进制协议时
1850
+ 别把字节先 decode 成 string 再发(帧型就变了)。
1626
1851
  - `ext_boot.js` 里别写库/发广播/打外部接口——执行次数是「模块数 + `pool_size` + WS Worker 数
1627
1852
  (每路由 `ws.workers_per_route`)」,副作用按此放大;boot 只做全局装配。
1628
1853
  - `ext_boot.js` 顶层 `await` 忘了 `export {};` → 看起来莫名的 SyntaxError(CJS 启发式误判)。
@@ -66,6 +66,8 @@ interface HttpApi {
66
66
  query: Record<string, string>;
67
67
  headers: Record<string, string>;
68
68
  body: any;
69
+ // WS Binary 帧时 body 为 null;原始帧字节走 bodyBytes()(v0.1.16,文本帧亦可用)。
70
+ bodyBytes(): Promise<Uint8Array>;
69
71
  // 取路由参数或 query 参数:路径参数优先,query 兜底,均缺失返回 def 原值。
70
72
  param(name: string, def?: unknown): any;
71
73
  // 租户 id(tenant 启用时从租户头提取;未启用为 null)。
@@ -113,8 +115,9 @@ interface KVApi {
113
115
  }
114
116
 
115
117
  // ws.* :WebSocket 生命周期钩子内的主动发送/关闭控制(HTTP 路径 no-op)。
118
+ // send 帧型由参数类型决定:string → Text 帧(0x1);Uint8Array → Binary 帧(0x2)(v0.1.16)。
116
119
  interface WSApi {
117
- send(data: string): void;
120
+ send(data: string | Uint8Array): void;
118
121
  close(): void;
119
122
  }
120
123
 
@@ -134,6 +137,7 @@ interface BlobApi {
134
137
  // bus.* :主题广播。publish 广播给订阅 topic 的全部 WS 会话,返回接收方数;
135
138
  // subscribe 仅 WS 会话内可用(HTTP 路径报错);kind 报告活跃 broker 类型。
136
139
  interface BusApi {
140
+ // data 为 Uint8Array/ArrayBuffer → Binary 帧原字节(不包信封,v0.1.16);其余 → JSON 信封 Text 帧。
137
141
  publish(topic: string, data?: unknown): Promise<number>;
138
142
  subscribe(topic: string): Promise<void>;
139
143
  kind(): Promise<string>;
@@ -239,7 +243,19 @@ declare global {
239
243
  headers?: Record<string, string>;
240
244
  body?: string;
241
245
  }
246
+ // WS 帧测试面(v0.1.16):path 形如 "/echo-bin/ws"(相对 base)。
247
+ interface TestWsFrame {
248
+ binary: boolean;
249
+ data: string | Uint8Array;
250
+ }
251
+ interface TestWs {
252
+ send(data: string | Uint8Array): Promise<void>;
253
+ // 下一帧:{binary, data};对端关闭 {closed: true};超时无帧 null(默认 1000ms)。
254
+ next(ms?: number): Promise<TestWsFrame | { closed: true } | null>;
255
+ close(): Promise<void>;
256
+ }
242
257
  interface Client {
258
+ ws(path: string): TestWs;
243
259
  get(path: string, opts?: ClientOptions): Promise<ClientResp>;
244
260
  post(path: string, opts?: ClientOptions): Promise<ClientResp>;
245
261
  put(path: string, opts?: ClientOptions): Promise<ClientResp>;
@@ -295,6 +311,9 @@ interface OjMqMessage {
295
311
  offset?: number;
296
312
  key?: string;
297
313
  value: any;
314
+ // 非 UTF-8 载荷时 value 为 null,value_b64 为 base64 字符串(v0.1.16);生产侧传
315
+ // Uint8Array 亦编码进 value_b64(record 载荷 = 原始字节)。
316
+ value_b64?: string;
298
317
  headers?: Record<string, string>;
299
318
  ts?: number;
300
319
  delivery_tag?: number; // rabbit 专属:ack/nack 载荷原样回传
package/oj.exe CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oj-bin/oj-x86_64-pc-windows-msvc",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "description": "Prebuilt oj binary + plugins for x86_64-pc-windows-msvc. Optional platform package of @oj-bin/oj; do not install directly.",
5
5
  "homepage": "https://github.com/everpan/only-js",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/everpan/only-js.git" },