Skip to content

Commit ec7986b

Browse files
authored
Merge pull request #37 from DogeLakeDev/fix/pr31-leftovers-hot-enable-interactive-tx
fix(pr31): interactive db.tx readback + hot enable runtime sync
2 parents 3d15949 + 50285d2 commit ec7986b

13 files changed

Lines changed: 499 additions & 91 deletions

File tree

db-server/src/index.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,13 @@ import http from "node:http";
2323

2424
import { createIdempotencyStore } from "./lib/idempotency-store.js";
2525
import { loadEnv } from "./env.js";
26-
import { buildModuleAuth, verifyModuleAuth } from "./module-auth.js";
26+
import {
27+
buildModuleAuth,
28+
ensureModuleToken,
29+
persistModuleAuth,
30+
revokeModuleToken,
31+
verifyModuleAuth,
32+
} from "./module-auth.js";
2733
import { loadManifestV2 } from "./manifest-loader.js";
2834
import { log } from "./lib/log.js";
2935
import { assertNodeVersion } from "./lib/runtime.js";
@@ -34,7 +40,11 @@ import { initSchema } from "./domain/schema.js";
3440
import { SchemaRegistry } from "./schema-registry.js";
3541
import { ServiceRegistry } from "./service-registry.js";
3642
import { TxRunner } from "./tx-runner.js";
37-
import { registerEnabledBuiltinServices } from "./services/builtin-handlers.js";
43+
import {
44+
registerBuiltinPluginForModule,
45+
registerEnabledBuiltinServices,
46+
unregisterBuiltinPluginForModule,
47+
} from "./services/builtin-handlers.js";
3848

3949
import { readJson } from "@sfmc-bds/sdk/node/config";
4050

@@ -199,6 +209,44 @@ function setModuleEnabled(mod: { id: string; canDisable: boolean }, enabled: boo
199209
updateModuleState(lockFile, mod.id, { enabled: !!enabled });
200210
// DRY:与 loadModuleLock 对称走 saveModuleLock,勿散落 writeJson
201211
saveModuleLock(env.MODULE_LOCK_PATH, lockFile);
212+
213+
// 热更新运行时图(PR #31 未完成项):enabledSet / tokens / manifests / builtin handlers
214+
// 与启动期同源,避免「lock 已开但鉴权仍 401 / handler 未注册」。
215+
syncRuntimeEnabled(mod.id, !!enabled);
216+
}
217+
218+
/**
219+
* 启停后同步 db-server 进程内运行时状态(不重启)。
220+
* TxRunner / serviceRoutes 持有 enabledManifests 引用,Map 就地改即可。
221+
*/
222+
function syncRuntimeEnabled(moduleId: string, enabled: boolean): void {
223+
if (enabled) {
224+
const manifest = loadedManifest.modules[moduleId];
225+
if (!manifest) {
226+
log.warn(`[modules] 热启用 ${moduleId}: manifest 缺失(未安装?),仅写 lock`);
227+
return;
228+
}
229+
enabledSet.add(moduleId);
230+
enabledManifests.set(moduleId, manifest);
231+
if (ensureModuleToken(moduleAuth, moduleId)) {
232+
log.info(`[modules] 热启用 ${moduleId}: 派生 module token`);
233+
}
234+
persistModuleAuth(env.PROJECT_ROOT, moduleAuth, env.AUTH_TOKEN);
235+
if (registerBuiltinPluginForModule(serviceRegistry, { query, db }, moduleId)) {
236+
log.success(`[modules] 热启用 ${moduleId}: 已注册内置 service handlers`);
237+
}
238+
return;
239+
}
240+
241+
enabledSet.delete(moduleId);
242+
enabledManifests.delete(moduleId);
243+
if (revokeModuleToken(moduleAuth, moduleId)) {
244+
persistModuleAuth(env.PROJECT_ROOT, moduleAuth, env.AUTH_TOKEN);
245+
}
246+
const n = unregisterBuiltinPluginForModule(serviceRegistry, moduleId);
247+
if (n > 0) {
248+
log.info(`[modules] 热禁用 ${moduleId}: 卸下 ${n} 个内置 handlers`);
249+
}
202250
}
203251

204252
// ── 平台路由(非模块业务) ───────────────────────────────────

db-server/src/module-auth.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,34 @@ export function loadModuleAuth(projectRoot: string): ModuleAuthMap | null {
102102
return { tokens: parsed.tokens, secret: parsed.secret };
103103
}
104104

