@oj-module/cli 0.2.5 → 0.2.8

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.
Files changed (105) hide show
  1. package/README.md +1 -1
  2. package/devkit/manual.md +19 -17
  3. package/devkit/sql-builder-missing-capabilities-pr.md +234 -0
  4. package/package.json +2 -2
  5. package/shell-dist/assets/runtime.js +2 -2
  6. package/shell-dist/index.html +2 -2
  7. package/shell-dist/versions.json +3 -3
  8. package/src/contract/__tests__/handler-scan.test.ts +29 -0
  9. package/src/contract/__tests__/market-client.integration.test.ts +167 -0
  10. package/src/contract/handler-scan.ts +35 -4
  11. package/src/dev.ts +10 -2
  12. package/src/index.ts +3 -3
  13. package/src/init.ts +44 -0
  14. package/src/preview-config.ts +163 -0
  15. package/src/preview.ts +105 -56
  16. package/src/usage.ts +3 -2
  17. package/templates/api/config.yaml +4 -2
  18. package/templates/api/src/_platform/README.md +31 -0
  19. package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
  20. package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
  21. package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
  22. package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
  23. package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
  24. package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
  25. package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
  26. package/templates/api/src/_platform/_lifecycle/uninstall.ts +13 -8
  27. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
  28. package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +8 -6
  29. package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
  30. package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
  31. package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +18 -17
  32. package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
  33. package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
  34. package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
  35. package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +12 -12
  36. package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
  37. package/templates/api/src/_platform/contacts/api.ts +2 -2
  38. package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
  39. package/templates/api/src/_platform/manifest.yaml +3 -1
  40. package/templates/api/src/_platform/market/api.ts +54 -16
  41. package/templates/api/src/_platform/schema.yaml +14 -0
  42. package/templates/api/src/_platform/solutions/api.ts +13 -12
  43. package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
  44. package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
  45. package/templates/api/src/auth/_shared/session.ts +25 -21
  46. package/templates/api/src/auth/api.ts +1 -1
  47. package/templates/api/src/demo/todos/api.ts +1 -0
  48. package/templates/api/src/home/line/api.ts +4 -4
  49. package/templates/api/src/home/pie/api.ts +4 -4
  50. package/templates/api/src/notification/_shared/notify.ts +40 -38
  51. package/templates/api/src/notifications/api.ts +1 -4
  52. package/templates/api/src/personal-center/upload/api.ts +1 -1
  53. package/templates/api/src/system/login-methods/api.ts +1 -1
  54. package/templates/api/src/system/oplog/api.ts +3 -3
  55. package/templates/api/src/system/permissions/api.ts +2 -2
  56. package/templates/api/src/system/roles/api.ts +2 -2
  57. package/templates/api/src/system/roles/bindings/api.ts +2 -2
  58. package/templates/api/src/system/settings/api.ts +2 -2
  59. package/templates/api/src/system/users/api.ts +2 -2
  60. package/templates/api/src/system/users/forbidden/api.ts +2 -2
  61. package/templates/api/src/system/users/password/api.ts +2 -2
  62. package/templates/api/src/system/users/roles/api.ts +2 -2
  63. package/templates/api/src/web/user-info/api.ts +2 -5
  64. package/templates/api/tests/auth-session.spec.ts +1 -1
  65. package/templates/api/tests/auth-throttle.spec.ts +1 -1
  66. package/templates/api/tests/guard.spec.ts +1 -1
  67. package/templates/api/tests/lifecycle/market-api.spec.ts +1 -1
  68. package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
  69. package/templates/api/tests/lifecycle/uninstall.spec.ts +1 -1
  70. package/templates/api/tests/query-utils.spec.ts +1 -1
  71. package/templates/api/tests/rbac-audit.spec.ts +1 -1
  72. package/templates/api/tests/rbac-contacts.spec.ts +2 -2
  73. package/templates/api/tests/rbac-guard.spec.ts +1 -1
  74. package/templates/api/tests/rbac-roles.spec.ts +2 -2
  75. package/templates/api/tests/rbac-settings.spec.ts +2 -2
  76. package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
  77. package/templates/api/tests/rbac-users.spec.ts +1 -1
  78. package/templates/api/tsconfig.json +8 -0
  79. package/templates/env.d.ts +2 -0
  80. package/templates/global.d.ts +816 -0
  81. package/templates/init.include.yaml +18 -1
  82. package/templates/logs/task-events.jsonl +0 -0
  83. package/templates/openapi.json +525 -13
  84. package/templates/package.json +2 -1
  85. package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
  86. package/templates/web/src/_platform/__client/api.ts +152 -39
  87. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
  88. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
  89. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
  90. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
  91. package/templates/web/src/oj-market/api/client.ts +5 -105
  92. package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
  93. package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
  94. package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
  95. package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
  96. package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
  97. package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
  98. package/templates/web/src/oj-market/locales/en-US.json +2 -1
  99. package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
  100. package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
  101. package/templates/web.config.ts +3 -3
  102. package/vendor/host-versions.json +3 -3
  103. /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
  104. /package/templates/api/src/_platform/_shared/service/{oplog.ts → infra/oplog.ts} +0 -0
  105. /package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +0 -0
package/README.md CHANGED
@@ -27,7 +27,7 @@ ojm dev [port] # 启动开发服务器(默认 5174):宿主代理 + 本
27
27
  ojm build # 构建模块产物与 dist/modules.json
28
28
  ojm init [dir] [--yes] # 脚手架:模板工程 + 证书签发 + 联网下载 oj 到 bin/
29
29
  ojm vendor [tag] [--force] # 下载/重装 oj vendor(经 npm 包 @oj-bin/oj,tag 缺省取最新版本,形如 v0.1.13)
