@oj-module/cli 0.2.8 → 0.2.14

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 (98) hide show
  1. package/devkit/README.md +70 -24
  2. package/devkit/framework-dev.md +14 -5
  3. package/devkit/manual.md +9 -6
  4. package/devkit/module-dev-and-cdn-playground.md +197 -0
  5. package/devkit/sql-builder-missing-capabilities-pr.md +38 -4
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +130 -45
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/build-backend.test.ts +64 -0
  11. package/src/__tests__/versions.test.ts +26 -0
  12. package/src/build.ts +9 -0
  13. package/src/bundle.ts +8 -5
  14. package/src/contract/__tests__/market-client.integration.test.ts +13 -10
  15. package/src/index.ts +6 -0
  16. package/src/init.ts +22 -0
  17. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  18. package/src/market/__tests__/bootstrap.test.ts +204 -0
  19. package/src/market/__tests__/build-index.test.ts +154 -0
  20. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  21. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  22. package/src/market/__tests__/fixture.test.ts +48 -0
  23. package/src/market/__tests__/publish.test.ts +109 -0
  24. package/src/market/__tests__/report.test.ts +51 -0
  25. package/src/market/__tests__/setup.test.ts +114 -0
  26. package/src/market/__tests__/verify.test.ts +111 -0
  27. package/src/market/bootstrap.ts +255 -0
  28. package/src/market/build-index.ts +159 -0
  29. package/src/market/cdn-layout.ts +122 -0
  30. package/src/market/command.ts +139 -0
  31. package/src/market/failure-playbook.ts +57 -0
  32. package/src/market/fixture.ts +138 -0
  33. package/src/market/index-builder.ts +76 -0
  34. package/src/market/publish.ts +156 -0
  35. package/src/market/report.ts +67 -0
  36. package/src/market/scan.ts +99 -0
  37. package/src/market/setup.ts +144 -0
  38. package/src/market/trial.ts +121 -0
  39. package/src/market/verify.ts +298 -0
  40. package/src/market/walkthrough.ts +506 -0
  41. package/src/oj.ts +3 -1
  42. package/src/preview-config.ts +14 -0
  43. package/src/preview.ts +4 -3
  44. package/src/sign.ts +21 -6
  45. package/src/usage.ts +19 -0
  46. package/src/vendor.ts +4 -2
  47. package/src/versions.ts +28 -0
  48. package/templates/api/.ojm-api-exempt.json +6 -2
  49. package/templates/api/config.yaml +20 -0
  50. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  51. package/templates/api/src/_platform/_lifecycle/uninstall.ts +21 -53
  52. package/templates/api/src/_platform/_shared/service/domain/contacts.ts +15 -10
  53. package/templates/api/src/_platform/_shared/service/domain/solution-perms.ts +8 -6
  54. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +16 -23
  55. package/templates/api/src/_platform/_shared/service/infra/query.ts +18 -0
  56. package/templates/api/src/_platform/_shared/service/infra/rate.ts +4 -1
  57. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  58. package/templates/api/src/_platform/manifest.yaml +1 -0
  59. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  60. package/templates/api/src/_platform/schema.yaml +12 -0
  61. package/templates/api/src/auth/_shared/session.ts +2 -2
  62. package/templates/api/src/demo/todos/api.ts +8 -9
  63. package/templates/api/src/notification/_shared/notify.ts +64 -26
  64. package/templates/api/src/notification/api.ts +1 -1
  65. package/templates/api/src/notification/manifest.yaml +1 -0
  66. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  67. package/templates/api/src/notification/schema.yaml +7 -0
  68. package/templates/api/src/notification/seed.sql +5 -4
  69. package/templates/api/src/personal-center/profile/api.ts +63 -0
  70. package/templates/api/src/web/user-info/api.ts +3 -1
  71. package/templates/api/tests/fixture.ts +8 -16
  72. package/templates/api/tests/l1-builder.test.ts +91 -0
  73. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  74. package/templates/api/tests/lifecycle/market-api.spec.ts +1 -3
  75. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  76. package/templates/api/tests/lifecycle/uninstall.spec.ts +28 -88
  77. package/templates/api/tests/query-utils.spec.ts +85 -1
  78. package/templates/api/tests/rbac-settings.spec.ts +64 -19
  79. package/templates/cdn/README.md +48 -0
  80. package/templates/global.d.ts +36 -2
  81. package/templates/init.include.yaml +3 -2
  82. package/templates/openapi.json +55 -10
  83. package/templates/web/src/notification/entry.ts +46 -9
  84. package/templates/web/src/notification/locales/en-US.json +19 -0
  85. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  86. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  87. package/templates/web/src/oj-market/entry.ts +8 -2
  88. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  89. package/templates/web/src/personal-center/__client/api.ts +25 -1
  90. package/templates/web/src/personal-center/entry.ts +5 -0
  91. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  92. package/templates/web/src/system/entry.ts +2 -1
  93. package/templates/web.config.ts +10 -7
  94. package/vendor/host-versions.json +3 -3
  95. package/templates/api/src/notifications/api.ts +0 -29
  96. package/templates/api/src/notifications/manifest.yaml +0 -5
  97. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  98. package/templates/web/src/notifications/__client/api.ts +0 -80
