Compare commits

...

2 Commits

Author SHA1 Message Date
uncle6me-web 179dd60571 fix(kbdb): 三元組版標庫批次補存量+對外通道(Arcrun#87 二次收尾)
藏書地圖(library-map.ts)讀的是三元組 record 自己的 'library' slot(entry_values),
不是 entries.metadata_json.library——Arcrun#85 建的 POST /entries/backfill-library
補的是後者,補了地圖依然是 0。既有能補三元組 library 值的通道只有單筆
PATCH /kbdb/records/:id(b6ef0f0,已在 main),母體上千筆時逐筆呼叫不現實,也沒有
節流/冪等保護。

新增:
- kbdb/src/actions/library-backfill.ts:backfillTripletLibraryTags +
  tripletLibraryBackfillStatus,安全原則與既有 entries 版逐條對齊——呼叫端決定
  library 值+source_prefix 篩選(base 不猜語意)、冪等(NOT EXISTS 找缺 library 的
  那半)、與 entries 版/embed reconcile 共用同一顆 D69 每日 D1 寫入額度、owner_id 必填、
  寫入沿用既有 updateRecord(不手刻第二套 entry_values UPSERT SQL)。
- kbdb/src/routes/records.ts:POST /records/backfill-library + GET .../status。
- cypher-executor/src/routes/kbdb-proxy.ts:對外通道,owner_id 強制用租戶身份(同本檔
  既有 POST /kbdb/records 慣例,不信任 caller 自帶 owner_id)。

測試:kbdb 新增 5 案(含跨租戶隔離/冪等/D69 共用額度截斷/status 統計),全套
218/218 綠燈;cypher-executor 新增 5 案(租戶閘/參數驗證/轉發/owner_id 覆蓋),
兩份既有失敗(auth.test.ts ExecutionContext 型別缺陷、portal-data/portal-admin/
console-library-map-page/executor.test.ts 訊息措辭)複驗與本次改動無關(stash 前後
一致,pre-existing)。

紅線:未併 main、未部署、未對 leo21c 執行任何實際 backfill(缺安全的寫入通道——
MCP 工具集無 update/patch record 能力,CLI 全域設定指著 leo21c 不可裸跑)。
批次執行是下一步,需總管/leo 決定執行方式與時機。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:12:56 +08:00
uncle6me-web 9ff933ba98 chore(builds): 重編成品——把 #108 的租戶修法放進執行檔
cypher source 10d150a → b223a698(#108 併入)。
重編時新的租戶來源閘第一行就報:
  ✔ 租戶來源檢查通過:cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts

⇒ 那道閘現在長在建置線上:以後有人再寫 env.XXX || 'leo',成品編不出來。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:25:46 +08:00
11 changed files with 580 additions and 85 deletions
+19 -15
View File
@@ -1,7 +1,12 @@
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
var __esm = (fn, res, err2) => function __init() {
if (err2) throw err2[0];
try {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
} catch (e) {
throw err2 = [e], e;
}
};
var __export = (target, all) => {
for (var name in all)
@@ -102,7 +107,7 @@ function applyBaseRuntimeOptions(runtime, options) {
function applyModuleEvalRuntimeOptions(runtime, options) {
options.moduleLoader && runtime.setModuleLoader(options.moduleLoader), options.shouldInterrupt && runtime.setInterruptHandler(options.shouldInterrupt), options.memoryLimitBytes !== void 0 && runtime.setMemoryLimit(options.memoryLimitBytes), options.maxStackSizeBytes !== void 0 && runtime.setMaxStackSize(options.maxStackSizeBytes);
}
var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, UnstableSymbol, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule;
var __defProp2, __export2, QTS_DEBUG, errors_exports, QuickJSUnwrapError, QuickJSWrongOwner, QuickJSUseAfterFree, QuickJSNotImplemented, QuickJSAsyncifyError, QuickJSAsyncifySuspended, QuickJSMemoryLeakDetected, QuickJSEmscriptenModuleError, QuickJSUnknownIntrinsic, QuickJSPromisePending, QuickJSEmptyGetOwnPropertyNames, AwaitYield, UsingDisposable, SymbolDispose, prototypeAsAny, Lifetime, StaticLifetime, WeakLifetime, Scope, AbstractDisposableResult, DisposableSuccess, DisposableFail, DisposableResult, QuickJSDeferredPromise, ModuleMemory, DefaultIntrinsics, QuickJSIterator, ContextMemory, QuickJSContext, QuickJSRuntime, QuickJSEmscriptenModuleCallbacks, QuickJSModuleCallbacks, QuickJSWASMModule;
var init_chunk_JTKJZQYV = __esm({
"registry/components/code/node_modules/quickjs-emscripten-core/dist/chunk-JTKJZQYV.mjs"() {
init_dist();
@@ -190,7 +195,7 @@ var init_chunk_JTKJZQYV = __esm({
return this.dispose();
}
};
SymbolDispose = Symbol.dispose ?? Symbol.for("Symbol.dispose");
SymbolDispose = Symbol.dispose ?? /* @__PURE__ */ Symbol.for("Symbol.dispose");
prototypeAsAny = UsingDisposable.prototype;
prototypeAsAny[SymbolDispose] || (prototypeAsAny[SymbolDispose] = function() {
return this.dispose();
@@ -409,7 +414,6 @@ Lifetime used`) : new QuickJSUseAfterFree("Lifetime not alive");
return this.module._free(ptr), str;
}
};
UnstableSymbol = Symbol("Unstable");
DefaultIntrinsics = Object.freeze({ BaseObjects: true, Date: true, Eval: true, StringNormalize: true, RegExp: true, JSON: true, Proxy: true, MapSet: true, TypedArrays: true, Promise: true });
QuickJSIterator = class extends UsingDisposable {
constructor(handle, context) {
@@ -773,7 +777,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
}
return result.value;
}
[Symbol.for("nodejs.util.inspect.custom")]() {
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
return this.alive ? `${this.constructor.name} { ctx: ${this.ctx.value} rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
}
getFunction(fn_id) {
@@ -909,7 +913,7 @@ ${cause.stack}Host: ${hostStack}`), Object.assign(exception, rest), exception;
debugLog(...msg) {
this._debugMode && console.log("quickjs-emscripten:", ...msg);
}
[Symbol.for("nodejs.util.inspect.custom")]() {
[/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() {
return this.alive ? `${this.constructor.name} { rt: ${this.rt.value} }` : `${this.constructor.name} { disposed }`;
}
getSystemContext() {
@@ -1339,10 +1343,10 @@ async function QuickJSRaw(moduleArg = {}) {
x ? (0 === h && (h = ra()), g[m] = x(e[m])) : g[m] = e[m];
}
b = a(...g);
return b = function(k) {
return b = (function(k) {
0 !== h && sa(h);
return "string" === d ? R(k) : "boolean" === d ? !!k : k;
}(b);
})(b);
};
c.wasmMemory ? r = c.wasmMemory : r = new WebAssembly.Memory({ initial: (c.INITIAL_MEMORY || 16777216) / 65536, maximum: 32768 });
K();
@@ -1464,7 +1468,7 @@ async function QuickJSRaw(moduleArg = {}) {
}, t: function(a, d) {
c.callbacks.freeHostRef(void 0, a, d);
} }, Z;
Z = await async function() {
Z = await (async function() {
function a(b) {
b = Z = b.exports;
c._malloc = b.v;
@@ -1551,7 +1555,7 @@ async function QuickJSRaw(moduleArg = {}) {
});
M ??= c.locateFile ? c.locateFile ? c.locateFile("emscripten-module.wasm", u) : u + "emscripten-module.wasm" : new URL("emscripten-module.wasm", import.meta.url).href;
return a((await ea(d)).instance);
}();
})();
(function() {
function a() {
c.calledRun = true;
@@ -3034,7 +3038,7 @@ var Hono = class _Hono {
var emptyParam = [];
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -3046,7 +3050,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -4008,7 +4012,7 @@ app.post("/", async (c) => {
);
}
});
var code_default = app;
var index_default = app;
export {
code_default as default
index_default as default
};
@@ -1,7 +1,12 @@
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
var __esm = (fn, res, err) => function __init() {
if (err) throw err[0];
try {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
} catch (e) {
throw err = [e], e;
}
};
var __export = (target, all) => {
for (var name in all)
@@ -1501,7 +1506,7 @@ var init_hono_base = __esm({
// cypher-executor/node_modules/.pnpm/hono@4.12.10/node_modules/hono/dist/router/reg-exp-router/matcher.js
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -1513,7 +1518,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -7433,7 +7438,7 @@ var init_lib = __esm({
...processCreateParams(params)
});
};
BRAND = Symbol("zod_brand");
BRAND = /* @__PURE__ */ Symbol("zod_brand");
ZodBranded = class extends ZodType {
_parse(input) {
const { ctx } = this._processInputParams(input);
@@ -7607,14 +7612,14 @@ var init_lib = __esm({
onumber = () => numberType().optional();
oboolean = () => booleanType().optional();
coerce = {
string: (arg) => ZodString.create({ ...arg, coerce: true }),
number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
boolean: (arg) => ZodBoolean.create({
string: ((arg) => ZodString.create({ ...arg, coerce: true })),
number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
boolean: ((arg) => ZodBoolean.create({
...arg,
coerce: true
}),
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
date: (arg) => ZodDate.create({ ...arg, coerce: true })
})),
bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
};
NEVER = INVALID;
z = /* @__PURE__ */ Object.freeze({
@@ -7835,6 +7840,7 @@ var init_recipe_loader = __esm({
super(message);
this.recipe = recipe;
}
recipe;
};
}
});
@@ -8841,7 +8847,7 @@ function recordRecipeStats(env, recipeKeys, ok, at, ctx) {
)
).then(() => void 0);
if (ctx?.waitUntil) ctx.waitUntil(promise);
else ;
else void promise;
}
function generateToken() {
const tokenBytes = crypto.getRandomValues(new Uint8Array(16));
@@ -8883,7 +8889,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
result.trace
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else ;
else void statsPromise;
}
return { success: true, data: result.data, duration_ms };
} catch (err) {
@@ -8907,7 +8913,7 @@ async function executeWebhookGraph(env, graph, triggerContext, token, apiKey, ct
err.trace
);
if (ctx?.waitUntil) ctx.waitUntil(statsPromise);
else ;
else void statsPromise;
}
if (err instanceof ExecutionError) {
const traceFormatted = err.trace.map((s) => ({
@@ -9374,6 +9380,7 @@ function extractTarget(input) {
return typeof raw2 === "string" ? raw2 : JSON.stringify(raw2);
}
async function writeExecutionVerdict(env, workflowId, nodes, verdict, durationMs, message, input, apiKey) {
void nodes;
try {
const { base, headers } = kbdbBase(env);
await fetch(`${base}/execution-log/record`, {
@@ -15654,10 +15661,10 @@ app.route("/", consoleAuthRouter);
app.route("/", consoleDashboardRouter);
app.route("/", portalRouter);
app.route("/", portalDataRouter);
var src_default = {
var index_default = {
fetch: app.fetch,
scheduled: handleScheduled
};
export {
src_default as default
index_default as default
};
@@ -1427,7 +1427,7 @@ var Hono = class _Hono {
var emptyParam = [];
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -1439,7 +1439,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -2522,7 +2522,7 @@ app.post("/", async (c) => {
);
}
});
var src_default = app;
var index_default = app;
async function runWasm(input) {
const hostFunctions = {
http_request: async (url, method, headersJson, body) => {
@@ -2568,5 +2568,5 @@ async function runWasm(input) {
return JSON.parse(stdout);
}
export {
src_default as default
index_default as default
};
+4 -4
View File
@@ -1444,7 +1444,7 @@ var Hono = class _Hono {
var emptyParam = [];
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -1456,7 +1456,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -4157,7 +4157,7 @@ app.route("/recipe-stats", recipeStatRoutes);
app.route("/execution-log", executionLogRoutes);
app.route("/embed", embedRoutes);
app.route("/map", mapRoutes);
var src_default = app;
var index_default = app;
export {
src_default as default
index_default as default
};
+37 -34
View File
@@ -5,7 +5,11 @@ var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
try {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
} catch (e) {
throw mod = 0, e;
}
};
var __export = (target, all) => {
for (var name in all)
@@ -8223,7 +8227,7 @@ var Hono = class _Hono {
var emptyParam = [];
function match(method, path) {
const matchers = this.buildAllMatchers();
const match2 = (method2, path2) => {
const match2 = ((method2, path2) => {
const matcher = matchers[method2] || matchers[METHOD_NAME_ALL];
const staticMatch = matcher[2][path2];
if (staticMatch) {
@@ -8235,7 +8239,7 @@ function match(method, path) {
}
const index = match3.indexOf("", 1);
return [matcher[1][index], match3];
};
});
this.match = match2;
return match2(method, path);
}
@@ -12959,7 +12963,7 @@ ZodNaN.create = (params) => {
...processCreateParams(params)
});
};
var BRAND = Symbol("zod_brand");
var BRAND = /* @__PURE__ */ Symbol("zod_brand");
var ZodBranded = class extends ZodType {
_parse(input) {
const { ctx } = this._processInputParams(input);
@@ -13161,14 +13165,14 @@ var ostring = () => stringType().optional();
var onumber = () => numberType().optional();
var oboolean = () => booleanType().optional();
var coerce = {
string: (arg) => ZodString.create({ ...arg, coerce: true }),
number: (arg) => ZodNumber.create({ ...arg, coerce: true }),
boolean: (arg) => ZodBoolean.create({
string: ((arg) => ZodString.create({ ...arg, coerce: true })),
number: ((arg) => ZodNumber.create({ ...arg, coerce: true })),
boolean: ((arg) => ZodBoolean.create({
...arg,
coerce: true
}),
bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }),
date: (arg) => ZodDate.create({ ...arg, coerce: true })
})),
bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })),
date: ((arg) => ZodDate.create({ ...arg, coerce: true }))
};
var NEVER = INVALID;
@@ -13219,7 +13223,6 @@ function $constructor(name, initializer3, params) {
Object.defineProperty(_, "name", { value: name });
return _;
}
var $brand = Symbol("zod_brand");
var $ZodAsyncError = class extends Error {
constructor() {
super(`Encountered Promise during synchronous parse. Use .parseAsync() instead.`);
@@ -15723,8 +15726,6 @@ function en_default2() {
}
// mcp/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v4/core/registries.js
var $output = Symbol("ZodOutput");
var $input = Symbol("ZodInput");
var $ZodRegistry = class {
constructor() {
this._map = /* @__PURE__ */ new Map();
@@ -17002,10 +17003,10 @@ var ZodMiniType = /* @__PURE__ */ $constructor("ZodMiniType", (inst, def) => {
};
inst.clone = (_def, params) => clone(inst, _def, params);
inst.brand = () => inst;
inst.register = (reg, meta) => {
inst.register = ((reg, meta) => {
reg.add(inst, meta);
return inst;
};
});
});
var ZodMiniObject = /* @__PURE__ */ $constructor("ZodMiniObject", (inst, def) => {
$ZodObject.init(inst, def);
@@ -17268,10 +17269,10 @@ var ZodType2 = /* @__PURE__ */ $constructor("ZodType", (inst, def) => {
};
inst.clone = (def2, params) => clone(inst, def2, params);
inst.brand = () => inst;
inst.register = (reg, meta) => {
inst.register = ((reg, meta) => {
reg.add(inst, meta);
return inst;
};
});
inst.parse = (data, params) => parse2(inst, data, params, { callee: inst.parse });
inst.safeParse = (data, params) => safeParse3(inst, data, params);
inst.parseAsync = async (data, params) => parseAsync2(inst, data, params, { callee: inst.parseAsync });
@@ -19243,11 +19244,13 @@ function assertCompleteRequestPrompt(request) {
if (request.params.ref.type !== "ref/prompt") {
throw new TypeError(`Expected CompleteRequestPrompt, but got ${request.params.ref.type}`);
}
void request;
}
function assertCompleteRequestResourceTemplate(request) {
if (request.params.ref.type !== "ref/resource") {
throw new TypeError(`Expected CompleteRequestResourceTemplate, but got ${request.params.ref.type}`);
}
void request;
}
var CompleteResultSchema = ResultSchema.extend({
completion: looseObject({
@@ -19400,7 +19403,7 @@ function isTerminal(status) {
}
// mcp/node_modules/.pnpm/zod-to-json-schema@3.25.2_zod@3.25.76/node_modules/zod-to-json-schema/dist/esm/Options.js
var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use");
var ignoreOverride = /* @__PURE__ */ Symbol("Let zodToJsonSchema decide on which parser to use");
var defaultOptions = {
name: void 0,
$refStrategy: "root",
@@ -22376,7 +22379,7 @@ var Server = class extends Protocol {
};
// mcp/node_modules/.pnpm/@modelcontextprotocol+sdk@1.29.0_zod@3.25.76/node_modules/@modelcontextprotocol/sdk/dist/esm/server/completable.js
var COMPLETABLE_SYMBOL = Symbol.for("mcp.completable");
var COMPLETABLE_SYMBOL = /* @__PURE__ */ Symbol.for("mcp.completable");
function isCompletable(schema4) {
return !!schema4 && typeof schema4 === "object" && COMPLETABLE_SYMBOL in schema4;
}
@@ -24691,13 +24694,13 @@ function registerAllIntrospectionTools(server, env) {
}
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/nodes/identity.js
var ALIAS = Symbol.for("yaml.alias");
var DOC = Symbol.for("yaml.document");
var MAP = Symbol.for("yaml.map");
var PAIR = Symbol.for("yaml.pair");
var SCALAR = Symbol.for("yaml.scalar");
var SEQ = Symbol.for("yaml.seq");
var NODE_TYPE = Symbol.for("yaml.node.type");
var ALIAS = /* @__PURE__ */ Symbol.for("yaml.alias");
var DOC = /* @__PURE__ */ Symbol.for("yaml.document");
var MAP = /* @__PURE__ */ Symbol.for("yaml.map");
var PAIR = /* @__PURE__ */ Symbol.for("yaml.pair");
var SCALAR = /* @__PURE__ */ Symbol.for("yaml.scalar");
var SEQ = /* @__PURE__ */ Symbol.for("yaml.seq");
var NODE_TYPE = /* @__PURE__ */ Symbol.for("yaml.node.type");
var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS;
var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC;
var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
@@ -24727,9 +24730,9 @@ function isNode(node) {
var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor;
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/visit.js
var BREAK = Symbol("break visit");
var SKIP = Symbol("skip children");
var REMOVE = Symbol("remove node");
var BREAK = /* @__PURE__ */ Symbol("break visit");
var SKIP = /* @__PURE__ */ Symbol("skip children");
var REMOVE = /* @__PURE__ */ Symbol("remove node");
function visit(node, visitor) {
const visitor_ = initVisitor(visitor);
if (isDocument(node)) {
@@ -29322,9 +29325,9 @@ ${end.comment}` : end.comment;
};
// mcp/node_modules/.pnpm/yaml@2.9.0/node_modules/yaml/browser/dist/parse/cst-visit.js
var BREAK2 = Symbol("break visit");
var SKIP2 = Symbol("skip children");
var REMOVE2 = Symbol("remove item");
var BREAK2 = /* @__PURE__ */ Symbol("break visit");
var SKIP2 = /* @__PURE__ */ Symbol("skip children");
var REMOVE2 = /* @__PURE__ */ Symbol("remove item");
function visit2(cst, visitor) {
if ("type" in cst && cst.type === "document")
cst = { start: cst.start, value: cst.value };
@@ -33596,7 +33599,7 @@ app.post("/", partnerAuthMiddleware, async (c) => {
const identity = resolveKnowledgeIdentity(c.get("auth_path"), c.get("portal"));
return handleMcpRequest(c.req.raw, c.env, orgNamespace, partnerToken, identity);
});
var src_default = _app;
var index_default = _app;
export {
src_default as default
index_default as default
};
+12 -12
View File
@@ -1,8 +1,8 @@
{
"schema": 1,
"built_for": "arcrun-tier2-worker-artifacts",
"generated_at": "2026-08-12T16:16:33.433Z",
"repo_head": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
"generated_at": "2026-08-12T16:25:19.210Z",
"repo_head": "2fcae722e7d7eab3c8b39744b87ae1344d23ba2a",
"repo_dirty": false,
"workers": [
{
@@ -11,8 +11,8 @@
"source_commit": "b223a698844be289c1b01f99eb34a8e2ac85bb74",
"main_module": "worker.mjs",
"main_file": "arcrun-cypher-executor/worker.mjs",
"js_bytes": 588397,
"content_sha256": "e0026a23792f8b6b02e35c91081604b9a761c608a2cae0e6ee8ab0f34a484501",
"js_bytes": 588587,
"content_sha256": "c43728d21251f7835497d7dc40a3e702526a70b33406b617ed64126ffb16e1e0",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -61,8 +61,8 @@
"source_commit": "f87d0e92f49690253e7c89c5badc82a08eb5d21b",
"main_module": "worker.mjs",
"main_file": "arcrun-kbdb/worker.mjs",
"js_bytes": 149791,
"content_sha256": "9c6d41895d78cbf3048fb539690591c86071b7d6ebf7fb0b8b51607c6b1f8ee9",
"js_bytes": 149797,
"content_sha256": "8b23853cbc88aee0ca15ef20ca46e92bd8e75064cd311af2847f4d51811960b1",
"modules": [],
"compat_date": "2025-02-19",
"compat_flags": [
@@ -90,8 +90,8 @@
"source_commit": "1e85dfb49b0e8d81c0854781d93ee4e6a300c7b3",
"main_module": "worker.mjs",
"main_file": "arcrun-http-request/worker.mjs",
"js_bytes": 80073,
"content_sha256": "9a9dcb71879a7bdfd9fec1bd94eb9742e12cb63733d822ce63eeb1be30008d15",
"js_bytes": 80079,
"content_sha256": "cdd97364f277587cbade69e09bb40812c68f26a1e8bc9aa632c65b1b962b0b85",
"modules": [
{
"name": "component.wasm",
@@ -122,8 +122,8 @@
"source_commit": "621cb8d948d61be6202063fd02effb3f538437fe",
"main_module": "worker.mjs",
"main_file": "arcrun-code/worker.mjs",
"js_bytes": 153671,
"content_sha256": "285a7406ec694ae47dccfaf48517f712c74d207a1689dffa15c39f1555b45be5",
"js_bytes": 153758,
"content_sha256": "751634a3fc9a99cc2da662026818d754c48f031d10bff3b3be2d3a8ee2311bd6",
"modules": [
{
"name": "quickjs.wasm",
@@ -151,8 +151,8 @@
"source_commit": "10d150ac2b4385af95a457f3c411430c4a146cf9",
"main_module": "worker.mjs",
"main_file": "arcrun-mcp/worker.mjs",
"js_bytes": 1179229,
"content_sha256": "3ebc0d441bc04ae56a1205507da701f93bed9efa9b1e53c1777c04cbef5bdb67",
"js_bytes": 1179487,
"content_sha256": "1cd4c4d079d72bf7cba7c490ba6a88476f70b3ea51af7e5c93f9a184ae3c0ce6",
"modules": [],
"compat_date": "2024-11-27",
"compat_flags": [
+39
View File
@@ -111,6 +111,45 @@ kbdbProxyRouter.get('/kbdb/records/by-template/:template', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// POST /kbdb/records/backfill-library — 三元組版標庫補存量對外通道(Arcrun#87 二次收尾,2026-08-13)。
// 基本盤(kbdb/src/routes/records.ts)的 POST /records/backfill-library 缺對外通道——同
// PATCH /kbdb/records/:recordId 那條的破口(能力在 base,插件/工作流打不到)。純轉發,
// owner_id 強制用租戶身份(同本檔 POST /kbdb/records 的既有慣例,不信任 caller 自帶 owner_id)。
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
kbdbProxyRouter.post('/kbdb/records/backfill-library', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const body = await c.req.json().catch(() => null);
if (!body || !body.library) return c.json({ error: 'library 必填' }, 400);
const { base, headers } = kbdbBase(c.env);
const res = await fetch(`${base}/records/backfill-library`, {
method: 'POST',
headers,
body: JSON.stringify({
library: body.library,
owner_id: owner,
triplet_template: body.triplet_template,
source_prefix: body.source_prefix,
limit: body.limit,
}),
});
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// GET /kbdb/records/backfill-library/status — 待補標統計(三元組版),owner_id 同樣強制用租戶身份。
kbdbProxyRouter.get('/kbdb/records/backfill-library/status', async (c) => {
const owner = tenant(c);
if (!owner) return c.json(NEED_KEY, 401);
const { base, headers } = kbdbBase(c.env);
const params = new URLSearchParams({ owner_id: owner });
for (const k of ['triplet_template', 'source_prefix']) {
const v = c.req.query(k);
if (v) params.set(k, v);
}
const res = await fetch(`${base}/records/backfill-library/status?${params.toString()}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// GET /kbdb/records/:recordId — 取單筆 record。
kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
@@ -0,0 +1,100 @@
/**
* POST /kbdb/records/backfill-library + GET .../status proxy Arcrun#87 2026-08-13
*
* kbdb/src/routes/records.ts
* backfillTripletLibraryTags record 'library' slot
* entries backfill-library metadata_json.library cypher proxy
* kbdb-records-patch-proxy.test.ts base
* /owner_id caller owner_id
* POST /kbdb/records
*
* IO KBDB kbdb-records-patch-proxy.test.ts
* 1. X-Arcrun-API-Key 401 KBDB
* 2. body library 400
* 3. owner_id 使 caller owner_id
* 4. GET statusowner_id query
*
* KBDB fetchMock hostdisableNetConnect
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('POST /kbdb/records/backfill-library — 租戶閘', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library: 'arcrun' }),
});
expect(res.status).toBe(401);
});
});
describe('POST /kbdb/records/backfill-library — 參數驗證', () => {
it('body 沒有 library → 400,不轉發', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ source_prefix: 'gitea:Leo/Arcrun@' }),
});
expect(res.status).toBe(400);
});
});
describe('POST /kbdb/records/backfill-library — 轉發', () => {
it('owner_id 一律用租戶身份覆蓋,即使 caller 自帶了別的 owner_id', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records/backfill-library',
method: 'POST',
body: JSON.stringify({
library: 'arcrun',
owner_id: 'leo', // 來自 X-Arcrun-API-Key,不是 body 裡的 'someone-else'
triplet_template: undefined,
source_prefix: 'gitea:Leo/Arcrun@',
limit: 200,
}),
})
.reply(200, { success: true, library: 'arcrun', scanned: 208, tagged: 200, remaining: 8, quota_limit: 2000, quota_used_today: 200, quota_exceeded: true });
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ library: 'arcrun', owner_id: 'someone-else', source_prefix: 'gitea:Leo/Arcrun@', limit: 200 }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; tagged: number; remaining: number };
expect(data.success).toBe(true);
expect(data.tagged).toBe(200);
expect(data.remaining).toBe(8);
});
});
describe('GET /kbdb/records/backfill-library/status — 租戶閘 + 轉發', () => {
it('無 X-Arcrun-API-Key → 401', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@');
expect(res.status).toBe(401);
});
it('owner_id 強制用租戶身份,其餘 query 參數透傳', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/records/backfill-library/status?owner_id=leo&source_prefix=gitea%3ALeo%2FArcrun%40', method: 'GET' })
.reply(200, { success: true, pending: 8 });
const res = await SELF.fetch('http://localhost/kbdb/records/backfill-library/status?source_prefix=gitea:Leo/Arcrun@', {
headers: KEY,
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; pending: number };
expect(data.success).toBe(true);
expect(data.pending).toBe(8);
});
});
+129
View File
@@ -29,6 +29,8 @@
// 這件事不只管向量化,也要管補標,否則做標庫時就會把補算的閘繞過去」)。
import type { Bindings } from '../types';
import { maintenanceBudgetToday, addMaintenanceUsage } from './maintenance-quota';
import { updateRecord } from './record-crud';
import { ensureTripletLibrarySlot, DEFAULT_TRIPLET_TEMPLATE } from './library-map';
// IN 清單長度上限(避開 D1/SQLite bound-parameter 上限;一次點名這麼多張卡已經很夠用,
// 呼叫端清單更長就自然分批呼叫,跟 limit 分頁是同一種節奏)。
@@ -166,3 +168,130 @@ export async function libraryBackfillStatus(
.first<{ c: number }>();
return { pending: row?.c ?? 0 };
}
// ── 三元組(triplet)版:藏書地圖真正讀的那一半(Arcrun#87 二次收尾,2026-08-13)──────────
//
// 上面 backfillEntryLibraryTags 補的是 entries.metadata_json.$.library(卡片/搜尋/embed 層)。
// 藏書地圖(library-map.ts 的 recomputeLibraryMapliveTripletCountsByLibrary)讀的是**三元組
// record 自己的 'library' slot**entry_values,經 record-crud 的 updateRecord 寫入)——兩者是
// 兩個互不相干的存放處(票上 2026-08-11 14:21 comment「庫值有兩個互不相干的存放處」段已釐清),
// 補了前者地圖依然是 0。
//
// 既有通道只有「單筆 PATCH /kbdb/records/:id」(b6ef0f02026-08-11)——沒有批次版本。
// 補標母體上千筆時逐筆 PATCH 不現實(也不安全:呼叫端要自己刻節流/冪等,容易漏做)。
// 本函式是三元組版的批次 backfill,安全原則與上面 entries 版逐條對齊,不重新發明:
// - 呼叫端決定 library 值+篩選條件(base 對內容語意無知,不猜哪個 source_uri 該歸哪個庫)
// - 冪等:只選「目前沒有 library slot 值」的候選(NOT EXISTS 找缺 library 的那半,同 entries
// 版用「library 為空」而非覆蓋已標記過的)
// - D69 節流:與 entries 版、embed reconcile 共用同一顆每日 D1 寫入額度計數器(不共用會被繞過)
// - owner_id 必填(2026-08-11 leo 直令:批次改一大片既有資料不准無租戶範圍地掃)
// - 寫入沿用既有 updateRecordrecord-crud.ts)——不手刻第二套 entry_values UPSERT SQL
// 這條寫入路徑已經被 triplet-library-backfill.test.ts 驗證過語意正確(源頭順序/存量補標/
// 冪等三案),本函式只是把它包成「呼叫端給 library+source_prefix,一次處理一批」的批次版。
export interface TripletLibraryBackfillCriteria {
owner_id?: string;
triplet_template?: string; // 預設 DEFAULT_TRIPLET_TEMPLATE'triplet'
source_prefix?: string; // source_uri LIKE prefix%(本票的規則:^gitea:Leo/<repo>@ → 各庫;^kb:// → kb
}
function tripletCriteriaSql(c: TripletLibraryBackfillCriteria & { owner_id: string }): { where: string; params: unknown[] } {
// 候選:這個 template 底下、有 source_uri 值、owner 符合、source_uri 符合前綴、
// 且目前這個 record 沒有任何 'library' slot 值的 record_idNOT EXISTS 保冪等)。
const conds = [
'ev.slot_name = ?',
'e.content LIKE ? || \'%\'',
'e.owner_id = ?',
`NOT EXISTS (SELECT 1 FROM entry_values lev WHERE lev.record_id = ev.record_id AND lev.slot_name = 'library')`,
];
const params: unknown[] = ['source_uri', c.source_prefix ?? '', c.owner_id];
return { where: conds.join(' AND '), params };
}
/**
* library record target library
* + limit + budget entries /embed reconcile D1
* daemon#87 source_uri library
* recordbase backfillEntryLibraryTags
*/
export async function backfillTripletLibraryTags(
db: D1Database,
env: Pick<Bindings, 'KBDB_MAINTENANCE_DAILY_WRITE_LIMIT'>,
opts: { library: string; owner_id: string; limit?: number } & TripletLibraryBackfillCriteria,
): Promise<LibraryBackfillResult> {
const library = (opts.library ?? '').trim();
if (!library) throw new Error('library required');
const ownerId = (opts.owner_id ?? '').trim();
if (!ownerId) throw new Error('owner_id required(標庫是跨大量既有資料的批次寫入,不准無租戶範圍地掃全庫——2026-08-11 leo 直令)');
const limit = Math.min(Math.max(opts.limit ?? 100, 1), HARD_LIMIT_CAP);
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
// 冪等地確保 template 有 library slot(同 recomputeLibraryMap 的既有慣例,不動表)。
await ensureTripletLibrarySlot(db, tripletTemplateName);
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
if (!tpl) throw new Error(`triplet template not found: ${tripletTemplateName}`);
const sel = tripletCriteriaSql({ ...opts, owner_id: ownerId });
const res = await db
.prepare(
`SELECT ev.record_id AS id FROM entry_values ev JOIN entries e ON ev.entry_id = e.id
WHERE ev.template_id = ? AND ${sel.where} ORDER BY ev.record_id ASC LIMIT ?`,
)
.bind(tpl.id, ...sel.params, limit)
.all<{ id: string }>();
const scannedIds = (res.results ?? []).map((r) => r.id);
const scanned = scannedIds.length;
// D69:額度截斷——每個候選最多 1 次 D1 writeupdateRecord 對「缺 slot」的 grow 路徑正是 1 次
// INSERT),與 entries 版/reconcile 共用同一顆計數器。
const budget = await maintenanceBudgetToday(env, db);
const ids = scannedIds.slice(0, budget.remaining);
const quotaExceeded = scanned > ids.length;
let tagged = 0;
for (const id of ids) {
const updated = await updateRecord(db, id, { library });
if (updated) tagged += 1;
}
try {
await addMaintenanceUsage(db, tagged);
} catch {
// fail-open:額度計數寫入失敗不影響已經完成的標庫寫入(精神同 embed.ts 的做法)。
}
const remRow = await db
.prepare(
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
)
.bind(tpl.id, ...sel.params)
.first<{ c: number }>();
return {
library,
scanned,
tagged,
remaining: remRow?.c ?? 0,
quota_limit: budget.limit,
quota_used_today: budget.used + tagged,
quota_exceeded: quotaExceeded,
};
}
/** 待補標統計(三元組版,回報用):符合條件、目前未標記 library 的三元組筆數。 */
export async function tripletLibraryBackfillStatus(
db: D1Database,
opts: TripletLibraryBackfillCriteria & { owner_id: string },
): Promise<{ pending: number }> {
const tripletTemplateName = opts.triplet_template ?? DEFAULT_TRIPLET_TEMPLATE;
const tpl = await db.prepare(`SELECT id FROM templates WHERE name = ?`).bind(tripletTemplateName).first<{ id: string }>();
if (!tpl) return { pending: 0 };
const sel = tripletCriteriaSql(opts);
const row = await db
.prepare(
`SELECT COUNT(*) as c FROM entry_values ev JOIN entries e ON ev.entry_id = e.id WHERE ev.template_id = ? AND ${sel.where}`,
)
.bind(tpl.id, ...sel.params)
.first<{ c: number }>();
return { pending: row?.c ?? 0 };
}
+49
View File
@@ -2,6 +2,7 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud';
import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../actions/library-backfill';
export const recordRoutes = new Hono<{ Bindings: Bindings }>();
@@ -57,6 +58,54 @@ recordRoutes.get('/by-template/:template', async (c) => {
return c.json({ success: true, records, count: records.length });
});
// POST /records/backfill-library — 三元組版標庫補存量(Arcrun#87 二次收尾,2026-08-13)。
// entries 版(POST /entries/backfill-libraryArcrun#85)補的是卡片層 metadata_json.library
// 藏書地圖讀的是三元組 record 自己的 'library' slot,兩者是不同存放處——本端點補後者,
// 地圖(GET /map)才會真的從 0 變成有意義的數字。
// body(必填 library + owner_id):{ library, owner_id, triplet_template?(預設 'triplet',
// source_prefix?(必要篩選:本票的規則=gitea:Leo/<repo>@ 前綴 → 對應庫;kb:// → 'kb',
// limit?(1-500,預設100) }。
// 冪等:只選「目前沒有 library slot 值」的三元組;分批:單次 limit 上限,remaining>0 → 重複呼叫直到 0。
// budget:與 /entries/backfill-library、/embed/reconcile 共用同一顆每日 D1 寫入額度(D69)。
// 此路由必須在 '/:recordId' 之前註冊,否則 'backfill-library' 會被當成 recordId 參數。
recordRoutes.post('/backfill-library', async (c) => {
const body = (await c.req.json().catch(() => ({}))) as {
library?: string;
owner_id?: string;
triplet_template?: string;
source_prefix?: string;
limit?: number | string;
};
const library = String(body.library ?? '').trim();
const ownerId = String(body.owner_id ?? '').trim();
if (!library || !ownerId) return c.json({ success: false, error: 'library 與 owner_id 必填' }, 400);
try {
const result = await backfillTripletLibraryTags(c.env.DB, c.env, {
library,
owner_id: ownerId,
triplet_template: body.triplet_template || undefined,
source_prefix: body.source_prefix || undefined,
limit: body.limit !== undefined ? Number(body.limit) : undefined,
});
return c.json({ success: true, ...result });
} catch (e) {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400);
}
});
// GET /records/backfill-library/status?owner_id=&triplet_template=&source_prefix=
// — 符合條件、目前未標記 library 的三元組筆數(backfill 前後都能查,判斷還剩多少)。
recordRoutes.get('/backfill-library/status', async (c) => {
const ownerId = c.req.query('owner_id') || '';
if (!ownerId) return c.json({ success: false, error: 'owner_id 必填' }, 400);
const status = await tripletLibraryBackfillStatus(c.env.DB, {
owner_id: ownerId,
triplet_template: c.req.query('triplet_template') || undefined,
source_prefix: c.req.query('source_prefix') || undefined,
});
return c.json({ success: true, ...status });
});
// GET /records/:recordId
recordRoutes.get('/:recordId', async (c) => {
const rec = await getRecord(c.env.DB, c.req.param('recordId'));
@@ -0,0 +1,164 @@
// 三元組版標庫「批次」backfillArcrun#87 二次收尾,2026-08-13)測試。
//
// 背景:kbdb/tests/triplet-library-backfill.test.ts2026-08-11)已經證明「逐筆 updateRecord
// 補標庫」這條寫入路徑語意正確(源頭順序/存量補標/冪等三案)。但那份測試操作的是逐筆手刻的
// backfillPass() helper,不是可對外呼叫的批次能力——通道只有單筆 PATCH /kbdb/records/:id
// (b6ef0f0),母體上千筆時逐筆 PATCH 給呼叫端自己刻節流/冪等不現實。本檔驗證新加的
// backfillTripletLibraryTagskbdb/src/actions/library-backfill.ts):把「呼叫端給 library
// + source_prefix,一次處理一批」包成一個安全、節流的函式,語意與 entries 版
// library-backfill.test.ts)逐條對齊。
//
// 覆蓋:
// 1. 只補「符合 source_prefix、目前未標記 library」的候選三元組;已標記的不動(冪等)
// 2. owner_id 必填(缺了要拋錯,同 2026-08-11 leo 直令)
// 3. 與 entries 版 backfillEntryLibraryTags 共用同一顆每日 D1 寫入額度(D69:兩者都要受管,
// 任一邊獨立跑就是把另一邊的閘繞過去)
// 4. status 欄位存在的 record 也一起處理(backfill 不管 active/superseded,那是地圖聚合層的
// 篩選責任,不是補標層的責任——見 library-map.ts withLib 的 COALESCE 判準)
//
// 測試手法沿 triplet-library-backfill.test.tslibrary-backfill.test.ts 慣例:真 node:sqlite
// 套 migrations/0001_base.sql 原檔(本檔只用得到 templates/entries/entry_values 三張表,
// 不涉 library_map,不需 0003),kbdb-sql-ok 行尾標記同既有慣例(測試治具本身,非牆外業務邏輯繞過 API)。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { backfillTripletLibraryTags, tripletLibraryBackfillStatus } from '../src/actions/library-backfill';
import { backfillEntryLibraryTags } from '../src/actions/library-backfill';
import { createTemplate, createRecord, getRecord } from '../src/actions/record-crud';
import type { Bindings } from '../src/types';
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim)套 migration 原檔
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok:測試治具(node:sqlite→D1 shim
async run() {
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
return { success: true, meta: { changes: r.changes } };
},
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
function makeEnv(db: D1Database, opts: { maintenanceLimit?: string } = {}): Bindings {
return {
DB: db,
ENVIRONMENT: 'test',
KBDB_MAINTENANCE_DAILY_WRITE_LIMIT: opts.maintenanceLimit,
} as unknown as Bindings;
}
// prod 實際 triplet template 的 slotslibrary-map.test.tstriplet-library-backfill.test.ts 同款常數)。
const PROD_TRIPLET_SLOTS = [
'subject', 'predicate', 'object', 'source_block_id', 'confidence', 'clusters_json',
'bridge_score', 'subject_entity_type', 'object_entity_type', 'status', 'superseded_by',
'source_uri', 'content_hash', 'source_anchor', 'predicate_embed',
];
async function seedTripletTemplate(db: D1Database): Promise<void> {
await createTemplate(db, { id: 'tpl-triplet-test', name: 'triplet', slots: PROD_TRIPLET_SLOTS, created_by: 'kbdb-graph' });
}
describe('backfillTripletLibraryTags — 批次補三元組的 library slot(藏書地圖真正讀的那一半)', () => {
it('只補符合 source_prefix、目前缺 library 的三元組;owner 不符/已標記過的不動', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/Arcrun@a.md' }, owner_id: 'bfezv28v' });
const r2 = await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/Arcrun@b.md' }, owner_id: 'bfezv28v' });
// 別的 owner,同前綴——不該被補到(跨租戶隔離)。
const r3 = await createRecord(db, { template: 'triplet', values: { subject: 'E', predicate: 'r', object: 'F', source_uri: 'gitea:Leo/Arcrun@c.md' }, owner_id: 'someone-else' });
// 不符 source_prefix——不該被補到。
const r4 = await createRecord(db, { template: 'triplet', values: { subject: 'G', predicate: 'r', object: 'H', source_uri: 'gitea:Leo/mira@d.md' }, owner_id: 'bfezv28v' });
const result = await backfillTripletLibraryTags(db, env, {
library: 'arcrun',
owner_id: 'bfezv28v',
source_prefix: 'gitea:Leo/Arcrun@',
});
expect(result.scanned).toBe(2);
expect(result.tagged).toBe(2);
expect(result.remaining).toBe(0);
const rec1 = await getRecord(db, r1.record_id);
const rec2 = await getRecord(db, r2.record_id);
const rec3 = await getRecord(db, r3.record_id);
const rec4 = await getRecord(db, r4.record_id);
expect(rec1!.values.library).toBe('arcrun');
expect(rec2!.values.library).toBe('arcrun');
expect(rec3!.values.library).toBeUndefined(); // 別的 owner,沒被動到
expect(rec4!.values.library).toBeUndefined(); // 不符前綴,沒被動到
});
it('冪等:已標記過 library 的三元組不會被第二輪 touch(不會覆蓋成別的值)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
const r1 = await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/kb@a.md' }, owner_id: 'bfezv28v' });
const first = await backfillTripletLibraryTags(db, env, { library: 'kb', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/kb@' });
expect(first.tagged).toBe(1);
// 第二輪同條件再跑一次:這筆已經有 library 值了,不該再被選中,即使再給一個不同的 library 值。
const second = await backfillTripletLibraryTags(db, env, { library: 'something-else', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/kb@' });
expect(second.scanned).toBe(0);
expect(second.tagged).toBe(0);
const rec1 = await getRecord(db, r1.record_id);
expect(rec1!.values.library).toBe('kb'); // 沒被第二輪的 'something-else' 洗掉
});
it('owner_id 必填 — 缺了直接拋錯,不准無租戶範圍地掃全庫', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
await expect(backfillTripletLibraryTags(db, env, { library: 'arcrun', owner_id: '' })).rejects.toThrow(/owner_id required/);
});
it('與 entries 版 backfillEntryLibraryTags 共用同一顆每日 D1 寫入額度(D69:任一邊都不能繞過去)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db, { maintenanceLimit: '3' }); // 每日只剩 3 次背景維護寫入額度
// entries 版先用掉 2 筆額度。
await db.prepare(`INSERT INTO entries (id, content, entry_type, owner_id, created_at, updated_at) VALUES (?, 'x', 'block', 'bfezv28v', 1000, 1000)`).bind('e1').run(); // kbdb-sql-ok:測試治具灌資料
await db.prepare(`INSERT INTO entries (id, content, entry_type, owner_id, created_at, updated_at) VALUES (?, 'x', 'block', 'bfezv28v', 1000, 1000)`).bind('e2').run(); // kbdb-sql-ok:測試治具灌資料
const entriesResult = await backfillEntryLibraryTags(db, env, { library: 'notes', owner_id: 'bfezv28v' });
expect(entriesResult.tagged).toBe(2);
// 三元組版此時只剩 1 次額度可用,即使候選有 2 筆,也只能補 1 筆、如實回報 quota_exceeded。
await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/Arcrun@a.md' }, owner_id: 'bfezv28v' });
await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/Arcrun@b.md' }, owner_id: 'bfezv28v' });
const tripletResult = await backfillTripletLibraryTags(db, env, { library: 'arcrun', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/Arcrun@' });
expect(tripletResult.scanned).toBe(2);
expect(tripletResult.tagged).toBe(1); // 只剩 1 額度,被截斷
expect(tripletResult.quota_exceeded).toBe(true);
expect(tripletResult.remaining).toBe(1); // 還有 1 筆沒補到
});
it('tripletLibraryBackfillStatus 回報還剩多少待補(backfill 前後都能查)', async () => {
const db = makeSqliteD1();
await seedTripletTemplate(db);
const env = makeEnv(db);
await createRecord(db, { template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B', source_uri: 'gitea:Leo/notes@a.md' }, owner_id: 'bfezv28v' });
await createRecord(db, { template: 'triplet', values: { subject: 'C', predicate: 'r', object: 'D', source_uri: 'gitea:Leo/notes@b.md' }, owner_id: 'bfezv28v' });
const before = await tripletLibraryBackfillStatus(db, { owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' });
expect(before.pending).toBe(2);
await backfillTripletLibraryTags(db, env, { library: 'notes', owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' });
const after = await tripletLibraryBackfillStatus(db, { owner_id: 'bfezv28v', source_prefix: 'gitea:Leo/notes@' });
expect(after.pending).toBe(0);
});
});