30
- ojm preview [port] [--oj-static] # 生产形态预览:oj migrate + release + 静态兜底
30
+ ojm preview [port] # 生产形态预览:oj migrate → oj serve 单服务(API + 静态 SPA 同进程)
31
31
  ojm info # 输出报障所需的版本矩阵与后端观测
32
32
  ojm api [dir] # 契约产物生成(client/openapi/mock stub)
33
33
  ojm merge <out> <in...> # 合并多份 modules.json
package/devkit/manual.md CHANGED
@@ -1294,7 +1294,7 @@ pnpm dev # 开发:devServer + oj 后端(api/src 保存即热更
1294
1294
  # Ctrl+C(SIGINT)/ kill(SIGTERM)终止 dev 时,前端与后端 oj 一起终止:
1295
1295
  # 先关 SSE 通道 → 停 oj 子进程 → 打印「后端 oj 已随前端一起终止」,不留 oj 孤儿占端口
1296
1296
  pnpm build # = ojm build:oj build(生成 routes.js)+ 前端全站合并到 web/dist
1297
- pnpm preview # = ojm preview:oj migrate(verify 闸)→ 启动 serve + 静态后备
1297
+ pnpm preview # = ojm preview:oj migrate(verify 闸)→ oj serve 单服务(API + 静态 SPA 同进程)
1298
1298
  pnpm typecheck # = tsc --noEmit
1299
1299
  pnpm exec ojm info # 版本矩阵 + 模块清单(报障用)
1300
1300
  pnpm exec ojm vendor # 下载/重装 oj 二进制