105+
/** 把当前 auth map 写回 data/module-tokens.json(热更新 token 时复用) */
106+
export function persistModuleAuth(projectRoot: string, auth: ModuleAuthMap, envAuthToken: string): void {
107+
const outFile = join(projectRoot, "data", "module-tokens.json");
108+
writeJson(outFile, {
109+
tokens: auth.tokens,
110+
secret: auth.secret,
111+
generatedAt: new Date().toISOString(),
112+
secretGenerated: !envAuthToken,
113+
} satisfies TokenStore);
114+
}
115+
116+
/**
117+
* 确保 moduleId 在 auth.tokens 中有派生值(复用现有 secret,勿重建随机 secret)。
118+
* @returns 是否新写入了 token
119+
*/
120+
export function ensureModuleToken(auth: ModuleAuthMap, moduleId: string): boolean {
121+
if (auth.tokens[moduleId]) return false;
122+
auth.tokens[moduleId] = deriveToken(moduleId, auth.secret);
123+
return true;
124+
}
125+
126+
/** 禁用时撤销内存中的 token(磁盘由 persistModuleAuth 同步) */
127+
export function revokeModuleToken(auth: ModuleAuthMap, moduleId: string): boolean {
128+
if (!(moduleId in auth.tokens)) return false;
129+
delete auth.tokens[moduleId];
130+
return true;
131+
}
132+
105133
/**
106134
* 校验请求方身份:
107135
* - Bearer 头 / X-Module-Token 二选一

db-server/src/routes/db-routes.ts

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,13 @@
33
*
44
* 端点:
55
* POST /api/sfmc/db/define-table body {name, columns, softDelete?} → {table, created}
6-
* POST /api/sfmc/db/tx body {steps[]} → TxResponse | TxError
6+
* POST /api/sfmc/db/tx body {steps[]} → TxResponse | TxError (批量)
7+
* POST /api/sfmc/db/tx/begin body {} → {txId}
8+
* POST /api/sfmc/db/tx/step body {txId, step} → {result}
9+
* POST /api/sfmc/db/tx/commit body {txId} → TxResponse
10+
* POST /api/sfmc/db/tx/rollback body {txId} → {ok}
711
* POST /api/sfmc/db/query body {table, opts?} → {rows}
8-
* POST /api/sfmc/db/get body {table, id} → {row}
9-
* POST /api/sfmc/db/insert body {table, row} → {row}
10-
* POST /api/sfmc/db/update body {table, id, patch} → {row}
11-
* POST /api/sfmc/db/delete body {table, id, hard?} → {changes}
12-
* POST /api/sfmc/db/audit body {table, rowId, action, data?} → {ok}
13-
* POST /api/sfmc/db/idempotent/probe body {action, key} → {replayed, cached?}
14-
* POST /api/sfmc/db/idempotent/commit body {action, key, value?} → {ok}
15-
*
16-
* 鉴权:handle() 校验后把 {id, permissions} 写到 ctx.moduleAuth(不挂 req)。
12+
* ...
1713
*/
1814

1915
import type { IncomingMessage, ServerResponse } from "node:http";
@@ -95,6 +91,60 @@ export function createDbRoutes(depsIn: Partial<DbRoutesDeps>) {
9591
return true;
9692
}
9793