@@ -69,12 +69,17 @@ function splitId(id: string): [scope: "u" | "b", raw: string] {
69
69
  function mapItem(r: Row): NoticeItem {
70
70
  const scope = String(r.scope);
71
71
  const isBroadcast = scope === "broadcast";
72
+ // UNION 结果列名取首分支(userQ)=> 位置 4 名为 is_read。按人项此处即存储整数列;
73
+ // 广播项此处实际承载回执 read_time(非空即已读,与原始 SQL 的 (read_time IS NOT NULL AND <> '') 等价)。
74
+ const isRead = isBroadcast
75
+ ? (r.is_read != null && String(r.is_read) !== "")
76
+ : Number(r.is_read) === 1;
72
77
  return {
73
78
  id: (isBroadcast ? "b:" : "u:") + String(r.id),
74
79
  scope: isBroadcast ? "broadcast" : "user",
75
80
  avatar: String(r.avatar ?? ""),
76
81
  date: String(r.date ?? ""),
77
- isRead: Number(r.is_read) === 1,
82
+ isRead,
78
83
  message: String(r.message ?? ""),
79
84
  title: String(r.title ?? ""),
80
85
  link: r.link == null ? undefined : String(r.link),
@@ -112,17 +117,17 @@ export async function pushBroadcastNotice(input: PushBroadcastInput): Promise<st
112
117
  }
113
118
 
114
119
  /**
115
- * 删除通知。按人项校验归属:非本人返回 false;广播项仅删本人回执(幂等,返回 true)。
120
+ * 删除通知。按人项校验归属:非本人返回 false;广播项是共享行,物理删除会影响
121
+ * 其他收件人——落「对我隐藏」标记(幂等,返回 true),列表查询过滤 hidden 命中项。
116
122
  * 返回是否成功操作到目标(调用方据 false 判 403)。
117
123
  */
118
124
  export async function delNotice(id: string, userId: string | number): Promise<boolean> {
119
125
  const [scope, raw] = splitId(id);
120
126
  const uid = Number(userId);
121
127
  if (scope === "b") {
122
- await db.table("notifications_broadcast_read")
123
- .where({ field: "broadcast_id", op: "eq", value: Number(raw) })
124
- .where({ field: "user_id", op: "eq", value: uid })
125
- .delete()
128
+ await db.table("notifications_broadcast_hidden")
129
+ .insert({ broadcast_id: Number(raw), user_id: uid, create_time: nowStr() })
130
+ .orIgnore()
126
131
  .run();
127
132
  return true;
128
133
  }
@@ -142,18 +147,43 @@ export async function noticesFor(
142
147
  const limit = opts.limit ?? 50;
143
148
  const offset = opts.offset ?? 0;
144
149
  const uid = Number(userId);
145
- const rows = await db.query(
146
- `SELECT id, avatar, date, is_read, message, title, link, category, 'user' as scope
147
- FROM notifications WHERE user_id = ?
148
- UNION ALL
149
- SELECT b.id, b.avatar, b.date, (r.read_time IS NOT NULL AND r.read_time <> '') as is_read,
150
- b.message, b.title, b.link, b.category, 'broadcast' as scope
151
- FROM notifications_broadcast b
152
- LEFT JOIN notifications_broadcast_read r ON r.broadcast_id = b.id AND r.user_id = ?
153
- ORDER BY date DESC
154
- LIMIT ? OFFSET ?`,
155
- [uid, uid, limit, offset],
156
- );
150
+ // 构造器(v0.1.81):UNION ALL 合并「按人」与「广播(LEFT JOIN 回执)」;lit 打固定 scope 标记列,
151
+ // 顶层 orderBy/limit/offset 跨方言一致。
152
+ // 注意:UNION 结果列名取首分支(userQ),故两分支位置对齐即可——广播第 4 列取回执 read_time,
153
+ // 经 union 落到 is_read 列名下,是否「已读」由 mapItem 据非空推导(与原始 SQL 同源)。
154
+ const userQ = db.table("notifications").select([
155
+ "id",
156
+ "avatar",
157
+ "date",
158
+ "is_read",
159
+ "message",
160
+ "title",
161
+ "link",
162
+ "category",
163
+ { lit: "user", as: "scope" },
164
+ ]).where({ field: "user_id", op: "eq", value: uid });
165
+ const broadcastQ = db.table("notifications_broadcast").as("b").join("notifications_broadcast_read", [
166
+ { left: "b.id", right: "r.broadcast_id" },
167
+ { left: "r.user_id", op: "eq", value: uid },
168
+ ], "left", "r").join("notifications_broadcast_hidden", [
169
+ { left: "b.id", right: "h.broadcast_id" },
170
+ { left: "h.user_id", op: "eq", value: uid },
171
+ ], "left", "h").where({ field: "h.id", op: "isnull" }).select([
172
+ "b.id",
173
+ "b.avatar",
174
+ "b.date",
175
+ "r.read_time",
176
+ "b.message",
177
+ "b.title",
178
+ "b.link",
179
+ "b.category",
180
+ { lit: "broadcast", as: "scope" },
181
+ ]);
182
+ const rows = await userQ.union(broadcastQ, "all")
183
+ .orderBy([{ field: "date", dir: "desc" }])
184
+ .limit(limit)
185
+ .offset(offset)
186
+ .all();
157
187
  return rows.map(mapItem);
158
188
  }
159
189
 
@@ -166,11 +196,11 @@ export async function markRead(id: string, userId: string | number): Promise<boo
166
196
  const uid = Number(userId);
167
197
  const now = nowStr();
168
198
  if (scope === "b") {
169
- // 保留 db.exec:INSERT OR IGNORE 构造器无等价(引擎按方言改写 IGNORE)
170
- await db.exec(
171
- "INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) VALUES (?, ?, ?)",
172
- [Number(raw), uid, now],
173
- );
199
+ // 构造器(v0.1.81):orIgnore 幂等写入(sqlite/pg → ON CONFLICT DO NOTHING;mysql → ON DUPLICATE KEY UPDATE pk=pk)。
200
+ await db.table("notifications_broadcast_read")
201
+ .insert({ broadcast_id: Number(raw), user_id: uid, read_time: now })
202
+ .orIgnore()
203
+ .run();
174
204
  return true;
175
205
  }
176
206
  const n = await db.table("notifications")
@@ -186,16 +216,24 @@ export async function markAllRead(userId: string | number): Promise<void> {
186
216
  const uid = Number(userId);
187
217
  const now = nowStr();
188
218
  await db.table("notifications").where({ field: "user_id", op: "eq", value: uid }).update({ is_read: 1 }).run();
189
- // 保留 db.exec:INSERT OR IGNORE ... SELECT 构造器无法表达
219
+ // 保留 db.exec:INSERT OR IGNORE ... SELECT 在 sqlite 上构造器无法表达——
220
+ // oj 0.1.81 的 orIgnore 仅跟随 VALUES 的 upsert 子句,不与 fromSelect(SELECT 源) 组合
221
+ // (实测:code 500 "orIgnore with fromSelect is not supported on sqlite")。原 SQL 三方言通用
222
+ // (sqlite OR IGNORE / pg ON CONFLICT DO NOTHING / mysql ON DUPLICATE KEY),退回裸 SQL。
190
223
  await db.exec(
191
224
  "INSERT OR IGNORE INTO notifications_broadcast_read (broadcast_id, user_id, read_time) SELECT id, ?, ? FROM notifications_broadcast",
192
225
  [uid, now],
193
226
  );
194
227
  }
195
228
 
196
- /** 清空:删按人项 + 删本人广播回执(列表消失) */
229
+ /** 清空:删按人项 + 广播逐条落「对我隐藏」标记(共享行不可物理删,他人不受影响) */
197
230
  export async function clearNotices(userId: string | number): Promise<void> {
198
231
  const uid = Number(userId);
199
232
  await db.table("notifications").where({ field: "user_id", op: "eq", value: uid }).delete().run();
200
- await db.table("notifications_broadcast_read").where({ field: "user_id", op: "eq", value: uid }).delete().run();
233
+ // 保留 db.exec:INSERT OR IGNORE ... SELECT 同 markAllRead(构造器 orIgnore 不与
234
+ // fromSelect 组合,实测 500),原 SQL 三方言通用。
235
+ await db.exec(
236
+ "INSERT OR IGNORE INTO notifications_broadcast_hidden (broadcast_id, user_id, create_time) SELECT id, ?, ? FROM notifications_broadcast",
237
+ [uid, nowStr()],
238
+ );
201
239
  }
@@ -72,7 +72,7 @@ const notifications = {
72
72
  },
73
73
  },
74
74
  // POST /api/notification/notifications/clear —— 清空通知(按当前用户)。
75
- // 删按人项 + 删本人广播回执(列表消失)。
75
+ // 删按人项 + 广播逐条落「对我隐藏」标记(共享行不可物理删,他人不受影响)。
76
76
  "clear": {
77
77
  async post() {
78
78
  const uid = http.user?.id;
@@ -5,3 +5,4 @@ tables:
5
5
  - notifications
6
6
  - notifications_broadcast
7
7
  - notifications_broadcast_read
8
+ - notifications_broadcast_hidden
@@ -0,0 +1,12 @@
1
+ -- 广播隐藏标记表(按人):清空/单条删除广播项时落标记——广播本体是共享行,
2
+ -- 任何人不得物理删除(会影响其他收件人),只能「对我隐藏」。
3
+ -- 清空 = 按人项物理删 + 广播逐条落 hidden 标记;列表查询过滤 hidden 命中项。
4
+ -- 三方言可移植:建表 + 索引均为标准 DDL。
5
+ CREATE TABLE IF NOT EXISTS notifications_broadcast_hidden (
6
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ broadcast_id INTEGER NOT NULL,
8
+ user_id INTEGER NOT NULL,
9
+ create_time TEXT NOT NULL DEFAULT ''
10
+ );
11
+
12
+ CREATE INDEX IF NOT EXISTS idx_broadcast_hidden ON notifications_broadcast_hidden (user_id, broadcast_id);
@@ -31,3 +31,10 @@ tables:
31
31
  broadcast_id: {type: integer, null: false}
32
32
  user_id: {type: integer, null: false}
33
33
  read_time: {type: text, null: false}
34
+ notifications_broadcast_hidden:
35
+ pk: id
36
+ columns:
37
+ id: {type: integer, autoincrement: true}
38
+ broadcast_id: {type: integer, null: false}
39
+ user_id: {type: integer, null: false}
40
+ create_time: {type: text, null: false}
@@ -1,6 +1,7 @@
1
1
  -- notification 模块种子(按人真实通知)。user_id=1(admin),link/category 幂等可空/默认。
2
+ -- avatar 给真实 URL:铃铛按 avatar 渲染头像,空串会破图(空串仅作为运行时兜底,不作为种子值)。
2
3
  -- 按分号朴素切分(S006):语句内不得含分号字面量。
3
- INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (1, 1, '', '2026-09-01 09:12', 0, '脚手架已就绪,用 admin / 123456 登录开始', '欢迎', NULL, 'notice');
4
- INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (2, 1, '', '2026-09-02 14:30', 0, '通知铃支持单条已读、全部已读与清空;广播经 push-broadcast 入 b: 命名空间', '通知铃', NULL, 'notice');
5
- INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (3, 1, '', '2026-09-03 18:45', 1, '契约改动后重跑 ojm api 生成前端 client 与文档', '契约', NULL, 'notice');
6
- INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (4, 1, '', '2026-09-04 08:00', 0, '编辑 api/src 下的 api.ts 保存即热更;handler 组支持四层嵌套(oj v0.1.64)', '开发提示', NULL, 'notice');
4
+ INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (1, 1, 'https://avatar.vercel.sh/seed-welcome.svg?text=W', '2026-09-01 09:12', 0, '脚手架已就绪,用 admin / 123456 登录开始', '欢迎', NULL, 'notice');
5
+ INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (2, 1, 'https://avatar.vercel.sh/seed-bell.svg?text=B', '2026-09-02 14:30', 0, '通知铃支持单条已读、全部已读与清空;广播经 push-broadcast 入 b: 命名空间', '通知铃', NULL, 'notice');
6
+ INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (3, 1, 'https://avatar.vercel.sh/seed-contract.svg?text=C', '2026-09-03 18:45', 1, '契约改动后重跑 ojm api 生成前端 client 与文档', '契约', NULL, 'notice');
7
+ INSERT OR IGNORE INTO notifications (id, user_id, avatar, date, is_read, message, title, link, category) VALUES (4, 1, 'https://avatar.vercel.sh/seed-dev.svg?text=D', '2026-09-04 08:00', 0, '编辑 api/src 下的 api.ts 保存即热更;handler 组支持四层嵌套(oj v0.1.64)', '开发提示', NULL, 'notice');
@@ -0,0 +1,63 @@
1
+ // POST /api/personal-center/profile —— 保存「我的资料」(个人中心表单入库端点)。
2
+ // 只允许改本人行(http.user.id 权威,不接受 body 传 id);username 全局唯一,
3
+ // 重名 400;avatar 走 upload 端点(multipart),不在此通道。
4
+ const handler = {
5
+ async post() {
6
+ const uid = http.user?.id;
7
+ if (!uid) {
8
+ json.fail(401, "unauthorized");
9
+ return;
10
+ }
11
+ const b = (http.body ?? {}) as {
12
+ username?: unknown
13
+ email?: unknown
14
+ phoneNumber?: unknown
15
+ description?: unknown
16
+ };
17
+ const patch: Record<string, string> = {};
18
+ if (b.username != null && String(b.username).trim() !== "") {
19
+ const username = String(b.username).trim();
20
+ const dup = await db.table("users").select(["id"]).where({ field: "username", op: "eq", value: username }).all();
21
+ if (dup.some(r => Number(r.id) !== Number(uid))) {
22
+ json.fail(400, "username already taken");
23
+ return;
24
+ }
25
+ patch.username = username;
26
+ }
27
+ if (b.email != null)
28
+ patch.email = String(b.email);
29
+ if (b.phoneNumber != null)
30
+ patch.phone_number = String(b.phoneNumber);
31
+ if (b.description != null)
32
+ patch.description = String(b.description);
33
+ if (Object.keys(patch).length === 0) {
34
+ json.fail(400, "nothing to update");
35
+ return;
36
+ }
37
+ try {
38
+ await db.table("users").update(patch).where({ field: "id", op: "eq", value: Number(uid) }).run();
39
+ json.ok(null);
40
+ }
41
+ catch (e) {
42
+ json.fail(500, String(e));
43
+ }
44
+ },
45
+ };
46
+
47
+ // 入参:四字段均可选(部分保存允许),类型即字符串。
48
+ handler.post.schema = {
49
+ body: {
50
+ type: "object",
51
+ properties: {
52
+ username: { type: "string" },
53
+ email: { type: "string" },
54
+ phoneNumber: { type: "string" },
55
+ description: { type: "string" },
56
+ },
57
+ },
58
+ };
59
+
60
+ // 出参:无业务载荷(前端保存后经 user-info 重拉刷新 store)。
61
+ handler.post.response = { type: "null" };
62
+
63
+ export default handler;
@@ -10,7 +10,7 @@ export default {
10
10
  return;
11
11
  }
12
12
  try {
13
- const rows = await db.table("users").select(["id", "username", "display_name", "avatar_base64", "email", "must_change"]).where({ field: "id", op: "eq", value: Number(uid) }).all();
13
+ const rows = await db.table("users").select(["id", "username", "display_name", "avatar_base64", "email", "phone_number", "description", "must_change"]).where({ field: "id", op: "eq", value: Number(uid) }).all();
14
14
  const row = rows[0];
15
15
  if (!row) {
16
16
  json.fail(404, "user not found");
@@ -23,6 +23,8 @@ export default {
23
23
  username: String(row.username),
24
24
  displayName: row.display_name == null ? "" : String(row.display_name),
25
25
  email: String(row.email ?? ""),
26
+ phoneNumber: row.phone_number == null ? "" : String(row.phone_number),
27
+ description: row.description == null ? "" : String(row.description),
26
28
  roles: roleRows.map(r => r.role_name),
27
29
  mustChange: Number(row.must_change) === 1,
28
30
  permissions: [],
@@ -1,6 +1,14 @@
1
1
  // 后端 BDD 夹具:node:sqlite 内存库 + **真实迁移文件**建表,装到 globalThis.db——
2
2
  // 与 oj 运行时注入的全局同名同面(query/exec/tx/dialect),service 代码不改一行即可被测。
3
3
  // 夹具直接读 api/src/<模块>/migrations/*.sql,与迁移不可能漂移(沿用 oj-wiki 先例)。
4
+ //
5
+ // **构造器边界(架构定论,勿在此夹具里补 table())**:db.table()/listTables() 等构造器是
6
+ // oj 二进制内的纯 Rust 实现,无 JS 模块可导出,Node 侧垫不出来——本夹具刻意只给
7
+ // {query, exec, tx, dialect}。两层测试的分工因此定型:
8
+ // · L2(本目录 *.spec.ts):构造器面用**记录型假 builder**(记 where/orderBy/limit 调用
9
+ // 序列做编排断言);真 SQL 执行断言走 db.query 直查。
10
+ // · L1(同目录 *.test.ts,`oj test` 收集):真 v8 + 真构造器闭环,守 SQL 渲染/过滤/转义
11
+ // 的真执行面(样板见 tests/l1-builder.test.ts;方法论见 devkit oj-test-guide.md)。
4
12
  import { createRequire } from "node:module";
5
13
 
6
14
  const req = createRequire(import.meta.url);
@@ -13,22 +21,6 @@ const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
13
21
 
14
22
  export const ADMIN = 1;
15
23
 
16
- /**
17
- * market_history 同构 DDL:表真源 = 市场方案包自带迁移(oj-market 0002__market_history),
18
- * 不属平台迁移(installDb 不回放)——需要断言留痕的用例显式执行;守卫语义(表不存在静默跳过)
19
- * 另有专项用例钉住。
20
- */
21
- export const MARKET_HISTORY_DDL = `CREATE TABLE market_history (
22
- id INTEGER PRIMARY KEY AUTOINCREMENT,
23
- solution TEXT NOT NULL,
24
- version TEXT NOT NULL,
25
- action TEXT NOT NULL,
26
- status TEXT NOT NULL,
27
- operator TEXT,
28
- message TEXT,
29
- created_at INTEGER NOT NULL
30
- )`;
31
-
32
24
  /** 建内存库 + 注入全局;返回 DatabaseSync 供用例直插数据/断言。 */
33
25
  export function installDb(): any {
34
26
  const dbo = new DatabaseSync(":memory:");
@@ -0,0 +1,91 @@
1
+ // L1 回归(`oj test` 收集,*.test.ts;与 vitest 的 *.spec.ts 互不干扰)。
2
+ //
3
+ // 为什么有这类文件:SQL 构造器是 oj 二进制内的纯 Rust 实现,vitest 夹具够不着真
4
+ // builder(架构定论:无 JS 模块可导、不写运行时垫片)——模板 tests/*.spec.ts 只能做
5
+ // L2 记录型断言。**真执行面**(SQL 渲染 / 真过滤 / 真转义)在这里经 oj test 闭环:
6
+ // 真 v8 + 真路由/鉴权 + 真迁移/seed + 真 db + 真构造器(方法论见 devkit oj-test-guide.md)。
7
+ //
8
+ // 覆盖 0.1.81/0.1.82 构造器迁移的关键行为:
9
+ // ① settings 真构造器(IN + LIMIT)——vitest 侧同源码 5 个用例红海的对照面;
10
+ // ② demo/todos:whereIf 真跳过(无 keyword 不过滤)+ contains 真渲染(%kw% + 元字符转义);
11
+ // ③ notification:join ON 值条件 + UNION ALL + 字面量列 {lit,as} 的真渲染(场景 41 同款)。
12
+ //
13
+ // 登录账号:fixtures 的 l1tester(src/_platform/fixtures/l1-test-user.sql,仅 oj test 灌入)。
14
+ // 不用 seed 的 admin:其 must_change=1(判权端点恒 403)且初始口令 123456 会随用户
15
+ // 首登改密漂移——L1 绑 admin 曾实测 401。l1tester 为 additive 专用账号(must_change=0)。
16
+ //
17
+ // 运行:cd packages/cli/templates/api && ~/.local/share/oj/oj test -c config.yaml -d src -t tests
18
+
19
+ const L1_USER = { username: "l1tester", password: "l1test1234" };
20
+ const asL1 = async () => ({ Authorization: `Bearer ${await client.login(L1_USER.username, L1_USER.password)}` });
21
+
22
+ describe("l1:0.1.81/0.1.82 构造器迁移真执行面", () => {
23
+ it("匿名 login-methods:真构造器(IN + LIMIT 渲染)返回 200 + 方法数组", async () => {
24
+ const r = await client.get("/system/login-methods");
25
+ expect(r.status).toBe(200);
26
+ const body = JSON.parse(r.body);
27
+ expect(body.code).toBe(0);
28
+ expect(Array.isArray(body.data.methods)).toBeTruthy();
29
+ expect(body.data.methods).toContain("local");
30
+ });
31
+
32
+ it("demo/todos 无 keyword:whereIf 真跳过(不发射条件),全量 4 条", async () => {
33
+ const h = await asL1();
34
+ const r = await client.get("/demo/todos", { headers: h });
35
+ expect(r.status).toBe(200);
36
+ const body = JSON.parse(r.body);
37
+ expect(body.data.total).toBe(4);
38
+ expect(body.data.list.length).toBe(4); // L1 expect 无 toHaveLength(契约见 api-manual §9)
39
+ });
40
+
41
+ it("demo/todos keyword=契约:contains 真渲染(%kw%)精确命中 1 条", async () => {
42
+ const h = await asL1();
43
+ const r = await client.get(`/demo/todos?keyword=${encodeURIComponent("契约")}`, { headers: h });
44
+ expect(r.status).toBe(200);
45
+ const body = JSON.parse(r.body);
46
+ expect(body.data.total).toBe(1);
47
+ expect(String(body.data.list[0].title)).toContain("契约");
48
+ });
49
+
50
+ it("demo/todos keyword=%:LIKE 元字符经引擎转义不泛化(0 命中且不炸)", async () => {
51
+ const h = await asL1();
52
+ const r = await client.get(`/demo/todos?keyword=${encodeURIComponent("%")}`, { headers: h });
53
+ expect(r.status).toBe(200);
54
+ expect(JSON.parse(r.body).data.total).toBe(0);
55
+ });
56
+
57
+ it("notification 列表:join ON 值条件 + UNION + 字面量 scope 列真渲染,按人/广播分流", async () => {
58
+ const h = await asL1();
59
+ // 自给自足(oj-test-guide §6 纪律:按自己推的行精确断言,不依赖 seed 的 user_id=1):
60
+ // 先清空本人视图,再经真 push 端点各发一条按人 + 一条广播,回来过 list 断言。
61
+ const clear = await client.post("/notification/notifications/clear", { headers: h });
62
+ expect(clear.status).toBe(200);
63
+
64
+ const pushUser = await client.post("/notification/push", {
65
+ headers: h,
66
+ body: JSON.stringify({ title: "L1 按人通知", message: "join ON 值条件 + UNION 真渲染" }),
67
+ });
68
+ expect(pushUser.status).toBe(200);
69
+ const userId = String(JSON.parse(pushUser.body).data.id);
70
+ expect(userId.startsWith("u:")).toBeTruthy(); // 命名空间 id:按人
71
+
72
+ const pushBcast = await client.post("/notification/push-broadcast", {
73
+ headers: h,
74
+ body: JSON.stringify({ title: "L1 广播通知", message: "LEFT JOIN 回执 + 字面量 scope 列" }),
75
+ });
76
+ expect(pushBcast.status).toBe(200);
77
+ const bcastId = String(JSON.parse(pushBcast.body).data.id);
78
+ expect(bcastId.startsWith("b:")).toBeTruthy(); // 命名空间 id:广播
79
+
80
+ const r = await client.get("/notification/notifications", { headers: h });
81
+ expect(r.status).toBe(200);
82
+ const list = JSON.parse(r.body).data as Array<Record<string, unknown>>;
83
+ expect(Array.isArray(list)).toBeTruthy();
84
+ const byId = new Map(list.map(n => [String(n.id), n]));
85
+ // {lit:"user",as:"scope"} / {lit:"broadcast",...} 经 UNION 落到首列名——两条各带正确作用域
86
+ expect(byId.get(userId)?.scope).toBe("user");
87
+ expect(byId.get(bcastId)?.scope).toBe("broadcast");
88
+ expect(String(byId.get(userId)?.title)).toBe("L1 按人通知");
89
+ expect(String(byId.get(bcastId)?.title)).toBe("L1 广播通知");
90
+ });
91
+ });
@@ -28,7 +28,7 @@ import { acquireTaskLock, commitLockEntry, parseLock, readLock, serializeLock, w
28
28
  import { createStateStore } from "../../src/_platform/_lifecycle/state";
29
29
  import { getTask, readTasks, redact, sweepTimedOut, writeTask } from "../../src/_platform/_lifecycle/tasks";
30
30
  import { createTrustStore } from "../../src/_platform/_lifecycle/trust";
31
- import { installDb, MARKET_HISTORY_DDL } from "../fixture";
31
+ import { installDb } from "../fixture";
32
32
 
33
33
  const g = globalThis as any;
34
34
 
@@ -979,7 +979,6 @@ describe("market_history 留痕(P1-15a 缺口 A):install/enable/disable/un
979
979
  }
980
980
 
981
981
  it("runInstall 终态(done)落 install 历史(守卫式:随主流程写,失败不阻断)", async () => {
982
- dbo.exec(MARKET_HISTORY_DDL);
983
982
  const t = await runInstall(makeReq());
984
983
  expect(t.status).toBe("done");
985
984
  const hist = rows("SELECT solution, version, action, status, operator, message FROM market_history");
@@ -989,7 +988,6 @@ describe("market_history 留痕(P1-15a 缺口 A):install/enable/disable/un
989
988
  });
990
989
 
991
990
  it("setEnabled 落 disable/enable 历史(status=success,message 与返回人话一致)", async () => {
992
- dbo.exec(MARKET_HISTORY_DDL);
993
991
  await seedHistory();
994
992
  const off = await setEnabled({ name: "demo-solution", enabled: false, sharedRoot, lockPath, state, modulesJsonPath });
995
993
  const on = await setEnabled({ name: "demo-solution", enabled: true, sharedRoot, lockPath, state, modulesJsonPath });
@@ -1185,7 +1183,6 @@ describe("P1-16 接线:reconcile 不一致自动修复(重跑 setEnabled)+
1185
1183
  });
1186
1184
 
1187
1185
  it("operator 通道:runInstall / setEnabled 的 operator 落 market_history(缺省 null 语义不变)", async () => {
1188
- dbo.exec(MARKET_HISTORY_DDL);
1189
1186
  await writeLock(lockPath, { "demo-solution": "0.1.0", "auth": "0.1.0" });
1190
1187
  await state.write({ solutions: { "demo-solution": { version: "0.1.0", enabled: true, installedAt: 1000 } } });
1191
1188
  await fsp.mkdir(join(sharedRoot, "demo-solution-0.1.0"), { recursive: true });
@@ -28,7 +28,7 @@ import { getTask, writeTask } from "../../src/_platform/_lifecycle/tasks";
28
28
  import { createTrustStore } from "../../src/_platform/_lifecycle/trust";
29
29
  import { invalidateAllUsers } from "../../src/_platform/_shared/service/infra/access";
30
30
  import { createMarketApi, marketEnv, parseVersionsJson, versionsJsonPathOf } from "../../src/_platform/market/api";
31
- import { installDb, MARKET_HISTORY_DDL } from "../fixture";
31
+ import { installDb } from "../fixture";
32
32
 
33
33
  function hexToBytes(hex: string): Uint8Array {
34
34
  const out = new Uint8Array(hex.length / 2);
@@ -680,7 +680,6 @@ describe("GET index(CDN bundles.yaml no-cache 拉取 + 本地 state 对比)"
680
680
  });
681
681
 
682
682
  it("operator 回落 history 行(P2-11:最近一次 install 动作的 operator;表缺失/无行 → null 不炸)", async () => {
683
- dbo.exec(MARKET_HISTORY_DDL);
684
683
  dbo.prepare("insert into market_history (solution, version, action, status, operator, message, created_at) values (?, ?, ?, ?, ?, ?, ?)")
685
684
  .run("demo-solution", "0.1.0", "install", "done", "alice", "", 1700000000);
686
685
  const api = createMarketApi(makeEnv());
@@ -917,7 +916,6 @@ describe("P1-16 接线:marketEnv 生产口径 / exec opts 转发 / operator
917
916
  });
918
917
 
919
918
  it("operator 通道:install/enable 端点按 http.user.id 反查 username 落 market_history(uninstall 同款反查)", async () => {
920
- dbo.exec(MARKET_HISTORY_DDL);
921
919
  // 预置「已装」最小形态(enable 用例同款:单条目禁用会触发空锁拒启,S1-1.2)
922
920
  await writeLock(join(sharedRoot, "manifests.yaml"), { "demo-solution": "0.1.0", "other": "1.0.0" });
923
921
  const state = await createStateStore(join(sharedRoot, ".solutions", "state.yaml"));
@@ -514,7 +514,7 @@ describe("checkModuleManifest(C2-31 安装期复核纯函数:C2 manifest ↔
514
514
  const c2 = { name: "demo-solution", version: "0.1.0", summary: "演示方案 Demo — émoji 🎉" };
515
515
  const native = { name: "demo-solution", desc: "演示方案 Demo — émoji 🎉", version: "0.1.0" };
516
516
 
517
- it("合法:desc === summary(多字节逐字相等)、name/version 三方一致、七键之内 → ok", () => {
517
+ it("合法:name/version 三方一致、desc 非空、七键之内 → ok", () => {
518
518
  expect(checkModuleManifest(c2, native, "demo-solution", "0.1.0")).toEqual({ ok: true });
519
519
  });
520
520
 
@@ -535,10 +535,9 @@ describe("checkModuleManifest(C2-31 安装期复核纯函数:C2 manifest ↔
535
535
  expect(r.ok).toBe(false);
536
536
  });
537
537
 
538
- it("desc !== summary → 拒(构建期映射被篡改/上游不一致)", () => {
538
+ it("desc !== summary → 放行(等值要求已于 2026-10-21 移除:desc 只是模块描述,非安全边界)", () => {
539
539
  const r = checkModuleManifest(c2, { ...native, desc: "另一个描述" }, "demo-solution", "0.1.0");
540
- expect(r.ok).toBe(false);
541
- expect(!r.ok && r.message).toContain("desc");
540
+ expect(r.ok).toBe(true);
542
541
  });
543
542
 
544
543
  it("name 或 version 与包身份不一致 → 拒", () => {
@@ -613,11 +612,9 @@ describe("extract C2-31 安装期复核(B-11:api 段声明与包内原生清
613
612
  expect(r.message).toContain("C2-31");
614
613
  });
615
614
 
616
- it("包内原生清单 desc !== summary → C2-31 拒(映射被篡改)", async () => {
615
+ it("包内原生清单 desc !== summary → 放行(等值校验已移除)", async () => {
617
616
  await seedBundle({ nativeManifest: { name: "demo-solution", desc: "另一个描述", version: "0.1.0" } });
618
617
  const r = await extract(exCtx({ manifest: c2Manifest(), exec: realTarExec() }));
619
- expect(r.ok).toBe(false);
620
- expect(r.retryable).toBe(false);
621
- expect(r.message).toContain("C2-31");
618
+ expect(r.ok).toBe(true);
622
619
  });
623
620
  });