@@ -1354,10 +1354,11 @@ export default async (path: string, html: string) => {
1354
1354
  顺序是 transform → dev 的 reload 脚本注入,`ojm dev` 照旧热更。
1355
1355
  - 返回 `string` 或 `{ html, cacheControl? }`;**未配置时输出与历史逐字节一致**,抛错/坏返回只
1356
1356
  打日志并回退原文(不会 500),非 HTML 资产完全不受影响。
1357
- - 配置的模块缺失或 default 不是函数 → `ojm dev`/`ojm preview` **启动即报错**(不留半个 oj 进程)。
1358
- - **`preview --oj-static` 例外**:该模式静态由 oj 二进制直出(`--app-path`),不经 ojm 静态层,
1359
- 本钩子不生效;生产要在 oj 托管侧做同样的事,用 oj 的 `server.html_meta`(构建期 JSON)或
1360
- `server.html_meta_handler`(动态 handler)——见 oj devkit 手册「静态站点与 per-route meta」。
1357
+ - 配置的模块缺失或 default 不是函数 → `ojm dev` **启动即报错**(不留半个 oj 进程)。
1358
+ - **单服务 preview 例外**:preview 静态由 oj 二进制直出(web 挂载),不经 ojm 静态层,本钩子
1359
+ 不生效(启动检测到配置即 WARN,不加载模块);逐路由 meta 用 oj 的 `server.html_meta`
1360
+ (构建期 JSON)或 `server.html_meta_handler`(动态 handler)——见 oj devkit 手册
1361
+ 「静态站点与 per-route meta」。
1361
1362
 
1362
1363
  > release 的 `server.migrate_on_start` 默认 `verify`:迁移账本落后会**拒绝启动**,
1363
1364
  > 先跑 `ojm migrate -d api/dist`(透传自动补 `-c api/config.yaml`)。
@@ -1398,7 +1399,7 @@ export default async (path: string, html: string) => {
1398
1399
  | 豁免写了 `//` 注释后全部失效 | 严格 JSON 解析失败、静默回退 | 注释写进 `_comment` 字段 |
1399
1400
  | 新建后端模块目录后 API 404 | 目录镜像路由未热更 | **重启** `ojm dev` |
1400
1401
  | DELETE 返回 405 | 方法名写成了 `delete` | 改成 `del` |
1401
- | IM 预览/爬虫只有默认标题(页面 JS 改了没用) | 服务端没做 HTML 注入 | 配 `htmlTransform`(见 §7;`preview --oj-static` 模式下要改到 oj 侧 `server.html_meta_handler`) |
1402
+ | IM 预览/爬虫只有默认标题(页面 JS 改了没用) | 服务端没做 HTML 注入 | 配 `htmlTransform`(见 §7;单服务 preview 要改到 oj 侧 `server.html_meta`/`server.html_meta_handler`) |
1402
1403
  | 配了 `htmlTransform` 但没生效 | 路径写错 / default 不是函数会**启动报错**;能起来却没效果 → 检查该请求是否命中静态层(`/api` 前缀与带扩展名的资源都不过钩子) | 见 §7 的生效范围 |
1403
1404
  | 上传报 401 | `headers` 写成固定对象,token 过期 | 写成方法,用时现取(§4.5) |
1404
1405
  | 后端菜单用了我的布局名,页面却没壳 | 注册该布局的模块没加载 | 看控制台 `[layout] 未知名布局` 提醒 |
@@ -1468,7 +1469,7 @@ export default async (path: string, html: string) => {
1468
1469
 
1469
1470
  > **为什么只有这 5 个走透传?** oj 还有 `serve` / `build` / `secret`(`keygen`/`seal` 等)
1470
1471
  > 子命令——但它们被 `ojm` **内部调用并附带额外逻辑**(如 `ojm build` 在 `oj build`
1471
- > 之后还要做前端全站合并;`ojm preview` 在 `oj migrate` 之后起 `oj serve` + 静态后备),
1472
+ > 之后还要做前端全站合并;`ojm preview` 在 `oj migrate` 之后起 `oj serve` 单服务),
1472
1473
  > 所以不单独透传给用户,避免绕开 ojm 的工程化处理。需要原汁原味跑 oj 其它子命令时,
1473
1474
  > 直接 `./bin/oj <sub> ...`(记得手动 `-c api/config.yaml`)。
1474
1475
 
@@ -1516,21 +1517,22 @@ export default async (path: string, html: string) => {
1516
1517
  > release 下只有 `routes.js` 里的路由存在,目录镜像路由不生效——所以参数路由要先
1517
1518
  > `ojm build` 再测(见 §7)。
1518
1519
 
1519
- #### `ojm preview [port] [--oj-static]`
1520
+ #### `ojm preview [port]`
1520
1521
 
1521
- 生产形态预览(设计 §6):
1522
+ 生产形态预览(设计 §6,2026-10-19 单服务化):
1522
1523
 
1523
1524
  1. 启动前 **fail-fast 四查**(缺 oj 二进制 / `config.yaml` / `web/dist/index.html` /
1524
1525
  `api/dist/manifests.yaml` 任一即报错并给补救指引,绝不留下 oj 孤儿);
1525
1526
  2. `oj migrate`(应用待执行迁移,非零退出即退、不起 server);
1526
- 3. `oj serve`(release/js,仅 API);
1527
- 4. ojm 静态层后备(SPA 回退,无 SSE 注入、不设 no-store)。
1528
-
1529
- - `port`:preview 端口(缺省 **4173**)。
1530
- - `--oj-static`:静态改由 oj `--app-path` 直出(真 exercise oj 静态层;history 深链接 404 为已知限制,
1531
- ojm 本口仍提供 SPA 后备 + `/api` 反代)。
1532
- - 支持 `web.config.ts` 的 `htmlTransform`(按路由注入 `<title>`/`og:*`,见 §7),模块缺失或
1533
- default 非函数会**启动即报错**。
1527
+ 3. 生成派生 config `api/.ojm-preview.yaml`(原 config 副本 + upsert `/` web 挂载 +
1528
+ `spa: true`;显式 `port` 时覆盖 `server.port`);
1529
+ 4. `oj serve`(release/js,**单进程同时出 API 与静态**,SPA 深链回落)。
1530
+
1531
+ - `port`:preview 端口(缺省用 config 的 `server.port`,默认 9778);显式传参经派生 config 覆盖。
1532
+ - ojm 不再自建 http server、不反代、不传 `--app-path`——与「oj serve + web 挂载」的生产公式同形。
1533
+ - 派生 config 为临时产物,退出三路清理(SIGINT / 进程退出兜底 / 编程式 stop),模板 .gitignore 已收录。
1534
+ - `web.config.ts` 的 `htmlTransform` 在单服务 preview **不生效**(启动 WARN 指向 oj
1535
+ `server.html_meta` / `server.html_meta_handler`)。
1534
1536
 
1535
1537
  #### `ojm api [dir] [--check] [--docs] [--exempt <path>]`
1536
1538
 
@@ -0,0 +1,234 @@
1
+ # 上游 PR 提案:SQL 构造器缺失能力清单
2
+
3
+ > 目的:把模板 `db.query` 全面迁移到 `db.table(...)` 构造器以获得多库(sqlite / mysql / postgres)可移植性时,
4
+ > 识别出构造器**当前无法表达**、只能回退 `db.query` 的 7 类能力。本文档作为向上游 `oj` 运行时 SQL 构造器提 PR 的依据。
5
+ >
6
+ > 证据来源:`~/.local/share/oj/devkit/api-manual.md`(查询构造器章节,含 `like` 操作清单 §1142、子查询限制 §1343、
7
+ > `union` §1346、CTE §1378 等);框架 `README.md` v0.1.66(ESCAPE 纪律);模板代码 `packages/cli/templates/api/src/**`。
8
+
9
+ ---
10
+
11
+ ## 1. 背景与现状
12
+
13
+ 模板已完成 `db.query` → `db.table()` 的主体迁移:约 96 处查询/写入迁移到构造器,但仍有 **17 处**因构造器能力缺口被迫保留
14
+ `db.query`(含注释 `保留 db.query:…`)。这些缺口若不上游补齐,模板永远无法做到"零原生 SQL"。
15
+
16
+ 构造器已具备:`select/where/orderBy/limit/offset/all`、`insert/update/delete/returning/run`、
17
+ `where` 树(`and/or/not`)、`in/like/isnull/eq…` 比较、`union`/`union all`、`case`/`window` 列、`with` CTE、嵌套子查询(深度≤4)。
18
+
19
+ 缺口集中在**表达能力**而非基础 CRUD。
20
+
21
+ ---
22
+
23
+ ## 2. 缺失能力总览(按出现频率与阻塞面排序)
24
+
25
+ | # | 缺失能力 | 阻塞面 | 当前回退点(模板) | 优先级 |
26
+ |---|----------|--------|--------------------|--------|
27
+ | 1 | `like` 的 `ESCAPE` 转义子句 | 模糊搜索全场景(用户/方案/联系人/目录) | `contacts.ts:20`、`solution-perms.ts:100`、`uninstall.ts:76/82/116` | P0 |
28
+ | 2 | `join ... ON` 带**值条件**(`AND user_id = ?`) | 通知合并查询(broadcast LEFT JOIN 回执) | `notify.ts:135-167` | P0 |
29
+ | 3 | `select` 支持**字面量/计算列**(`'user' as scope`、`(x IS NOT NULL…) as y`) | 通知合并 + 角色作用域标记 | `notify.ts:144/148` | P1 |
30
+ | 4 | `INSERT OR IGNORE` / `INSERT … SELECT` | 通知已读回执幂等写入 | `notify.ts:167-169/186-188` | P1 |
31
+ | 5 | **全表 delete 的强制 where 豁免** | 登录限流重置等有意清空 | `rate.ts:78` | P2 |
32
+ | 6 | **动态 WHERE** 组合(运行时按可选参数拼接) | 多过滤项搜索、动态 IN | `demo/todos/api.ts:12`、`uninstall.ts:166` | P2 |
33
+ | 7 | `select *`(无列清单) | 刷新令牌等整行读取 | `session.ts:114-115` | P3 |
34
+
35
+ ---
36
+
37
+ ## 3. 逐项详述
38
+
39
+ ### 3.1 `like` 缺 `ESCAPE` 子句(P0)
40
+
41
+ **为什么是硬约束**:框架对模糊搜索有强制纪律——`LIKE` 元字符必须用 `ESCAPE '!'` 转义,且**不能用 `ESCAPE '\'`**
42
+ (mysql 默认 `sql_mode` 下 `ESCAPE '\'` 直接 1064)。`api-manual.md` 的 `like` 操作清单(§1142)及子查询限制(§1343)
43
+ 表明:`WhereCond` 的 `like` 不接受 `escape` 字段,也不接受子查询。
44
+
45
+ **阻塞的真实代码**(`contacts.ts:20`):
46
+
47
+ ```ts
48
+ // 保留 db.query:模糊搜索需 ESCAPE '!'(构造器 like 无 escape 子句;mysql 默认 sql_mode 下 ESCAPE '\' 即 1064)
49
+ const rows = await db.query(
50
+ `SELECT * FROM contacts WHERE name LIKE ? ESCAPE '!' OR phone LIKE ? ESCAPE '!'`,
51
+ [pat, pat],
52
+ ) as unknown as ContactRow[];
53
+ ```
54
+
55
+ **提议 API**:
56
+
57
+ ```ts
58
+ // 方案 A:WhereCond 增加 escape 字段
59
+ db.table("contacts").where({ field: "name", op: "like", value: pat, escape: "!" }).all();
60
+ // 方案 B:链式 like 三参
61
+ db.table("contacts").where(c => c.like("name", pat, "!").or.like("phone", pat, "!")).all();
62
+ ```
63
+
64
+ 引擎侧:sqlite 原样 `ESCAPE '!'`;mysql/postgres 同语义转写。**务必锁定 `!` 为默认转义符**,杜绝 `'\'`。
65
+
66
+ ---
67
+
68
+ ### 3.2 `join ... ON` 仅支持列=列,不支持值条件(P0)
69
+
70
+ **阻塞的真实代码**(`notify.ts:150`,广播通知合并读取):
71
+
72
+ ```sql
73
+ SELECT b.id, …, (r.read_time IS NOT NULL AND r.read_time <> '') as is_read, …, 'broadcast' as scope
74
+ FROM notifications_broadcast b
75
+ LEFT JOIN notifications_broadcast_read r ON r.broadcast_id = b.id AND r.user_id = ?
76
+ ```
77
+
78
+ `api-manual.md` 的 join `on` 仅接受 `{ left, right }`(列=列)。`AND r.user_id = ?` 是**值条件**,构造器无法表达。
79
+
80
+ **提议 API**:
81
+
82
+ ```ts
83
+ db.table("notifications_broadcast as b")
84
+ .leftJoin("notifications_broadcast_read as r",
85
+ on => on.on("r.broadcast_id", "b.id").and("r.user_id", "=", uid))
86
+ .select([...])
87
+ .all();
88
+ // on() 第二形参支持 op + value:.and("r.user_id", "=", uid)
89
+ ```
90
+
91
+ ---
92
+
93
+ ### 3.3 `select` 不支持字面量 / 计算列(P1)
94
+
95
+ **阻塞的真实代码**(`notify.ts:144/148`):
96
+
97
+ ```sql
98
+ SELECT id, avatar, date, is_read, message, title, link, category, 'user' as scope
99
+ FROM notifications WHERE user_id = ?
100
+ UNION ALL
101
+ SELECT b.id, …, (r.read_time IS NOT NULL AND r.read_time <> '') as is_read, …, 'broadcast' as scope
102
+ FROM notifications_broadcast b LEFT JOIN …
103
+ ```
104
+
105
+ `'user' as scope`(字面量列)与 `(r.read_time IS NOT NULL AND r.read_time <> '') as is_read`(计算列)均无法用构造器 `select([...])`
106
+ 表达(该 API 当前只收列名字符串或 `{ fn, field, as }` 聚合)。
107
+
108
+ **提议 API**:
109
+
110
+ ```ts
111
+ .select([
112
+ "id", "avatar", "date",
113
+ { expr: "'user'", as: "scope" }, // 字面量
114
+ { expr: "(r.read_time IS NOT NULL AND r.read_time <> '')", as: "is_read" }, // 计算列
115
+ ])
116
+ ```
117
+
118
+ > 注:`case`/`window` 列(§1360)已支持,但**任意表达式 + as 别名**维度缺失,建议补齐 `expr` 入口。
119
+
120
+ ---
121
+
122
+ ### 3.4 缺 `INSERT OR IGNORE` / `INSERT … SELECT`(P1)
123
+
124
+ **阻塞的真实代码**(`notify.ts:167-169` 与 `186-188`):
125
+
126
+ ```ts
127
+ // 保留 db.exec:INSERT OR IGNORE 构造器无等价(引擎按方言改写 IGNORE)
128
+ await d.exec("INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) VALUES (?, ?, ?)", [...]);
129
+ // 保留 db.exec:INSERT OR IGNORE ... SELECT 构造器无法表达
130
+ await d.exec("INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) SELECT id, ?, ? FROM notifications_broadcast", [...]);
131
+ ```
132
+
133
+ `INSERT OR IGNORE` 是 sqlite 方言;mysql 对应 `INSERT … ON DUPLICATE KEY IGNORE`/忽略,postgres 对应 `ON CONFLICT DO NOTHING`。
134
+ 构造器当前 `insert()` 无 `orIgnore()` 修饰,也无 `fromSelect()`。
135
+
136
+ **提议 API**:
137
+
138
+ ```ts
139
+ db.table("notifications_broadcast_read")
140
+ .insert({ broadcast_id, user_id, read_time })
141
+ .orIgnore() // 引擎按方言转写:sqlite OR IGNORE / pg ON CONFLICT DO NOTHING / mysql 忽略重复
142
+ .run();
143
+
144
+ db.table("notifications_broadcast_read")
145
+ .insert(["broadcast_id", "user_id", "read_time"])
146
+ .fromSelect(db.table("notifications_broadcast").select(["id", lit(uid), lit(now)]))
147
+ .orIgnore()
148
+ .run();
149
+ ```
150
+
151
+ ---
152
+
153
+ ### 3.5 全表 delete 强制 where 无豁免(P2)
154
+
155
+ **阻塞的真实代码**(`rate.ts:78`,登录限流重置——有意清空整张表):
156
+
157
+ ```ts
158
+ await db.exec("delete from sys_login_throttle", []);
159
+ ```
160
+
161
+ 构造器在运行时抛 `"update requires where"`(delete 同理强制 where)。清空整表是合法运维动作,需显式豁免。
162
+
163
+ **提议 API**:
164
+
165
+ ```ts
166
+ db.table("sys_login_throttle").delete().where({ __all: true }).run();
167
+ // 或显式逃逸:.delete().run({ force: true })
168
+ ```
169
+
170
+ 命名上建议用**显眼且不可误用**的写法规避"忘了加 where 误清表",例如要求 `where({ __all: true })` 或 `deleteAll()`。
171
+
172
+ ---
173
+
174
+ ### 3.6 动态 WHERE 组合无安全出口(P2)
175
+
176
+ **阻塞的真实代码**(`demo/todos/api.ts:12`、`uninstall.ts:166`):
177
+
178
+ ```ts
179
+ // 保留 db.query:WHERE 由 JS 字符串拼接动态生成(规则5)
180
+ // 保留 db.query:IN 占位符经 JS 字符串拼接动态生成(动态 SQL),构造器 in 仅接受静态数组值
181
+ ```
182
+
183
+ 运行时按可选过滤参数拼接 WHERE(如"给了 status 才加 `status = ?`"、动态 IN 长度)超出了构造器静态 `where(...)` 树。
184
+
185
+ **提议 API**(安全逃逸舱,而非放开字符串拼接):
186
+
187
+ ```ts
188
+ db.table("todos")
189
+ .where(sql`status = ? AND owner = ?`, [status, owner]) // 类型安全参数化片段
190
+ .all();
191
+ // 或 builder 拼接:.whereIf(cond, c => c.eq("status", v))
192
+ ```
193
+
194
+ > 反对直接暴露 `where(rawString, params)` 给业务层——保留给平台内部/高级场景,避免回到拼接 SQL 的老路。
195
+
196
+ ---
197
+
198
+ ### 3.7 `select *` 无整行读取(P3)
199
+
200
+ **阻塞的真实代码**(`session.ts:114-115`):
201
+
202
+ ```ts
203
+ // 保留 db.query:select * 无已知列清单(规则6)
204
+ const rows = await db.query("select * from sys_refresh_token where token_hash = ?", [hash]) …;
205
+ ```
206
+
207
+ **提议 API**:
208
+
209
+ ```ts
210
+ db.table("sys_refresh_token").select("*").where({ field: "token_hash", op: "eq", value: hash }).all();
211
+ // 或省略 select,默认整行:.all()
212
+ ```
213
+
214
+ ---
215
+
216
+ ## 4. 不在本文范围
217
+
218
+ - **方言无关的表达式求值**:计算列目前建议以 `expr` 字符串透传(由引擎保证参数化安全),不要求构造器解析表达式语义。
219
+ - **存储过程 / 批处理 / 事务编排**:与可移植性目标无关。
220
+ - **`db.query` 下架**:补齐上述能力后模板侧可全量迁移,下架是后续动作,不在本 PR 提案内。
221
+
222
+ ---
223
+
224
+ ## 5. 验收建议(上游实现后模板侧可关闭的回退点)
225
+
226
+ 1. `contacts.ts:20` 模糊搜索 → 改用 `like` + `escape`。
227
+ 2. `solution-perms.ts:100` / `uninstall.ts:76/82/116` 目录与权限 LIKE → 同上。
228
+ 3. `notify.ts:135-167` 通知合并 → `leftJoin` 值条件 + `expr` 列。
229
+ 4. `notify.ts:167-188` 已读回执 → `orIgnore()` / `fromSelect()`。
230
+ 5. `rate.ts:78` 限流重置 → `delete().where({ __all: true })`。
231
+ 6. `demo/todos/api.ts:12` / `uninstall.ts:166` → 动态 WHERE 安全出口。
232
+ 7. `session.ts:114-115` → `select("*")`。
233
+
234
+ 全部关闭后,模板 `api/src` 内 `保留 db.query` / `保留 d.exec` 注释应归零,达成"零原生 SQL 可移植"目标。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.5",
4
+ "version": "0.2.8",
5
5
  "description": "React Antd Admin 模块工程命令行工具(含预构建宿主 shell-dist)",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -49,7 +49,7 @@
49
49
  ],
50
50
  "dependencies": {
51
51
  "@faker-js/faker": "^10.4.0",
52
- "@oj-module/runtime": "0.2.5",
52
+ "@oj-module/runtime": "0.2.8",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "esbuild": "^0.28.2",
@@ -256,10 +256,10 @@ function getAppInfo() {
256
256
  return {
257
257
  "pkg": {
258
258
  "name": "@oj-module/runtime",
259
- "version": "0.2.5",
259
+ "version": "0.2.8",
260
260
  "license": "MIT"
261
261
  },
262
- "lastBuildTime": "2026-10-19 10:55:08"
262
+ "lastBuildTime": "2026-10-20 00:16:33"
263
263
  };
264
264
  }
265
265
  var init_get_app_info = __esmMin((() => {}));
@@ -5,9 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" href="/favicon.ico" />
7
7
  <title>React Antd Admin · Shell</title>
8
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-O1CJWdo1CXHlXEClzw+t4w=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-GbU8577c6LFtg4pmT1NjGA=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
9
9
 
10
- <script type="importmap" nonce="O1CJWdo1CXHlXEClzw+t4w==">{
10
+ <script type="importmap" nonce="GbU8577c6LFtg4pmT1NjGA==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -7,9 +7,9 @@
7
7
  "react-router": "7.18.2",
8
8
  "react-router/dom": "7.18.2",
9
9
  "@tanstack/react-query": "5.102.8",
10
- "@oj-module/runtime": "0.2.5",
11
- "@oj-module/runtime/contract": "0.2.5",
12
- "@oj-module/runtime/contract/errors": "0.2.5",
10
+ "@oj-module/runtime": "0.2.8",
11
+ "@oj-module/runtime/contract": "0.2.8",
12
+ "@oj-module/runtime/contract/errors": "0.2.8",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -51,6 +51,35 @@ export default createMarketApi({} as Env);
51
51
  ]);
52
52
  });
