@sfmc-bds/db-server 0.1.0 → 0.2.0-beta.0

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 (112) hide show
  1. package/README.md +1 -1
  2. package/dist/db-tables.js +16 -17
  3. package/dist/db-tables.js.map +7 -1
  4. package/dist/domain/bridge.js +54 -77
  5. package/dist/domain/bridge.js.map +7 -1
  6. package/dist/domain/economy.d.ts +83 -2
  7. package/dist/domain/economy.d.ts.map +1 -1
  8. package/dist/domain/economy.js +292 -281
  9. package/dist/domain/economy.js.map +7 -1
  10. package/dist/domain/schema.js +480 -426
  11. package/dist/domain/schema.js.map +7 -1
  12. package/dist/domain/transaction.js +32 -80
  13. package/dist/domain/transaction.js.map +7 -1
  14. package/dist/env.d.ts.map +1 -1
  15. package/dist/env.js +85 -84
  16. package/dist/env.js.map +7 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +218 -240
  20. package/dist/index.js.map +7 -1
  21. package/dist/lib/http.js +30 -39
  22. package/dist/lib/http.js.map +7 -1
  23. package/dist/lib/idempotency-store.js +34 -47
  24. package/dist/lib/idempotency-store.js.map +7 -1
  25. package/dist/lib/idempotency.js +8 -13
  26. package/dist/lib/idempotency.js.map +7 -1
  27. package/dist/lib/identifiers.js +18 -14
  28. package/dist/lib/identifiers.js.map +7 -1
  29. package/dist/lib/log.d.ts +3 -6
  30. package/dist/lib/log.d.ts.map +1 -1
  31. package/dist/lib/log.js +13 -14
  32. package/dist/lib/log.js.map +7 -1
  33. package/dist/lib/module-state.d.ts.map +1 -1
  34. package/dist/lib/module-state.js +32 -21
  35. package/dist/lib/module-state.js.map +7 -1
  36. package/dist/lib/order-by.d.ts +17 -0
  37. package/dist/lib/order-by.d.ts.map +1 -0
  38. package/dist/lib/order-by.js +24 -0
  39. package/dist/lib/order-by.js.map +7 -0
  40. package/dist/lib/runtime.js +16 -31
  41. package/dist/lib/runtime.js.map +7 -1
  42. package/dist/lib/sql-helpers.js +9 -27
  43. package/dist/lib/sql-helpers.js.map +7 -1
  44. package/dist/lib/sqlite.js +40 -52
  45. package/dist/lib/sqlite.js.map +7 -1
  46. package/dist/manifest-loader.d.ts.map +1 -1
  47. package/dist/manifest-loader.js +154 -195
  48. package/dist/manifest-loader.js.map +7 -1
  49. package/dist/manifest.d.ts +3 -7
  50. package/dist/manifest.d.ts.map +1 -1
  51. package/dist/manifest.js +76 -115
  52. package/dist/manifest.js.map +7 -1
  53. package/dist/module-auth.d.ts +17 -6
  54. package/dist/module-auth.d.ts.map +1 -1
  55. package/dist/module-auth.js +93 -107
  56. package/dist/module-auth.js.map +7 -1
  57. package/dist/module-runtime-sync.d.ts +37 -0
  58. package/dist/module-runtime-sync.d.ts.map +1 -0
  59. package/dist/module-runtime-sync.js +54 -0
  60. package/dist/module-runtime-sync.js.map +7 -0
  61. package/dist/permission-gate.d.ts.map +1 -1
  62. package/dist/permission-gate.js +55 -86
  63. package/dist/permission-gate.js.map +7 -1
  64. package/dist/routes/_shared.d.ts +7 -2
  65. package/dist/routes/_shared.d.ts.map +1 -1
  66. package/dist/routes/_shared.js +16 -25
  67. package/dist/routes/_shared.js.map +7 -1
  68. package/dist/routes/config.d.ts +11 -1
  69. package/dist/routes/config.d.ts.map +1 -1
  70. package/dist/routes/config.js +167 -213
  71. package/dist/routes/config.js.map +7 -1
  72. package/dist/routes/db-routes.d.ts +6 -10
  73. package/dist/routes/db-routes.d.ts.map +1 -1
  74. package/dist/routes/db-routes.js +198 -172
  75. package/dist/routes/db-routes.js.map +7 -1
  76. package/dist/routes/health.js +15 -20
  77. package/dist/routes/health.js.map +7 -1
  78. package/dist/routes/messages.d.ts.map +1 -1
  79. package/dist/routes/messages.js +56 -63
  80. package/dist/routes/messages.js.map +7 -1
  81. package/dist/routes/module-config-routes.d.ts.map +1 -1
  82. package/dist/routes/module-config-routes.js +92 -111
  83. package/dist/routes/module-config-routes.js.map +7 -1
  84. package/dist/routes/modules.d.ts.map +1 -1
  85. package/dist/routes/modules.js +82 -88
  86. package/dist/routes/modules.js.map +7 -1
  87. package/dist/routes/service-routes.d.ts.map +1 -1
  88. package/dist/routes/service-routes.js +54 -61
  89. package/dist/routes/service-routes.js.map +7 -1
  90. package/dist/runtime.test.js +248 -49
  91. package/dist/runtime.test.js.map +7 -1
  92. package/dist/schema-registry.d.ts.map +1 -1
  93. package/dist/schema-registry.js +119 -160
  94. package/dist/schema-registry.js.map +7 -1
  95. package/dist/server.js +69 -72
  96. package/dist/server.js.map +7 -1
  97. package/dist/service-registry.d.ts.map +1 -1
  98. package/dist/service-registry.js +60 -67
  99. package/dist/service-registry.js.map +7 -1
  100. package/dist/services/builtin-handlers.d.ts +4 -0
  101. package/dist/services/builtin-handlers.d.ts.map +1 -1
  102. package/dist/services/builtin-handlers.js +34 -20
  103. package/dist/services/builtin-handlers.js.map +7 -1
  104. package/dist/services/economy-handlers.js +143 -132
  105. package/dist/services/economy-handlers.js.map +7 -1
  106. package/dist/tx-runner.d.ts +35 -11
  107. package/dist/tx-runner.d.ts.map +1 -1
  108. package/dist/tx-runner.js +293 -250
  109. package/dist/tx-runner.js.map +7 -1
  110. package/dist/where.js +71 -94
  111. package/dist/where.js.map +7 -1
  112. package/package.json +4 -4