94+
// 交互式事务会话 — 供 SDK 在回调内读回 query/get/call 结果
95+
if (path === "/api/sfmc/db/tx/begin") {
96+
try {
97+
const result = deps.txRunner!.beginSession(moduleId);
98+
json(res, result as unknown as Record<string, unknown>, result.ok ? 200 : 400);
99+
} catch (e) {
100+
jsonV2Fail(res, (e as Error).message, 500);
101+
}
102+
return true;
103+
}
104+
if (path === "/api/sfmc/db/tx/step") {
105+
try {
106+
const txId = String(body.txId || "");
107+
const step = body.step as TxStep | undefined;
108+
if (!txId || !step || typeof step !== "object" || !("op" in step)) {
109+
jsonV2Fail(res, "tx/step 需要 { txId, step }", 400);
110+
return true;
111+
}
112+
const result = await deps.txRunner!.stepSession(txId, moduleId, step);
113+
json(res, result as unknown as Record<string, unknown>, result.ok ? 200 : 400);
114+
} catch (e) {
115+
jsonV2Fail(res, (e as Error).message, 500);
116+
}
117+
return true;
118+
}
119+
if (path === "/api/sfmc/db/tx/commit") {
120+
try {
121+
const txId = String(body.txId || "");
122+
if (!txId) {
123+
jsonV2Fail(res, "tx/commit 需要 { txId }", 400);
124+
return true;
125+
}
126+
const result = deps.txRunner!.commitSession(txId, moduleId);
127+
json(res, result as unknown as Record<string, unknown>, result.ok ? 200 : 400);
128+
} catch (e) {
129+
jsonV2Fail(res, (e as Error).message, 500);
130+
}
131+
return true;
132+
}
133+
if (path === "/api/sfmc/db/tx/rollback") {
134+
try {
135+
const txId = String(body.txId || "");
136+
if (!txId) {
137+
jsonV2Fail(res, "tx/rollback 需要 { txId }", 400);
138+
return true;
139+
}
140+
const result = deps.txRunner!.rollbackSession(txId, moduleId);
141+
json(res, result as unknown as Record<string, unknown>, result.ok ? 200 : 400);
142+
} catch (e) {
143+
jsonV2Fail(res, (e as Error).message, 500);
144+
}
145+
return true;
146+
}
147+
98148
type SingleOp = "query" | "get" | "insert" | "update" | "delete";
99149
const singles: { match: RegExp; op: SingleOp }[] = [
100150
{ match: /^\/api\/sfmc\/db\/query$/, op: "query" },

db-server/src/runtime.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,101 @@ test("normalizeOrderBy: SDK field 与遗留 col / 数组互通(LSP)", async () =
130130
]);
131131
throws(() => normalizeOrderBy({ dir: "asc" }), /field\/col/);
132132
});
133+
134+
test("module-auth: ensureModuleToken / revoke 复用 secret(DRY)", async () => {
135+
const { deriveToken, ensureModuleToken, revokeModuleToken } = await import("./module-auth.js");
136+
const secret = "test-secret";
137+
const auth = { secret, tokens: {} as Record<string, string> };
138+
equal(ensureModuleToken(auth, "feature-afk"), true);
139+
equal(auth.tokens["feature-afk"], deriveToken("feature-afk", secret));
140+
equal(ensureModuleToken(auth, "feature-afk"), false);
141+
equal(revokeModuleToken(auth, "feature-afk"), true);
142+
equal(auth.tokens["feature-afk"], undefined);
143+
equal(revokeModuleToken(auth, "feature-afk"), false);
144+
});
145+
146+
test("builtin-handlers: 热禁用按 serviceNames 卸载(OCP)", async () => {
147+
const { BUILTIN_SERVICE_PLUGINS, unregisterBuiltinPluginForModule } = await import(
148+
"./services/builtin-handlers.js"
149+
);
150+
const economy = BUILTIN_SERVICE_PLUGINS.find((p) => p.moduleId === "feature-economy");
151+
equal(!!economy?.serviceNames?.includes("economy.account.get"), true);
152+
153+
const reg = new ServiceRegistry();
154+
reg.registerHandler("feature-economy", "economy.account.get", async () => ({}));
155+
reg.registerHandler("feature-economy", "economy.account.debit", async () => ({}));
156+
equal(unregisterBuiltinPluginForModule(reg, "feature-economy"), 2);
157+
equal(reg.list().length, 0);
158+
equal(unregisterBuiltinPluginForModule(reg, "feature-unknown"), 0);
159+
});
160+
161+
test("TxRunner 交互会话: step 中途读回 insert/get(PR #31 leftover)", async () => {
162+
const { DatabaseSync } = await import("node:sqlite");
163+
const { createQuery } = await import("./lib/sqlite.js");
164+
const { SchemaRegistry } = await import("./schema-registry.js");
165+
const { TxRunner } = await import("./tx-runner.js");
166+
167+
const db = new DatabaseSync(":memory:");
168+
const query = createQuery(db);
169+
const schema = new SchemaRegistry(db);
170+
schema.define("feature-demo", {
171+
name: "demo_items",
172+
columns: {
173+
id: { type: "text", primary: true },
174+
name: { type: "text", notNull: true },
175+
},
176+
softDelete: false,
177+
});
178+
179+
const enabled = new Map([
180+
[
181+
"feature-demo",
182+
{
183+
id: "feature-demo",
184+
version: "1.0.0",
185+
permissions: ["db:read:demo_items", "db:write:demo_items"],
186+
services: { provides: [], requires: [] },
187+
db: { tables: [] },
188+
config: { key: "demo" },
189+
} as unknown as import("./manifest-loader.js").ModuleManifestV2,
190+
],
191+
]);
192+
193+
const runner = new TxRunner({
194+
db,
195+
query,
196+
schema,
197+
serviceRegistry: new ServiceRegistry(),
198+
enabled,
199+
});
200+
201+
const begin = runner.beginSession("feature-demo");
202+
equal(begin.ok, true);
203+
if (!begin.ok) return;
204+
const { txId } = begin;
205+
206+
const ins = await runner.stepSession(txId, "feature-demo", {
207+
op: "insert",
208+
table: "demo_items",
209+
row: { id: "a1", name: "alpha" },
210+
});
211+
equal(ins.ok, true);
212+
if (!ins.ok) return;
213+
equal((ins.result as { op: string; row: { id: string } }).row.id, "a1");
214+
215+
const got = await runner.stepSession(txId, "feature-demo", {
216+
op: "get",
217+
table: "demo_items",
218+
id: "a1",
219+
});
220+
equal(got.ok, true);
221+
if (!got.ok) return;
222+
equal((got.result as { row: { name: string } | null }).row?.name, "alpha");
223+
224+
const committed = runner.commitSession(txId, "feature-demo");
225+
equal(committed.ok, true);
226+
227+
const rows = db.prepare("SELECT name FROM demo_items WHERE id = ?").all("a1") as Array<{ name: string }>;
228+
equal(rows[0]?.name, "alpha");
229+
db.close();
230+
});