53
53
 
54
+ it("标识符默认导出指回工厂调用:const x = createXxx(env); export default x(market/api.ts 真形态)", () => {
55
+ // 真实模板形态:工厂调用赋给 const,再 export default 该标识符。
56
+ // 此前 scanHandlerFile 只认「default 导出 = 调用表达式」,认不出
57
+ // 「default 导出 = 标识符 → 指向一次工厂调用」,导致 market 整族 route 漏登。
58
+ const rows = scan("api.ts", `
59
+ import type { Env } from "./types";
60
+ export function createMarketApi(env: Env) {
61
+ return {
62
+ "install": {
63
+ async post() { return {}; },
64
+ },
65
+ "verify": {
66
+ async post() { return {}; },
67
+ last: {
68
+ async get() { return {}; },
69
+ },
70
+ },
71
+ };
72
+ }
73
+ const marketApi = createMarketApi({} as Env);
74
+ export default marketApi;
75
+ `);
76
+ expect(rows.map(r => `${r.method} ${r.full}`).sort()).toEqual([
77
+ "GET _platform/market/verify/last",
78
+ "POST _platform/market/install",
79
+ "POST _platform/market/verify",
80
+ ]);
81
+ });
82
+
54
83
  it("无 return 对象 / 未知 callee / 点分 callee 不可解析 → 零行(不炸不猜)", () => {
55
84
  expect(scan("a.ts", "export default createMissingApi({});")).toEqual([]);
56
85
  expect(scan("b.ts", `
@@ -0,0 +1,167 @@
1
+ import type { ScopedRequestLike } from "../../../templates/web/src/_platform/__client/api";
2
+
3
+ // 集成回归:真实 httpMarketClient(统一后委托 ojm 生成 client)的委托行为。
4
+ // 经 bindRequest 注入 mock request,验证每条 MarketClient 方法 → 正确 URL/body/query + 返回形状;
5
+ // 并验证 index 的 503「未配置市场地址」→ [] 空态(PRD §5.3)。
6
+ //
7
+ // 不加载 @oj-module/runtime 重依赖(antd / pro-components 在 node 环境无法 import),仅桩 request、
8
+ // 透传真实 zod(供生成 client 的 DEV 响应校验走真实路径)。ContractApiError 由轻量子路径提供。
9
+ import { beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { bindRequest } from "../../../templates/web/src/_platform/__client/api";
11
+ import { httpMarketClient } from "../../../templates/web/src/oj-market/api/defaultClient";
12
+
13
+ // index() 直连 request(不走生成 client),故把桩 request 做成可变实现,测试内统一指向同一 mock。
14
+ const { requestImpl } = vi.hoisted(() => ({
15
+ requestImpl: {
16
+ get: () => { throw new Error("placeholder"); },
17
+ post: () => { throw new Error("placeholder"); },
18
+ put: () => { throw new Error("placeholder"); },
19
+ delete: () => { throw new Error("placeholder"); },
20
+ patch: () => { throw new Error("placeholder"); },
21
+ head: () => { throw new Error("placeholder"); },
22
+ },
23
+ }));
24
+
25
+ vi.mock("@oj-module/runtime", async () => {
26
+ const { z } = await import("zod");
27
+ return {
28
+ z,
29
+ // defaultClient 顶层 bindRequest(request) 用到;测试内立即被 mock 覆盖,不会真发请求。
30
+ request: requestImpl,
31
+ };
32
+ });
33
+
34
+ interface Call {
35
+ method: string
36
+ url: string
37
+ opts?: { searchParams?: Record<string, string | number | boolean>, json?: unknown }
38
+ }
39
+
40
+ function mockRequest(responder: (c: Call) => { status?: number, data?: unknown }) {
41
+ const calls: Call[] = [];
42
+ const make = (method: string) => (url: string, opts?: Call["opts"]) => {
43
+ calls.push({ method, url, opts });
44
+ const r = responder({ method, url, opts });
45
+ if (r.status && r.status >= 400) {
46
+ const err: { response?: { status?: number } } = new Error(`HTTP ${r.status}`);
47
+ err.response = { status: r.status };
48
+ throw err;
49
+ }
50
+ return { json: () => Promise.resolve({ code: 0, data: r.data ?? null }) };
51
+ };
52
+ const req = {
53
+ get: make("get"),
54
+ post: make("post"),
55
+ put: make("put"),
56
+ delete: make("delete"),
57
+ patch: make("patch"),
58
+ head: make("head"),
59
+ } as unknown as ScopedRequestLike;
60
+ return { calls, req };
61
+ }
62
+
63
+ // 让 index() 直连的 request 与生成 client 的 bindRequest 指向同一 mock,行为一致。
64
+ function useMock(m: ReturnType<typeof mockRequest>) {
65
+ bindRequest(m.req);
66
+ requestImpl.get = m.req.get;
67
+ requestImpl.post = m.req.post;
68
+ requestImpl.put = m.req.put;
69
+ requestImpl.delete = m.req.delete;
70
+ requestImpl.patch = m.req.patch;
71
+ requestImpl.head = m.req.head;
72
+ }
73
+
74
+ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
75
+ let mock: ReturnType<typeof mockRequest>;
76
+
77
+ beforeEach(() => {
78
+ mock = mockRequest(() => ({ data: {} }));
79
+ useMock(mock);
80
+ });
81
+
82
+ it("meta() → GET _platform/market/meta,返回 { apiVersion }", async () => {
83
+ mock = mockRequest(() => ({ data: { apiVersion: "1.2.3" } }));
84
+ useMock(mock);
85
+ const r = await httpMarketClient.meta();
86
+ expect(mock.calls).toEqual([{ method: "get", url: "_platform/market/meta", opts: undefined }]);
87
+ expect(r).toEqual({ apiVersion: "1.2.3" });
88
+ });
89
+
90
+ it("index() → GET _platform/market/index,返回 IndexRow[]", async () => {
91
+ const rows = [{ name: "a", latest: "1.0", installed: "1.0", enabled: true, upgradable: false }];
92
+ mock = mockRequest(() => ({ data: rows }));
93
+ useMock(mock);
94
+ const r = await httpMarketClient.index();
95
+ expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/index" });
96
+ expect(r).toEqual(rows);
97
+ });
98
+
99
+ it("index() 遇 503(未配置市场地址)→ [] 空态,不抛(PRD §5.3)", async () => {
100
+ mock = mockRequest(c => (c.url === "_platform/market/index" ? { status: 503 } : { data: {} }));
101
+ useMock(mock);
102
+ const r = await httpMarketClient.index();
103
+ expect(r).toEqual([]);
104
+ });
105
+
106
+ it("install(input) → POST _platform/market/install,body 含 name/version/source", async () => {
107
+ mock = mockRequest(() => ({ data: { task_id: "t-1" } }));
108
+ useMock(mock);
109
+ const r = await httpMarketClient.install({ name: "alpha", version: "2.0", source: "file" });
110
+ expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/install" });
111
+ expect(mock.calls[0].opts?.json).toEqual({ name: "alpha", version: "2.0", source: "file" });
112
+ expect(r).toEqual({ task_id: "t-1" });
113
+ });
114
+
115
+ it("install(input) 缺省 version/source → null / cdn 兜底", async () => {
116
+ mock = mockRequest(() => ({ data: { task_id: "t-2" } }));
117
+ useMock(mock);
118
+ await httpMarketClient.install({ name: "beta" });
119
+ expect(mock.calls[0].opts?.json).toEqual({ name: "beta", version: null, source: "cdn" });
120
+ });
121
+
122
+ it("task(id) → GET _platform/market/task? 带 id", async () => {
123
+ const rec = { id: "t-1", name: "a", version: "1.0", status: "done", stage: "done", progress: 100, message: "ok", stages: [], startedAt: 1 };
124
+ mock = mockRequest(() => ({ data: rec }));
125
+ useMock(mock);
126
+ const r = await httpMarketClient.task("t-1");
127
+ expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/task" });
128
+ expect(mock.calls[0].opts?.searchParams).toEqual({ id: "t-1" });
129
+ expect(r).toEqual(rec);
130
+ });
131
+
132
+ it("enable(input) → POST _platform/market/enable,body {name,enabled}", async () => {
133
+ mock = mockRequest(() => ({ data: { ok: true, message: "done" } }));
134
+ useMock(mock);
135
+ const r = await httpMarketClient.enable({ name: "a", enabled: false });
136
+ expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/enable" });
137
+ expect(mock.calls[0].opts?.json).toEqual({ name: "a", enabled: false });
138
+ expect(r).toEqual({ ok: true, message: "done" });
139
+ });
140
+
141
+ it("history() → GET _platform/market/history,返回 MarketHistory[]", async () => {
142
+ const hist = [{ id: 1, solution: "a", version: "1.0", action: "install", status: "ok", operator: null, message: null, created_at: 1 }];
143
+ mock = mockRequest(() => ({ data: hist }));
144
+ useMock(mock);
145
+ const r = await httpMarketClient.history();
146
+ expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/history" });
147
+ expect(r).toEqual(hist);
148
+ });
149
+
150
+ it("uninstallImpact(name) → GET _platform/market/uninstall-impact? 带 name", async () => {
151
+ mock = mockRequest(() => ({ data: { name: "a", affectedRoles: 2, affectedUsers: 5 } }));
152
+ useMock(mock);
153
+ const r = await httpMarketClient.uninstallImpact("a");
154
+ expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/uninstall-impact" });
155
+ expect(mock.calls[0].opts?.searchParams).toEqual({ name: "a" });
156
+ expect(r).toEqual({ name: "a", affectedRoles: 2, affectedUsers: 5 });
157
+ });
158
+
159
+ it("uninstall(name) → POST _platform/market/uninstall,body {name}", async () => {
160
+ mock = mockRequest(() => ({ data: { ok: true, message: "removed" } }));
161
+ useMock(mock);
162
+ const r = await httpMarketClient.uninstall("a");
163
+ expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/uninstall" });
164
+ expect(mock.calls[0].opts?.json).toEqual({ name: "a" });
165
+ expect(r).toEqual({ ok: true, message: "removed" });
166
+ });
167
+ });
@@ -94,6 +94,20 @@ function collectObjectDecls(sf: ts.SourceFile): Map<string, ts.ObjectLiteralExpr
94
94
  return decls;
95
95
  }
96
96
 
97
+ /** 顶层 `const x = createXxx(env)` 调用声明表:工厂形态经标识符引用(`export default x`)时的解析依据 */
98
+ function collectCallDecls(sf: ts.SourceFile): Map<string, ts.CallExpression> {
99
+ const decls = new Map<string, ts.CallExpression>();
100
+ for (const stmt of sf.statements) {
101
+ if (!ts.isVariableStatement(stmt))
102
+ continue;
103
+ for (const decl of stmt.declarationList.declarations) {
104
+ if (ts.isIdentifier(decl.name) && decl.initializer && ts.isCallExpression(decl.initializer))
105
+ decls.set(decl.name.text, decl.initializer);
106
+ }
107
+ }
108
+ return decls;
109
+ }
110
+
97
111
  /** verb 键 → 实现函数名(供 `.route` 语句按函数名查找;内联写法回落键名) */
98
112
  function implNameOf(prop: ts.ObjectLiteralElementLike): string | undefined {
99
113
  if (ts.isShorthandPropertyAssignment(prop))
@@ -141,9 +155,16 @@ function defaultExportObject(sf: ts.SourceFile, decls: Map<string, ts.ObjectLite
141
155
  * 是调用表达式,此前扫描器不认,route-unregistered 守卫对 market/solutions 全族失效):
142
156
  * 解析同文件同名函数声明体内 return 的对象字面量(运行时 oj 内省执行工厂拿到该对象,静态
143
157
  * 扫描镜像此语义)。支持多个 return;对象内标识符引用仍解析文件级顶层 const。
158
+ * 补全(P2-14 形态遗漏):`export default createXxxApi(marketEnv())` 中 `call.expression`
159
+ * 是**带实参的调用表达式**,函数名落在 `call.expression.expression`(createXxxApi);
160
+ * 旧逻辑直接 dottedNameOf(call.expression) 对 CallExpression 返回 undefined → 扫成 0 路由,
161
+ * 致使 ojm api --check 把 market 全族端点判为「未实现 / stub 缺失」。此处穿透一层取被调函数名。
144
162
  */
145
163
  function factoryReturnObjects(sf: ts.SourceFile, call: ts.CallExpression): ts.ObjectLiteralExpression[] {
146
- const callee = dottedNameOf(call.expression);
164
+ // `export default createXxxApi(marketEnv())`:`call.expression` 为带实参的调用表达式,
165
+ // 函数名在其 `.expression`;`export default obj.createXxxApi(marketEnv())` 同样穿透。
166
+ const target = ts.isCallExpression(call.expression) ? call.expression.expression : call.expression;
167
+ const callee = dottedNameOf(target);
147
168
  if (callee === undefined)
148
169
  return [];
149
170
  for (const stmt of sf.statements) {
@@ -189,6 +210,7 @@ export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow
189
210
  const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
190
211
  const routes = collectRouteDecls(sf);
191
212
  const decls = collectObjectDecls(sf);
213
+ const callDecls = collectCallDecls(sf);
192
214
  const relDir = relative(apiSrcDir, dirname(filePath)).split(sep).join("/");
193
215
  const rows: HandlerRow[] = [];
194
216
  const seen = new Set<ts.Node>();
@@ -251,10 +273,19 @@ export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow
251
273
  walk(root, "", filePrefix, 1);
252
274
  }
253
275
  else {
254
- // P2-14:工厂形态(CallExpression)——组前缀从目录镜像起算(同内联字面量形态)
276
+ // 工厂形态(P2-14 + 补全):export default 后接
277
+ // - CallExpression 直调:createXxx(env) / createXxx(marketEnv())
278
+ // - Identifier 引用:const api = createXxx(env); export default api
279
+ // 组前缀从目录镜像起算(同内联字面量形态)。
255
280
  for (const stmt of sf.statements) {
256
- if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isCallExpression(stmt.expression)) {
257
- for (const obj of factoryReturnObjects(sf, stmt.expression))
281
+ if (!ts.isExportAssignment(stmt) || stmt.isExportEquals)
282
+ continue;
283
+ const expr = stmt.expression;
284
+ const callExpr = ts.isCallExpression(expr)
285
+ ? expr
286
+ : (ts.isIdentifier(expr) ? callDecls.get(expr.text) : undefined);
287
+ if (callExpr) {
288
+ for (const obj of factoryReturnObjects(sf, callExpr))
258
289
  walk(obj, "", "", 1);
259
290
  }
260
291
  }