package/dist/tx-runner.js CHANGED
@@ -1,273 +1,316 @@
1
- /**
2
- * tx-runner.ts — 事务 RPC 处理器
3
- *
4
- * 协议:
5
- * POST /api/sfmc/db/tx
6
- * body = { moduleId, steps: TxStep[] }
7
- * reply: { ok: true } | { ok: false, step: <index>, error, code }
8
- *
9
- * 流程:
10
- * 1. 校验 moduleId in enabled + permission "db:write:*"
11
- * 2. BEGIN IMMEDIATE
12
- * 3. 顺序跑 steps — 任一抛错 → ROLLBACK + reply { ok:false, step:<i>, ... }
13
- * 4. 全过 → COMMIT + reply { ok:true }
14
- *
15
- * step.op:
16
- * - query { table, opts? }
17
- * - get { table, id }
18
- * - insert { table, row }
19
- * - update { table, id, patch }
20
- * - delete { table, id, hard }
21
- * - audit { table, rowId, action, data? } → 平台 sfmc__audit 表
22
- * - service { name, input } → 派发;失败抛错回退整事务
23
- *
24
- * 事务内 service call:不持有 db handle 给 handler(避免并发写) —
25
- * 实现:handler 是"同步纯函数",返回 result 写回 step 的输出。
26
- * 若 handler 自己也想写 db,应通过其它接口在事务外做(避免嵌套)。
27
- */
28
1
  import { randomUUID } from "node:crypto";
29
2
  import { compile } from "./where.js";
30
3
  import { log } from "./lib/log.js";
31
4
  import { DispatchError } from "./service-registry.js";
32
- import { PermissionDeniedError, Perm, assertModulePermission, } from "./permission-gate.js";
5
+ import {
6
+ PermissionDeniedError,
7
+ Perm,
8
+ assertModulePermission
9
+ } from "./permission-gate.js";
10
+ import { normalizeOrderBy } from "./lib/order-by.js";
33
11
  const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
34
12
  function identOk(t) {
35
- if (!IDENT.test(t))
36
- throw new Error(`[tx] invalid table name "${t}"`);
13
+ if (!IDENT.test(t)) throw new Error(`[tx] invalid table name "${t}"`);
37
14
  }