db-server/src/services/builtin-handlers.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,25 @@ export type BuiltinServiceDeps = { query: QueryFn; db: DatabaseSync };
1515
export type BuiltinServicePlugin = {
1616
moduleId: string;
1717
register: (registry: ServiceRegistry, deps: BuiltinServiceDeps) => void;
18+
/** 与 register 写入的 handler 名对齐,供热禁用 unregister */
19+
serviceNames?: string[];
1820
};
1921

2022
/** 内置 service 插件清单 — 唯一扩展点 */
2123
export const BUILTIN_SERVICE_PLUGINS: BuiltinServicePlugin[] = [
22-
{ moduleId: "feature-economy", register: registerEconomyHandlers },
24+
{
25+
moduleId: "feature-economy",
26+
register: registerEconomyHandlers,
27+
serviceNames: [
28+
"economy.account.get",
29+
"economy.account.debit",
30+
"economy.account.credit",
31+
"economy.account.transfer",
32+
"economy.dailyTasks.list",
33+
"economy.dailyTasks.submit",
34+
"economy.stats.monthly",
35+
],
36+
},
2337
];
2438

2539
/** 按 enabledSet 注册内置插件,返回已注册插件数 */
@@ -36,3 +50,31 @@ export function registerEnabledBuiltinServices(
3650
}
3751
return n;
3852
}
53+
54+
/** 热启用:只注册单个内置插件(若尚未注册) */
55+
export function registerBuiltinPluginForModule(
56+
registry: ServiceRegistry,
57+
deps: BuiltinServiceDeps,
58+
moduleId: string
59+
): boolean {
60+
const plugin = BUILTIN_SERVICE_PLUGINS.find((p) => p.moduleId === moduleId);
61+
if (!plugin) return false;
62+
const already = (plugin.serviceNames ?? []).some((n) => registry.list().some((h) => h.name === n));
63+
if (already) return false;
64+
plugin.register(registry, deps);
65+
return true;
66+
}
67+
68+
/** 热禁用:卸掉该模块的内置 handler */
69+
export function unregisterBuiltinPluginForModule(registry: ServiceRegistry, moduleId: string): number {
70+
const plugin = BUILTIN_SERVICE_PLUGINS.find((p) => p.moduleId === moduleId);
71+
if (!plugin?.serviceNames) return 0;
72+
let n = 0;
73+
for (const name of plugin.serviceNames) {
74+
if (registry.list().some((h) => h.name === name)) {
75+
registry.unregisterHandler(name);
76+
n += 1;
77+
}
78+
}
79+
return n;
80+
}

0 commit comments

Comments
 (0)