38
- export class TxRunner {
39
- deps;
40
- constructor(deps) {
41
- this.deps = deps;
15
+ class TxRunner {
16
+ constructor(deps) {
17
+ this.deps = deps;
18
+ }
19
+ deps;
20
+ sessions = /* @__PURE__ */ new Map();
21
+ async run(req) {
22
+ const { moduleId, steps } = req;
23
+ const manifest = this.deps.enabled.get(moduleId);
24
+ if (!manifest) return { ok: false, step: -1, error: "\u6A21\u5757\u672A enabled", code: "forbidden" };
25
+ const traceId = randomUUID().slice(0, 8);
26
+ const results = [];
27
+ try {
28
+ this.deps.db.exec("BEGIN IMMEDIATE");
29
+ } catch (e) {
30
+ return { ok: false, step: -1, error: `BEGIN \u5931\u8D25: ${e.message}`, code: "internal" };
42
31
  }
43
- async run(req) {
44
- const { moduleId, steps } = req;
45
- const manifest = this.deps.enabled.get(moduleId);
46
- if (!manifest)
47
- return { ok: false, step: -1, error: "模块未 enabled", code: "forbidden" };
48
- // 注意:不在此处做「整体 db:write:*」断言。
49
- // 通配 `db:write:*` 无法通过 permission-gate 的 validPermissionKey 校验(模块声明它会启动失败),
50
- // 因此该断言会让所有事务(含只读事务)恒被拒。真正的读写权限由每个 step 的
51
- // requireTableRead / requireTableWrite 按具体表精确 gate。
52
- const traceId = randomUUID().slice(0, 8);
53
- const results = [];
54
- try {
55
- this.deps.db.exec("BEGIN IMMEDIATE");
56
- }
57
- catch (e) {
58
- return { ok: false, step: -1, error: `BEGIN 失败: ${e.message}`, code: "internal" };
59
- }
60
- for (let i = 0; i < steps.length; i++) {
61
- const step = steps[i];
62
- try {
63
- const r = await this.runOne(moduleId, manifest, step);
64
- results.push(r);
65
- }
66
- catch (err) {
67
- this.rollback();
68
- const code = err instanceof PermissionDeniedError
69
- ? "permission_denied"
70
- : err instanceof DispatchError &&
71
- (err.code === "no_such_service" ||
72
- err.code === "not_in_requires" ||
73
- err.code === "forbidden" ||
74
- err.code === "domain_error")
75
- ? err.code
76
- : err.code === "no_such_service"
77
- ? "no_such_service"
78
- : err.code === "not_in_requires"
79
- ? "not_in_requires"
80
- : err.code === "no_such_table"
81
- ? "no_such_table"
82
- : "internal";
83
- log.warn(`[tx ${traceId}] step=${i} failed: ${err.message}`);
84
- return { ok: false, step: i, error: err.message, code };
85
- }
86
- }
87
- try {
88
- this.deps.db.exec("COMMIT");
89
- }
90
- catch (e) {
91
- this.rollback();
92
- return { ok: false, step: -1, error: `COMMIT 失败: ${e.message}`, code: "internal" };
93
- }
94
- log.info(`[tx ${traceId}] module=${moduleId} ${steps.length} steps OK`);
95
- return { ok: true, results };
32
+ for (let i = 0; i < steps.length; i++) {
33
+ const step = steps[i];
34
+ try {
35
+ const r = await this.runOne(moduleId, manifest, step);
36
+ results.push(r);
37
+ } catch (err) {
38
+ this.rollback();
39
+ const code = this.mapErrorCode(err);
40
+ log.warn(`[tx ${traceId}] step=${i} failed: ${err.message}`);
41
+ return { ok: false, step: i, error: err.message, code };
42
+ }
96
43
  }
97
- rollback() {
98
- try {
99
- this.deps.db.exec("ROLLBACK");
100
- }
101
- catch {
102
- // best-effort; fall through
103
- }
44
+ try {
45
+ this.deps.db.exec("COMMIT");
46
+ } catch (e) {
47
+ this.rollback();
48
+ return { ok: false, step: -1, error: `COMMIT \u5931\u8D25: ${e.message}`, code: "internal" };
104
49
  }
105
- /** step 由对应权限检查 + SQL 路径执行 */
106
- async runOne(moduleId, manifest, step) {
107
- switch (step.op) {
108
- case "query":
109
- return this.doQuery(moduleId, manifest, step);
110
- case "get":
111
- return this.doGet(moduleId, manifest, step);
112
- case "insert":
113
- return this.doInsert(moduleId, manifest, step);
114
- case "update":
115
- return this.doUpdate(moduleId, manifest, step);
116
- case "delete":
117
- return this.doDelete(moduleId, manifest, step);
118
- case "audit":
119
- return this.doAudit(moduleId, manifest, step);
120
- case "service":
121
- return this.doService(moduleId, manifest, step);
122
- }
50
+ log.info(`[tx ${traceId}] module=${moduleId} ${steps.length} steps OK`);
51
+ return { ok: true, results };
52
+ }
53
+ /**
54
+ * 交互式事务:begin step* → commit|rollback。
55
+ * 供 SDK db.tx 在回调内 await query/get/call 真实结果(PR #31 未完成项)。
56
+ * 单连接模型:新会话会先清掉残留会话(避免 BEGIN 锁死)
57
+ */
58
+ beginSession(moduleId) {
59
+ const manifest = this.deps.enabled.get(moduleId);
60
+ if (!manifest) return { ok: false, step: -1, error: "\u6A21\u5757\u672A enabled", code: "forbidden" };
61
+ if (this.sessions.size > 0) {
62
+ for (const id of [...this.sessions.keys()]) {
63
+ this.abortSession(id);
64
+ }
123
65
  }
124
- requireTableRead(mod, table) {
125
- identOk(table);
126
- this.assertTableRegistered(table);
127
- assertModulePermission(mod.id, mod.permissions, Perm.dbRead(table));
66
+ try {
67
+ this.deps.db.exec("BEGIN IMMEDIATE");
68
+ } catch (e) {
69
+ return { ok: false, step: -1, error: `BEGIN \u5931\u8D25: ${e.message}`, code: "internal" };
128
70
  }
129
- requireTableWrite(mod, table) {
130
- identOk(table);
131
- this.assertTableRegistered(table);
132
- assertModulePermission(mod.id, mod.permissions, Perm.dbWrite(table));
71
+ const txId = randomUUID();
72
+ this.sessions.set(txId, {
73
+ moduleId,
74
+ results: [],
75
+ openedAt: Date.now()
76
+ });
77
+ log.info(`[tx-session ${txId.slice(0, 8)}] begin module=${moduleId}`);
78
+ return { ok: true, txId };
79
+ }
80
+ async stepSession(txId, moduleId, step) {
81
+ const session = this.sessions.get(txId);
82
+ if (!session) {
83
+ return { ok: false, step: -1, error: "\u4E8B\u52A1\u4F1A\u8BDD\u4E0D\u5B58\u5728\u6216\u5DF2\u7ED3\u675F", code: "internal" };
133
84
  }
134
- assertTableRegistered(table) {
135
- if (table.startsWith("sfmc_"))
136
- return; // 平台表 — 始终可读写(平台层)
137
- if (!this.deps.schema.isRegistered(table)) {
138
- const err = new Error(`表 "${table}" 未注册到 schema-registry`);
139
- err.code = "no_such_table";
140
- throw err;
141
- }
85
+ if (session.moduleId !== moduleId) {
86
+ return { ok: false, step: session.results.length, error: "moduleId \u4E0E\u4F1A\u8BDD\u4E0D\u7B26", code: "forbidden" };
142
87
  }
143
- doQuery(_mid, mod, step) {
144
- this.requireTableRead(mod, step.table);
145
- const where = compile(step.opts?.where);
146
- const orderBy = step.opts?.orderBy;
147
- const limit = typeof step.opts?.limit === "number" ? step.opts?.limit : undefined;
148
- const offset = typeof step.opts?.offset === "number" ? step.opts?.offset : undefined;
149
- const whereClause = where.sql;
150
- const params = [...where.values];
151
- let extra = "";
152
- if (orderBy) {
153
- if (!IDENT.test(orderBy.col))
154
- throw new Error(`[tx] orderBy bad column "${orderBy.col}"`);
155
- const dir = orderBy.dir === "desc" ? "DESC" : "ASC";
156
- extra += ` ORDER BY "${orderBy.col}" ${dir}`;
157
- }
158
- if (typeof limit === "number") {
159
- extra += " LIMIT " + Math.max(0, Math.floor(limit));
160
- }
161
- if (typeof offset === "number") {
162
- extra += " OFFSET " + Math.max(0, Math.floor(offset));
163
- }
164
- const sql = `SELECT * FROM "${step.table}" WHERE ${whereClause}${extra}`;
165
- const rows = this.deps.db.prepare(sql).all(...params);
166
- return { op: "query", rows };
88
+ const manifest = this.deps.enabled.get(moduleId);
89
+ if (!manifest) {
90
+ this.abortSession(txId);
91
+ return { ok: false, step: session.results.length, error: "\u6A21\u5757\u672A enabled", code: "forbidden" };
167
92
  }
168
- /** 解析主键 WHERE。单列直接等值;联合主键支持 `a|b|c`(与 data-backup 约定一致)。 */
169
- pkWhere(table, id) {
170
- const cols = this.deps.db.prepare(`PRAGMA table_info("${table}")`).all();
171
- const pkCols = cols
172
- .filter((c) => c.pk > 0)
173
- .sort((a, b) => a.pk - b.pk)
174
- .map((c) => c.name);
175
- if (pkCols.length === 0) {
176
- throw new Error(`[tx] ${table} 没有 primary key`);
177
- }
178
- if (pkCols.length === 1) {
179
- return { clause: `"${pkCols[0]}" = ?`, values: [id] };
180
- }
181
- const parts = String(id).split("|");
182
- if (parts.length === pkCols.length) {
183
- return {
184
- clause: pkCols.map((c) => `"${c}" = ?`).join(" AND "),
185
- values: parts,
186
- };
187
- }
188
- // 联合主键但只传了首列(频道 id 等):按第一主键列匹配
189
- return { clause: `"${pkCols[0]}" = ?`, values: [id] };
93
+ const stepIndex = session.results.length;
94
+ try {
95
+ const r = await this.runOne(moduleId, manifest, step);
96
+ session.results.push(r);
97
+ return { ok: true, result: r };
98
+ } catch (err) {
99
+ this.abortSession(txId);
100
+ const code = this.mapErrorCode(err);
101
+ log.warn(`[tx-session ${txId.slice(0, 8)}] step=${stepIndex} failed: ${err.message}`);
102
+ return { ok: false, step: stepIndex, error: err.message, code };
190
103
  }
191
- doGet(_mid, mod, step) {
192
- this.requireTableRead(mod, step.table);
193
- const where = this.pkWhere(step.table, step.id);
194
- const row = this.deps.db
195
- .prepare(`SELECT * FROM "${step.table}" WHERE ${where.clause}`)
196
- .get(...where.values);
197
- return { op: "get", row: row ?? null };
104
+ }
105
+ commitSession(txId, moduleId) {
106
+ const session = this.sessions.get(txId);
107
+ if (!session) {
108
+ return { ok: false, step: -1, error: "\u4E8B\u52A1\u4F1A\u8BDD\u4E0D\u5B58\u5728\u6216\u5DF2\u7ED3\u675F", code: "internal" };
198
109
  }
199
- doInsert(_mid, mod, step) {
200
- this.requireTableWrite(mod, step.table);
201
- const cols = Object.keys(step.row);
202
- if (cols.length === 0)
203
- throw new Error("[tx] insert row 必须至少一个字段");
204
- for (const c of cols) {
205
- if (!IDENT.test(c))
206
- throw new Error(`[tx] invalid column name "${c}"`);
207
- }
208
- const placeholders = cols.map(() => "?").join(",");
209
- const quoted = cols.map((c) => `"${c}"`).join(",");
210
- const values = cols.map((c) => step.row[c]);
211
- const sql = `INSERT INTO "${step.table}" (${quoted}) VALUES (${placeholders})`;
212
- try {
213
- this.deps.db.prepare(sql).run(...values);
214
- }
215
- catch (e) {
216
- throw new Error(`INSERT ${step.table} 失败: ${e.message}`);
217
- }
218
- return { op: "insert", row: { ...step.row } };
110
+ if (session.moduleId !== moduleId) {
111
+ return { ok: false, step: -1, error: "moduleId \u4E0E\u4F1A\u8BDD\u4E0D\u7B26", code: "forbidden" };
219
112
  }
220
- doUpdate(_mid, mod, step) {
221
- this.requireTableWrite(mod, step.table);
222
- const cols = Object.keys(step.patch);
223
- if (cols.length === 0)
224
- throw new Error("[tx] update patch 不能为空");
225
- for (const c of cols) {
226
- if (!IDENT.test(c))
227
- throw new Error(`[tx] invalid column name "${c}"`);
228
- }
229
- const where = this.pkWhere(step.table, step.id);
230
- const sets = cols.map((c) => `"${c}" = ?`).join(",");
231
- const params = [...cols.map((c) => step.patch[c]), ...where.values];
232
- const sql = `UPDATE "${step.table}" SET ${sets} WHERE ${where.clause}`;
233
- const result = this.deps.db.prepare(sql).run(...params);
234
- if (result.changes === 0) {
235
- throw new Error(`UPDATE ${step.table} id=${step.id} 影响 0 行(找不到 / 已删?)`);
236
- }
237
- return { op: "update", row: { ...step.patch, id: step.id } };
113
+ try {
114
+ this.deps.db.exec("COMMIT");
115
+ } catch (e) {
116
+ this.abortSession(txId);
117
+ return { ok: false, step: -1, error: `COMMIT \u5931\u8D25: ${e.message}`, code: "internal" };
238
118
  }
239
- doDelete(_mid, mod, step) {
240
- this.requireTableWrite(mod, step.table);
241
- const where = this.pkWhere(step.table, step.id);
242
- if (step.hard) {
243
- const sql = `DELETE FROM "${step.table}" WHERE ${where.clause}`;
244
- const r = this.deps.db.prepare(sql).run(...where.values);
245
- return { op: "delete", changes: Number(r.changes) };
246
- }
247
- const cols = this.deps.db.prepare(`PRAGMA table_info("${step.table}")`).all();
248
- const hasDeletedAt = cols.find((c) => c.name === "_deleted_at");
249
- if (!hasDeletedAt) {
250
- throw new Error(`[tx] ${step.table} 没启用 softDelete,删不掉;请 hard=true 或 schema 设 softDelete=true`);
251
- }
252
- const sql = `UPDATE "${step.table}" SET "_deleted_at" = ?, "_version" = COALESCE("_version",0)+1 WHERE ${where.clause}`;
253
- const r = this.deps.db.prepare(sql).run(Date.now(), ...where.values);
254
- return { op: "delete", changes: Number(r.changes) };
119
+ const results = session.results;
120
+ this.sessions.delete(txId);
121
+ log.info(`[tx-session ${txId.slice(0, 8)}] commit ${results.length} steps OK`);
122
+ return { ok: true, results };
123
+ }
124
+ rollbackSession(txId, moduleId) {
125
+ const session = this.sessions.get(txId);
126
+ if (!session) {
127
+ return { ok: true };
255
128
  }
256
- doAudit(_mid, mod, step) {
257
- this.requireTableWrite(mod, step.table);
258
- const dataJson = step.data ? JSON.stringify(step.data) : null;
259
- const r = this.deps.db
260
- .prepare(`INSERT INTO sfmc__audit (module_id, table_name, row_id, action, data, created_at)
261
- VALUES (?, ?, ?, ?, ?, ?)`)
262
- .run(mod.id, step.table, step.rowId, step.action, dataJson, new Date().toISOString());
263
- return { op: "audit", changes: Number(r.changes) };
129
+ if (session.moduleId !== moduleId) {
130
+ return { ok: false, step: -1, error: "moduleId \u4E0E\u4F1A\u8BDD\u4E0D\u7B26", code: "forbidden" };
264
131
  }
265
- async doService(_mid, mod, step) {
266
- // requires / 自调用豁免的唯一权威在 ServiceRegistry.dispatch(DRY/LSP);
267
- // 此处只补 dispatch 不做的 service:<name> 权限门(与 HTTP service-routes 对齐)。
268
- assertModulePermission(mod.id, mod.permissions, Perm.service(step.name));
269
- const result = await this.deps.serviceRegistry.dispatch(this.deps.enabled, mod.id, step.name, step.input, { query: this.deps.query, db: this.deps.db });
270
- return { op: "service", result: result.result };
132
+ this.abortSession(txId);
133
+ return { ok: true };
134
+ }
135
+ abortSession(txId) {
136
+ this.sessions.delete(txId);
137
+ this.rollback();
138
+ log.info(`[tx-session ${txId.slice(0, 8)}] aborted`);
139
+ }
140
+ mapErrorCode(err) {
141
+ return err instanceof PermissionDeniedError ? "permission_denied" : err instanceof DispatchError && (err.code === "no_such_service" || err.code === "not_in_requires" || err.code === "forbidden" || err.code === "domain_error") ? err.code : err.code === "no_such_service" ? "no_such_service" : err.code === "not_in_requires" ? "not_in_requires" : err.code === "no_such_table" ? "no_such_table" : "internal";
142
+ }
143
+ rollback() {
144
+ try {
145
+ this.deps.db.exec("ROLLBACK");
146
+ } catch {
271
147
  }
148
+ }
149
+ /** 单 step 由对应权限检查 + SQL 路径执行 */
150
+ async runOne(moduleId, manifest, step) {
151
+ switch (step.op) {
152
+ case "query":
153
+ return this.doQuery(moduleId, manifest, step);
154
+ case "get":
155
+ return this.doGet(moduleId, manifest, step);
156
+ case "insert":
157
+ return this.doInsert(moduleId, manifest, step);
158
+ case "update":
159
+ return this.doUpdate(moduleId, manifest, step);
160
+ case "delete":
161
+ return this.doDelete(moduleId, manifest, step);
162
+ case "audit":
163
+ return this.doAudit(moduleId, manifest, step);
164
+ case "service":
165
+ return this.doService(moduleId, manifest, step);
166
+ }
167
+ }
168
+ requireTableRead(mod, table) {
169
+ identOk(table);
170
+ this.assertTableRegistered(table);
171
+ assertModulePermission(mod.id, mod.permissions, Perm.dbRead(table));
172
+ }
173
+ requireTableWrite(mod, table) {
174
+ identOk(table);
175
+ this.assertTableRegistered(table);
176
+ assertModulePermission(mod.id, mod.permissions, Perm.dbWrite(table));
177
+ }
178
+ assertTableRegistered(table) {
179
+ if (table.startsWith("sfmc_")) return;
180
+ if (!this.deps.schema.isRegistered(table)) {
181
+ const err = new Error(`\u8868 "${table}" \u672A\u6CE8\u518C\u5230 schema-registry`);
182
+ err.code = "no_such_table";
183
+ throw err;
184
+ }
185
+ }
186
+ doQuery(_mid, mod, step) {
187
+ this.requireTableRead(mod, step.table);
188
+ const where = compile(step.opts?.where);
189
+ const orders = normalizeOrderBy(step.opts?.orderBy);
190
+ const limit = typeof step.opts?.limit === "number" ? step.opts?.limit : void 0;
191
+ const offset = typeof step.opts?.offset === "number" ? step.opts?.offset : void 0;
192
+ const whereClause = where.sql;
193
+ const params = [...where.values];
194
+ let extra = "";
195
+ if (orders.length > 0) {
196
+ const parts = [];
197
+ for (const o of orders) {
198
+ if (!IDENT.test(o.col)) throw new Error(`[tx] orderBy bad column "${o.col}"`);
199
+ parts.push(`"${o.col}" ${o.dir === "desc" ? "DESC" : "ASC"}`);
200
+ }
201
+ extra += ` ORDER BY ${parts.join(", ")}`;
202
+ }
203
+ if (typeof limit === "number") {
204
+ extra += " LIMIT " + Math.max(0, Math.floor(limit));
205
+ }
206
+ if (typeof offset === "number") {
207
+ extra += " OFFSET " + Math.max(0, Math.floor(offset));
208
+ }
209
+ const sql = `SELECT * FROM "${step.table}" WHERE ${whereClause}${extra}`;
210
+ const rows = this.deps.db.prepare(sql).all(...params);
211
+ return { op: "query", rows };
212
+ }
213
+ /** 解析主键 WHERE。单列直接等值;联合主键支持 `a|b|c`(与 data-backup 约定一致)。 */
214
+ pkWhere(table, id) {
215
+ const cols = this.deps.db.prepare(`PRAGMA table_info("${table}")`).all();
216
+ const pkCols = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name);
217
+ if (pkCols.length === 0) {
218
+ throw new Error(`[tx] ${table} \u6CA1\u6709 primary key`);
219
+ }
220
+ if (pkCols.length === 1) {
221
+ return { clause: `"${pkCols[0]}" = ?`, values: [id] };
222
+ }
223
+ const parts = String(id).split("|");
224
+ if (parts.length === pkCols.length) {
225
+ return {
226
+ clause: pkCols.map((c) => `"${c}" = ?`).join(" AND "),
227
+ values: parts
228
+ };
229
+ }
230
+ return { clause: `"${pkCols[0]}" = ?`, values: [id] };
231
+ }
232
+ doGet(_mid, mod, step) {
233
+ this.requireTableRead(mod, step.table);
234
+ const where = this.pkWhere(step.table, step.id);
235
+ const row = this.deps.db.prepare(`SELECT * FROM "${step.table}" WHERE ${where.clause}`).get(...where.values);
236
+ return { op: "get", row: row ?? null };
237
+ }
238
+ doInsert(_mid, mod, step) {
239
+ this.requireTableWrite(mod, step.table);
240
+ const cols = Object.keys(step.row);
241
+ if (cols.length === 0) throw new Error("[tx] insert row \u5FC5\u987B\u81F3\u5C11\u4E00\u4E2A\u5B57\u6BB5");
242
+ for (const c of cols) {
243
+ if (!IDENT.test(c)) throw new Error(`[tx] invalid column name "${c}"`);
244
+ }
245
+ const placeholders = cols.map(() => "?").join(",");
246
+ const quoted = cols.map((c) => `"${c}"`).join(",");
247
+ const values = cols.map((c) => step.row[c]);
248
+ const sql = `INSERT INTO "${step.table}" (${quoted}) VALUES (${placeholders})`;
249
+ try {
250
+ this.deps.db.prepare(sql).run(...values);
251
+ } catch (e) {
252
+ throw new Error(`INSERT ${step.table} \u5931\u8D25: ${e.message}`);
253
+ }
254
+ return { op: "insert", row: { ...step.row } };
255
+ }
256
+ doUpdate(_mid, mod, step) {
257
+ this.requireTableWrite(mod, step.table);
258
+ const cols = Object.keys(step.patch);
259
+ if (cols.length === 0) throw new Error("[tx] update patch \u4E0D\u80FD\u4E3A\u7A7A");
260
+ for (const c of cols) {
261
+ if (!IDENT.test(c)) throw new Error(`[tx] invalid column name "${c}"`);
262
+ }
263
+ const where = this.pkWhere(step.table, step.id);
264
+ const sets = cols.map((c) => `"${c}" = ?`).join(",");
265
+ const params = [...cols.map((c) => step.patch[c]), ...where.values];
266
+ const sql = `UPDATE "${step.table}" SET ${sets} WHERE ${where.clause}`;
267
+ const result = this.deps.db.prepare(sql).run(...params);
268
+ if (result.changes === 0) {
269
+ throw new Error(`UPDATE ${step.table} id=${step.id} \u5F71\u54CD 0 \u884C(\u627E\u4E0D\u5230 / \u5DF2\u5220?)`);
270
+ }
271
+ return { op: "update", row: { ...step.patch, id: step.id } };
272
+ }
273
+ doDelete(_mid, mod, step) {
274
+ this.requireTableWrite(mod, step.table);
275
+ const where = this.pkWhere(step.table, step.id);
276
+ if (step.hard) {
277
+ const sql2 = `DELETE FROM "${step.table}" WHERE ${where.clause}`;
278
+ const r2 = this.deps.db.prepare(sql2).run(...where.values);
279
+ return { op: "delete", changes: Number(r2.changes) };
280
+ }
281
+ const cols = this.deps.db.prepare(`PRAGMA table_info("${step.table}")`).all();
282
+ const hasDeletedAt = cols.find((c) => c.name === "_deleted_at");
283
+ if (!hasDeletedAt) {
284
+ throw new Error(
285
+ `[tx] ${step.table} \u6CA1\u542F\u7528 softDelete,\u5220\u4E0D\u6389;\u8BF7 hard=true \u6216 schema \u8BBE softDelete=true`
286
+ );
287
+ }
288
+ const sql = `UPDATE "${step.table}" SET "_deleted_at" = ?, "_version" = COALESCE("_version",0)+1 WHERE ${where.clause}`;
289
+ const r = this.deps.db.prepare(sql).run(Date.now(), ...where.values);
290
+ return { op: "delete", changes: Number(r.changes) };
291
+ }
292
+ doAudit(_mid, mod, step) {
293
+ this.requireTableWrite(mod, step.table);
294
+ const dataJson = step.data ? JSON.stringify(step.data) : null;
295
+ const r = this.deps.db.prepare(
296
+ `INSERT INTO sfmc__audit (module_id, table_name, row_id, action, data, created_at)
297
+ VALUES (?, ?, ?, ?, ?, ?)`
298
+ ).run(mod.id, step.table, step.rowId, step.action, dataJson, (/* @__PURE__ */ new Date()).toISOString());
299
+ return { op: "audit", changes: Number(r.changes) };
300
+ }
301
+ async doService(_mid, mod, step) {
302
+ assertModulePermission(mod.id, mod.permissions, Perm.service(step.name));
303
+ const result = await this.deps.serviceRegistry.dispatch(
304
+ this.deps.enabled,
305
+ mod.id,
306
+ step.name,
307
+ step.input,
308
+ { query: this.deps.query, db: this.deps.db }
309
+ );
310
+ return { op: "service", result: result.result };
311
+ }
272
312
  }
273
- //# sourceMappingURL=tx-runner.js.map
313
+ export {
314
+ TxRunner
315
+ };
316
+ //# sourceMappingURL=tx-runner.js.map