feat(server): implement KV store API and fingerprinting functionality

- Added a new KV store API with endpoints for querying namespaces and key-value pairs.
- Introduced fingerprinting functionality to hash and manage data fingerprints, allowing for efficient change detection.
- Enhanced existing scripts to utilize the new fingerprinting capabilities, enabling conditional execution based on data changes.
- Updated the web interface to include a dedicated KV page for managing key-value entries and displaying their details.
- Improved overall user experience with pagination and search capabilities in the KV management interface.
This commit is contained in:
2026-08-15 06:00:30 +07:00
parent c5015bb391
commit 951e6f8371
15 changed files with 823 additions and 18 deletions
+74
View File
@@ -257,6 +257,80 @@ export async function kvList(namespace, opts = {}) {
return items; return items;
} }
function escapeLike(value) {
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
}
async function pruneExpiredKv() {
await db("script_state")
.whereNotNull("expires_at")
.andWhere("expires_at", "<=", nowIso())
.del();
}
/**
* @param {{
* namespace?: string,
* q?: string,
* limit?: number,
* offset?: number,
* }} [opts]
*/
export async function kvQuery(opts = {}) {
await pruneExpiredKv();
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
const offset = Math.max(Number(opts.offset) || 0, 0);
let q = db("script_state");
if (opts.namespace) {
assertNamespace(opts.namespace);
q = q.where({ namespace: opts.namespace });
}
if (typeof opts.q === "string" && opts.q.length > 0) {
const like = `%${escapeLike(opts.q)}%`;
q = q.where(function likeSearch() {
this.whereRaw("key LIKE ? ESCAPE '\\'", [like]).orWhereRaw(
"value LIKE ? ESCAPE '\\'",
[like],
);
});
}
const countRow = await q.clone().count({ count: "*" }).first();
const total = Number(countRow?.count ?? 0);
const rows = await q
.clone()
.orderBy("updated_at", "desc")
.orderBy("namespace", "asc")
.orderBy("key", "asc")
.limit(limit)
.offset(offset);
return {
items: rows.map((row) => ({
namespace: row.namespace,
key: row.key,
value: deserializeKvValue(row.value),
updatedAt: row.updated_at,
expiresAt: row.expires_at ?? null,
})),
total,
limit,
offset,
};
}
/**
* @returns {Promise<string[]>}
*/
export async function kvNamespaces() {
await pruneExpiredKv();
const rows = await db("script_state").distinct("namespace").orderBy("namespace", "asc");
return rows.map((row) => row.namespace);
}
/** /**
* @param {string} defaultNamespace * @param {string} defaultNamespace
*/ */
+61 -2
View File
@@ -260,6 +260,36 @@ export function createRegistry(server) {
); );
} }
function isSkipRemaining(result) {
return (
result != null &&
typeof result === "object" &&
!Array.isArray(result) &&
/** @type {{ skipRemaining?: unknown }} */ (result).skipRemaining === true
);
}
/**
* @param {import("./workflow-parse.js").CompiledStep} parsed
* @param {number} index
* @param {unknown} ctx
* @param {string} runId
* @param {import("pino").Logger} runLog
* @param {string} reason
*/
async function markStepSkipped(parsed, index, ctx, runId, runLog, reason) {
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
const step = await store.startStep({
runId,
index,
script,
config: parsed.config,
});
const stepLog = runLog.child({ stepId: step.id, script });
stepLog.debug({ reason }, "step skipped");
await store.finishStep(step.id, "skipped", ctx, reason);
}
/** /**
* @param {import("./workflow-parse.js").CompiledScripts} compiled * @param {import("./workflow-parse.js").CompiledScripts} compiled
* @param {{ data?: unknown }} ctx * @param {{ data?: unknown }} ctx
@@ -271,7 +301,8 @@ export function createRegistry(server) {
*/ */
async function runLinearSteps(compiled, ctx, runId, runLog, key, owner, depth) { async function runLinearSteps(compiled, ctx, runId, runLog, key, owner, depth) {
let next = ctx; let next = ctx;
for (const index of compiled.order) { for (let i = 0; i < compiled.order.length; i++) {
const index = compiled.order[i];
const parsed = compiled.steps[index]; const parsed = compiled.steps[index];
next = await runCompiledStep( next = await runCompiledStep(
parsed, parsed,
@@ -283,6 +314,20 @@ export function createRegistry(server) {
owner, owner,
depth, depth,
); );
if (isSkipRemaining(next)) {
for (let j = i + 1; j < compiled.order.length; j++) {
const laterIndex = compiled.order[j];
await markStepSkipped(
compiled.steps[laterIndex],
laterIndex,
next,
runId,
runLog,
"skipRemaining",
);
}
break;
}
} }
return next; return next;
} }
@@ -318,6 +363,20 @@ export function createRegistry(server) {
if (parsed.id) { if (parsed.id) {
outputsById.set(parsed.id, last); outputsById.set(parsed.id, last);
} }
if (isSkipRemaining(last)) {
for (let j = orderIndex + 1; j < compiled.order.length; j++) {
const laterParsed = compiled.steps[compiled.order[j]];
await markStepSkipped(
laterParsed,
j,
last,
runId,
runLog,
"skipRemaining",
);
}
break;
}
} }
return last; return last;
} }
@@ -389,7 +448,7 @@ export function createRegistry(server) {
const whenResult = await evaluateJsonata(parsed.when, ctx); const whenResult = await evaluateJsonata(parsed.when, ctx);
if (!isJsonataTruthy(whenResult)) { if (!isJsonataTruthy(whenResult)) {
stepLog.debug({ when: parsed.when }, "step skipped"); stepLog.debug({ when: parsed.when }, "step skipped");
await store.finishStep(step.id, "skipped", ctx); await store.finishStep(step.id, "skipped", ctx, "when condition");
return ctx; return ctx;
} }
} }
+2
View File
@@ -16,6 +16,7 @@ import workflowsPluginFactory from "./src/api/workflows.js";
import runsPlugin from "./src/api/runs.js"; import runsPlugin from "./src/api/runs.js";
import dashboardPluginFactory from "./src/api/dashboard.js"; import dashboardPluginFactory from "./src/api/dashboard.js";
import secretsPlugin from "./src/api/secrets.js"; import secretsPlugin from "./src/api/secrets.js";
import kvPlugin from "./src/api/kv.js";
import { WEB_DIST } from "./paths.js"; import { WEB_DIST } from "./paths.js";
import { resolveSecretsKeyMaterial } from "./secrets.js"; import { resolveSecretsKeyMaterial } from "./secrets.js";
@@ -88,6 +89,7 @@ await server.register(
await api.register(authPlugin); await api.register(authPlugin);
await api.register(usersPlugin); await api.register(usersPlugin);
await api.register(secretsPlugin); await api.register(secretsPlugin);
await api.register(kvPlugin);
await api.register(scriptsPluginFactory(registry)); await api.register(scriptsPluginFactory(registry));
await api.register(workflowsPluginFactory(registry)); await api.register(workflowsPluginFactory(registry));
await api.register(runsPlugin); await api.register(runsPlugin);
+183
View File
@@ -0,0 +1,183 @@
import { createHash } from "node:crypto";
const MAX_AGE_RE = /^(\d+(?:\.\d+)?)(s|m|h|d)$/i;
const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
/**
* @param {unknown} maxAge
* @returns {number | null}
*/
export function parseMaxAge(maxAge) {
if (maxAge == null || maxAge === false || maxAge === "") return null;
if (typeof maxAge === "number") {
if (!Number.isFinite(maxAge) || maxAge < 0) {
throw new Error("maxAge must be a non-negative number of milliseconds");
}
return maxAge;
}
if (typeof maxAge === "string") {
const trimmed = maxAge.trim();
if (trimmed === "") return null;
if (!/[a-z]/i.test(trimmed)) {
const asNum = Number(trimmed);
if (!Number.isFinite(asNum) || asNum < 0) {
throw new Error(`invalid maxAge: ${JSON.stringify(maxAge)}`);
}
return asNum;
}
const match = trimmed.match(MAX_AGE_RE);
if (!match) {
throw new Error(`invalid maxAge: ${JSON.stringify(maxAge)}`);
}
return Number(match[1]) * UNIT_MS[match[2].toLowerCase()];
}
throw new Error(`invalid maxAge: ${JSON.stringify(maxAge)}`);
}
function isBytes(value) {
return Buffer.isBuffer(value) || value instanceof Uint8Array;
}
/**
* @param {unknown} value
*/
export function canonicalize(value) {
if (value === undefined) return null;
if (isBytes(value)) return { $bytes: value.length };
if (value === null) return null;
if (typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(canonicalize);
if (value instanceof Date) return value.toISOString();
/** @type {Record<string, unknown>} */
const out = {};
for (const key of Object.keys(value).sort()) {
out[key] = canonicalize(value[key]);
}
return out;
}
/**
* @param {unknown} value
*/
export function hashFingerprint(value) {
return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
}
/**
* @param {unknown} stored
* @returns {{ hash: string | null, at: string | null } | null}
*/
export function parseFingerprintRecord(stored) {
if (stored == null) return null;
if (typeof stored === "string" && stored.length > 0) {
return { hash: stored, at: null };
}
if (stored && typeof stored === "object" && !Array.isArray(stored)) {
const hash = /** @type {{ hash?: unknown }} */ (stored).hash;
const at = /** @type {{ at?: unknown }} */ (stored).at;
if (typeof hash === "string" && hash.length > 0) {
return { hash, at: typeof at === "string" && at.length > 0 ? at : null };
}
}
return { hash: null, at: null };
}
function ageMs(at, now = Date.now()) {
if (at == null) return null;
const t = Date.parse(at);
if (Number.isNaN(t)) return null;
return now - t;
}
function isAgeExpired(record, maxAgeMs, now) {
if (maxAgeMs == null || !record) return false;
if (record.at == null) return true;
const age = ageMs(record.at, now);
if (age == null) return true;
return age >= maxAgeMs;
}
function inspect(stored, hash, maxAgeMs, now = Date.now()) {
const previous = parseFingerprintRecord(stored);
const previousAt = previous?.at ?? null;
const age = ageMs(previousAt, now);
const hashChanged = !previous || previous.hash !== hash;
const expired = Boolean(previous) && !hashChanged && isAgeExpired(previous, maxAgeMs, now);
return {
hash,
previous: previous?.hash ?? null,
previousAt,
ageMs: age,
changed: hashChanged || expired,
expired,
};
}
/**
* @param {ReturnType<import("./kv-store.js").createKvApi>} kv
*/
export function createFingerprintApi(kv) {
return {
hash(value) {
return hashFingerprint(value);
},
/**
* @param {string} key
* @param {unknown} value
* @param {{ maxAge?: unknown }} [opts]
*/
async check(key, value, opts = {}) {
const maxAgeMs = parseMaxAge(opts.maxAge);
const hash = hashFingerprint(value);
const stored = await kv.get(key);
return inspect(stored, hash, maxAgeMs);
},
/**
* @param {string} key
* @param {string} hash
*/
async remember(key, hash) {
if (typeof hash !== "string" || hash.length === 0) {
throw new Error("fingerprint hash is required");
}
const record = { hash, at: new Date().toISOString() };
await kv.set(key, record);
return record;
},
/**
* @param {string} key
* @param {unknown} value
* @param {{ maxAge?: unknown }} [opts]
*/
async claim(key, value, opts = {}) {
const maxAgeMs = parseMaxAge(opts.maxAge);
const hash = hashFingerprint(value);
const stored = await kv.get(key);
const result = inspect(stored, hash, maxAgeMs);
if (!result.changed) {
return { ...result, at: result.previousAt };
}
const next = { hash, at: new Date().toISOString() };
const cas = await kv.compareAndSet(key, stored ?? null, next);
if (!cas.ok) {
const again = inspect(cas.previous, hash, maxAgeMs);
if (!again.changed) {
return { ...again, at: again.previousAt };
}
return {
...again,
changed: false,
expired: false,
at: again.previousAt,
};
}
return { ...result, at: next.at };
},
};
}
+3
View File
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
import axios from "axios"; import axios from "axios";
import pino from "pino"; import pino from "pino";
import { createKvApi } from "./kv-store.js"; import { createKvApi } from "./kv-store.js";
import { createFingerprintApi } from "./script-fingerprint.js";
import { SCRIPTS_DIR } from "./paths.js"; import { SCRIPTS_DIR } from "./paths.js";
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js"; import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
import { getSecretPlaintext } from "./secrets-store.js"; import { getSecretPlaintext } from "./secrets-store.js";
@@ -342,6 +343,7 @@ function createScriptSandbox({
const scriptLog = log.child({ workflow: workflowName, script }); const scriptLog = log.child({ workflow: workflowName, script });
const $axios = createScreenedAxios(scriptLog); const $axios = createScreenedAxios(scriptLog);
const $kv = createKvApi(workflowName); const $kv = createKvApi(workflowName);
const $fingerprint = createFingerprintApi($kv);
const $secrets = createSecretsApi(owner); const $secrets = createSecretsApi(owner);
const sandbox = { const sandbox = {
...pickBuiltins(), ...pickBuiltins(),
@@ -349,6 +351,7 @@ function createScriptSandbox({
console: createConsole(scriptLog), console: createConsole(scriptLog),
$axios, $axios,
$kv, $kv,
$fingerprint,
$secrets, $secrets,
$workflows, $workflows,
require: createRestrictedRequire($axios), require: createRestrictedRequire($axios),
+99
View File
@@ -0,0 +1,99 @@
import jsonata from "jsonata";
function ensureDataObject(ctx) {
if (ctx.data == null || typeof ctx.data !== "object" || Array.isArray(ctx.data)) {
ctx.data = {};
}
}
async function fingerprint(ctx) {
const key =
typeof ctx.config?.key === "string" && ctx.config.key.length > 0
? ctx.config.key
: "fingerprint";
const jsonataExpr = ctx.config?.jsonata;
const skipRemaining = ctx.config?.skipRemaining !== false;
let source;
if (typeof jsonataExpr === "string" && jsonataExpr.length > 0) {
log.info({ jsonata: jsonataExpr }, "fingerprint: evaluating jsonata");
const expression = jsonata(jsonataExpr);
source = await expression.evaluate(ctx);
} else {
source = ctx.data;
}
log.info({ key }, "fingerprint: claiming");
const result = await $fingerprint.claim(key, source, {
maxAge: ctx.config?.maxAge,
});
ensureDataObject(ctx);
ctx.data.fingerprint = result.hash;
ctx.data.fingerprintChanged = result.changed;
ctx.data.fingerprintPrevious = result.previous;
ctx.data.fingerprintAt = result.changed ? result.at : result.previousAt;
ctx.data.fingerprintAge = result.ageMs;
ctx.data.fingerprintExpired = result.expired;
log.info(
{
key,
changed: result.changed,
expired: result.expired,
ageMs: result.ageMs,
},
"fingerprint: result",
);
if (!result.changed && skipRemaining) {
ctx.skipRemaining = true;
}
return ctx;
}
fingerprint.meta = {
description:
"Hash a value, compare it to the last stored fingerprint, and skip remaining steps when unchanged",
config: {
key: {
type: "string",
default: "fingerprint",
description: "KV key for the stored fingerprint record",
},
jsonata: {
type: "string",
required: false,
description: "JSONata against ctx; omitted hashes ctx.data",
},
skipRemaining: {
type: "boolean",
default: true,
description: "Skip later steps when the fingerprint is unchanged",
},
maxAge: {
type: "string",
required: false,
description: "Optional age limit (e.g. 24h, 7d, or milliseconds). Older matching hashes fire again",
},
},
input: {},
output: {
fingerprint: { type: "string", description: "SHA-256 hex of the source value" },
fingerprintChanged: { type: "boolean" },
fingerprintPrevious: { type: "string", required: false },
fingerprintAt: { type: "string", required: false, description: "ISO timestamp of the stored record" },
fingerprintAge: { type: "number", required: false, description: "Age in milliseconds" },
fingerprintExpired: { type: "boolean" },
},
example: {
data: { item: { guid: "https://example.com/post-1" } },
config: {
key: "latest-item",
jsonata: "data.item.guid ? data.item.guid : data.item.link",
},
},
};
export default fingerprint;
+84 -5
View File
@@ -1,3 +1,5 @@
import jsonata from "jsonata";
function ntfyHeaders(ctx) { function ntfyHeaders(ctx) {
const headers = {}; const headers = {};
@@ -9,6 +11,32 @@ function ntfyHeaders(ctx) {
return headers; return headers;
} }
function resolveFingerprintKey(config) {
const fingerprint = config?.fingerprint;
if (fingerprint === true) return "fingerprint:ntfy";
if (typeof fingerprint === "string" && fingerprint.length > 0) return fingerprint;
return null;
}
function defaultFingerprintSource(ctx) {
return {
title: ctx.data?.title,
message: ctx.data?.message,
attach: ctx.data?.attach,
filename: ctx.data?.filename,
contentType: ctx.data?.contentType,
};
}
async function resolveFingerprintValue(ctx) {
const expr = ctx.config?.fingerprintJsonata;
if (typeof expr === "string" && expr.length > 0) {
const expression = jsonata(expr);
return await expression.evaluate(ctx);
}
return defaultFingerprintSource(ctx);
}
async function ntfy(ctx) { async function ntfy(ctx) {
const file = ctx.data?.file; const file = ctx.data?.file;
const hasFile = Buffer.isBuffer(file) || file instanceof Uint8Array; const hasFile = Buffer.isBuffer(file) || file instanceof Uint8Array;
@@ -25,6 +53,35 @@ async function ntfy(ctx) {
"ntfy incoming context", "ntfy incoming context",
); );
const fingerprintKey = resolveFingerprintKey(ctx.config);
/** @type {{ hash: string, previousAt: string | null, ageMs: number | null } | null} */
let fp = null;
if (fingerprintKey) {
const source = await resolveFingerprintValue(ctx);
const checked = await $fingerprint.check(fingerprintKey, source, {
maxAge: ctx.config?.fingerprintMaxAge,
});
if (!checked.changed) {
log.info(
{
key: fingerprintKey,
fingerprint: checked.hash,
ageMs: checked.ageMs,
},
"ntfy: skipped, fingerprint unchanged",
);
return {
sent: "false",
skipped: true,
fingerprint: checked.hash,
fingerprintAt: checked.previousAt,
fingerprintAge: checked.ageMs,
};
}
fp = checked;
}
const headers = ntfyHeaders(ctx); const headers = ntfyHeaders(ctx);
const ntfyUrl = ctx.config?.url || "https://ntfy.sh/scrunner"; const ntfyUrl = ctx.config?.url || "https://ntfy.sh/scrunner";
@@ -47,9 +104,7 @@ async function ntfy(ctx) {
maxBodyLength: Infinity, maxBodyLength: Infinity,
maxContentLength: Infinity, maxContentLength: Infinity,
}); });
return { sent: "true" }; } else {
}
if (ctx.data?.attach) { if (ctx.data?.attach) {
log.info("ntfy: setting attach %s", ctx.data.attach); log.info("ntfy: setting attach %s", ctx.data.attach);
headers.Attach = ctx.data.attach; headers.Attach = ctx.data.attach;
@@ -62,7 +117,16 @@ async function ntfy(ctx) {
await $axios.post(ntfyUrl, ctx.data?.message || "Hello from scrunner", { await $axios.post(ntfyUrl, ctx.data?.message || "Hello from scrunner", {
headers, headers,
}); });
return { sent: "true" }; }
/** @type {Record<string, unknown>} */
const sent = { sent: "true" };
if (fingerprintKey && fp) {
const stored = await $fingerprint.remember(fingerprintKey, fp.hash);
sent.fingerprint = stored.hash;
sent.fingerprintAt = stored.at;
}
return sent;
} }
ntfy.meta = { ntfy.meta = {
@@ -73,6 +137,21 @@ ntfy.meta = {
default: "https://ntfy.sh/scrunner", default: "https://ntfy.sh/scrunner",
description: "ntfy topic URL", description: "ntfy topic URL",
}, },
fingerprint: {
type: "string",
required: false,
description: "true or a KV key; skip send when the payload fingerprint is unchanged",
},
fingerprintJsonata: {
type: "string",
required: false,
description: "JSONata against ctx; default hashes title, message, attach, filename, contentType",
},
fingerprintMaxAge: {
type: "string",
required: false,
description: "Optional age limit (e.g. 24h, 7d, or milliseconds)",
},
}, },
input: { input: {
title: { type: "string", required: false }, title: { type: "string", required: false },
@@ -87,7 +166,7 @@ ntfy.meta = {
}, },
example: { example: {
data: { title: "Hello", message: "Hello from scrunner" }, data: { title: "Hello", message: "Hello from scrunner" },
config: { url: "https://ntfy.sh/scrunner" }, config: { url: "https://ntfy.sh/scrunner", fingerprint: true },
}, },
}; };
+22
View File
@@ -0,0 +1,22 @@
import { kvNamespaces, kvQuery } from "../../kv-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function kvPlugin(fastify) {
fastify.get("/kv/namespaces", async () => {
return { namespaces: await kvNamespaces() };
});
fastify.get("/kv", async (req) => {
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
const limit = q.limit != null ? Number(q.limit) : undefined;
const offset = q.offset != null ? Number(q.offset) : undefined;
return kvQuery({
namespace: q.namespace || undefined,
q: q.q || undefined,
limit: Number.isFinite(limit) ? limit : undefined,
offset: Number.isFinite(offset) ? offset : undefined,
});
});
}
+105
View File
@@ -0,0 +1,105 @@
import { migrate, db } from "../db.js";
import { createKvApi, kvDelete, kvSet } from "../kv-store.js";
import {
createFingerprintApi,
hashFingerprint,
} from "../script-fingerprint.js";
import { runScriptSource } from "../script-sandbox.js";
import { log } from "../logger.js";
await migrate();
const ns = "test/fingerprint-smoke";
const api = createKvApi(ns);
const fp = createFingerprintApi(api);
for (const key of ["a", "b", "cas", "age", "legacy", "script-key"]) {
await kvDelete(ns, key);
}
const h1 = hashFingerprint({ b: 1, a: 2 });
const h2 = hashFingerprint({ a: 2, b: 1 });
if (h1 !== h2) throw new Error("hash should ignore key order");
const bufHash = hashFingerprint(Buffer.from("hello"));
const sameLen = hashFingerprint(Buffer.from("world"));
if (bufHash !== sameLen) {
throw new Error("buffers of equal length should hash as { $bytes: length }");
}
const otherLen = hashFingerprint(Buffer.from("hi"));
if (bufHash === otherLen) throw new Error("different byte lengths should hash differently");
const first = await fp.check("a", { item: "one" });
if (!first.changed || first.previous !== null) {
throw new Error(`first check should be changed: ${JSON.stringify(first)}`);
}
const remembered = await fp.remember("a", first.hash);
if (remembered.hash !== first.hash || typeof remembered.at !== "string") {
throw new Error(`remember failed: ${JSON.stringify(remembered)}`);
}
const second = await fp.check("a", { item: "one" });
if (second.changed) throw new Error("second check should be unchanged");
if (second.previous !== first.hash) throw new Error("previous hash mismatch");
if (second.previousAt !== remembered.at) throw new Error("previousAt should be kept");
const claimNew = await fp.claim("b", "hello");
if (!claimNew.changed) throw new Error("first claim should be changed");
const storedAt = claimNew.at;
const claimSame = await fp.claim("b", "hello");
if (claimSame.changed) throw new Error("unchanged claim should return changed: false");
if (claimSame.at !== storedAt) throw new Error("unchanged claim should keep original at");
const oldAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
const oldHash = hashFingerprint("aged");
await kvSet(ns, "age", { hash: oldHash, at: oldAt });
const agedNoMax = await fp.check("age", "aged");
if (agedNoMax.changed) {
throw new Error("omit maxAge: matching hash should skip even if old");
}
const agedExpired = await fp.claim("age", "aged", { maxAge: "7d" });
if (!agedExpired.changed || !agedExpired.expired) {
throw new Error(`maxAge should expire old hash: ${JSON.stringify(agedExpired)}`);
}
if (agedExpired.at === oldAt) throw new Error("expired claim should write a new at");
const fresh = await fp.check("age", "aged", { maxAge: "7d" });
if (fresh.changed || fresh.expired) {
throw new Error(`freshly claimed hash should not expire: ${JSON.stringify(fresh)}`);
}
await kvSet(ns, "legacy", oldHash);
const legacyNoMax = await fp.check("legacy", "aged");
if (legacyNoMax.changed) {
throw new Error("legacy bare hash should still match without maxAge");
}
const legacyExpired = await fp.check("legacy", "aged", { maxAge: "1h" });
if (!legacyExpired.changed || !legacyExpired.expired) {
throw new Error(`legacy hash with maxAge should expire: ${JSON.stringify(legacyExpired)}`);
}
const script = `
export default async function (ctx) {
const result = await $fingerprint.claim("script-key", ctx.data.n);
return result;
}
`;
const scriptOut = await runScriptSource(
"fingerprint-smoke.js",
script,
{ data: { n: 1 } },
{ log, workflowName: ns },
);
if (!scriptOut.changed) throw new Error("sandbox $fingerprint.claim should be new");
for (const key of ["a", "b", "cas", "age", "legacy", "script-key"]) {
await kvDelete(ns, key);
}
console.log("fingerprint smoke test passed");
await db.destroy();
@@ -5,6 +5,10 @@ scripts:
url: "https://selfh.st/rss/" url: "https://selfh.st/rss/"
outputVar: item outputVar: item
jsonata: items[0] jsonata: items[0]
- script: fingerprint.js
config:
key: selfhst-latest
jsonata: "data.item.guid ? data.item.guid : data.item.link"
- script: jsonata.js - script: jsonata.js
config: config:
expression: | expression: |
+2
View File
@@ -12,6 +12,7 @@ import { WorkflowsPage } from "./pages/WorkflowsPage.jsx";
import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx"; import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx";
import { EventsPage } from "./pages/EventsPage.jsx"; import { EventsPage } from "./pages/EventsPage.jsx";
import { EventDetailPage } from "./pages/EventDetailPage.jsx"; import { EventDetailPage } from "./pages/EventDetailPage.jsx";
import { KvPage } from "./pages/KvPage.jsx";
import { UsersPage } from "./pages/UsersPage.jsx"; import { UsersPage } from "./pages/UsersPage.jsx";
import { SecretsPage } from "./pages/SecretsPage.jsx"; import { SecretsPage } from "./pages/SecretsPage.jsx";
@@ -57,6 +58,7 @@ export function App() {
<Route path="/workflows/:owner/:file/edit" element={<WorkflowEditPage />} /> <Route path="/workflows/:owner/:file/edit" element={<WorkflowEditPage />} />
<Route path="/events" element={<EventsPage />} /> <Route path="/events" element={<EventsPage />} />
<Route path="/events/:id" element={<EventDetailPage />} /> <Route path="/events/:id" element={<EventDetailPage />} />
<Route path="/kv" element={<KvPage />} />
{user.role === "admin" ? ( {user.role === "admin" ? (
<> <>
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
+22
View File
@@ -313,3 +313,25 @@ export function useDeleteSecret() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
}); });
} }
export function useKvNamespaces() {
return useQuery({
queryKey: ["kv", "namespaces"],
queryFn: async () => (await api.get("/kv/namespaces")).data.namespaces,
});
}
export function useKv(filters = {}) {
const { namespace, q, limit, offset } = filters;
return useQuery({
queryKey: ["kv", { namespace, q, limit, offset }],
queryFn: async () => {
const params = {};
if (namespace) params.namespace = namespace;
if (q) params.q = q;
if (limit != null) params.limit = limit;
if (offset != null) params.offset = offset;
return (await api.get("/kv", { params })).data;
},
});
}
+2
View File
@@ -2,6 +2,7 @@ import { NavLink, useNavigate } from "react-router-dom";
import { import {
LuActivity, LuActivity,
LuCode, LuCode,
LuDatabase,
LuGitBranch, LuGitBranch,
LuHouse, LuHouse,
LuKey, LuKey,
@@ -19,6 +20,7 @@ const links = [
{ to: "/scripts", label: "Scripts", icon: LuCode }, { to: "/scripts", label: "Scripts", icon: LuCode },
{ to: "/workflows", label: "Workflows", icon: LuGitBranch }, { to: "/workflows", label: "Workflows", icon: LuGitBranch },
{ to: "/events", label: "Events", icon: LuActivity }, { to: "/events", label: "Events", icon: LuActivity },
{ to: "/kv", label: "KV", icon: LuDatabase },
]; ];
export function Layout({ user, children }) { export function Layout({ user, children }) {
+8 -2
View File
@@ -98,7 +98,7 @@ export function EventDetailPage() {
<th>Script</th> <th>Script</th>
<th>Status</th> <th>Status</th>
<th>Duration</th> <th>Duration</th>
<th>Error</th> <th>Detail</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -119,7 +119,13 @@ export function EventDetailPage() {
<StatusBadge status={s.status} /> <StatusBadge status={s.status} />
</td> </td>
<td>{s.duration_ms != null ? `${s.duration_ms}ms` : "—"}</td> <td>{s.duration_ms != null ? `${s.duration_ms}ms` : "—"}</td>
<td className="text-error text-xs">{s.error || ""}</td> <td
className={
s.status === "skipped" ? "opacity-60 text-xs" : "text-error text-xs"
}
>
{s.error || ""}
</td>
<td className="text-right"> <td className="text-right">
<button <button
type="button" type="button"
+143
View File
@@ -0,0 +1,143 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useKv, useKvNamespaces } from "../api/hooks.js";
import { formatTime } from "../lib/format.jsx";
const PAGE_SIZE = 50;
function previewValue(value) {
const json = JSON.stringify(value);
if (json == null) return "—";
if (json.length <= 120) return json;
return `${json.slice(0, 120)}…`;
}
export function KvPage() {
const [params, setParams] = useSearchParams();
const namespace = params.get("namespace") || "";
const q = params.get("q") || "";
const offset = Math.max(Number(params.get("offset")) || 0, 0);
const [expanded, setExpanded] = useState(null);
const { data: namespaces = [] } = useKvNamespaces();
const { data, isLoading } = useKv({
namespace: namespace || undefined,
q: q || undefined,
limit: PAGE_SIZE,
offset,
});
const items = data?.items ?? [];
const total = data?.total ?? 0;
const limit = data?.limit ?? PAGE_SIZE;
function update(key, value, resetOffset = true) {
const next = new URLSearchParams(params);
if (value) next.set(key, value);
else next.delete(key);
if (resetOffset && key !== "offset") next.delete("offset");
setParams(next);
setExpanded(null);
}
function rowId(item) {
return `${item.namespace}\0${item.key}`;
}
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">KV</h1>
<div className="flex flex-col sm:flex-row gap-2">
<select
className="select select-sm w-full sm:max-w-xs"
value={namespace}
onChange={(e) => update("namespace", e.target.value)}
>
<option value="">all namespaces</option>
{namespaces.map((ns) => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
<input
className="input input-sm w-full sm:max-w-sm"
placeholder="search key or value"
value={q}
onChange={(e) => update("q", e.target.value)}
/>
</div>
{isLoading ? (
<span className="loading loading-spinner" />
) : items.length === 0 ? (
<p className="text-sm opacity-60">No KV entries.</p>
) : (
<>
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Namespace</th>
<th>Key</th>
<th>Value</th>
<th>Updated</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const id = rowId(item);
const open = expanded === id;
return (
<tr
key={id}
className="hover cursor-pointer"
onClick={() => setExpanded(open ? null : id)}
>
<td className="font-mono text-xs align-top">{item.namespace}</td>
<td className="font-mono text-xs align-top">{item.key}</td>
<td className="align-top">
{open ? (
<pre className="text-xs whitespace-pre-wrap break-all max-w-xl">
{JSON.stringify(item.value, null, 2)}
</pre>
) : (
<span className="font-mono text-xs">{previewValue(item.value)}</span>
)}
</td>
<td className="whitespace-nowrap align-top">{formatTime(item.updatedAt)}</td>
<td className="whitespace-nowrap align-top">
{item.expiresAt ? formatTime(item.expiresAt) : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex items-center gap-2 text-sm">
<button
type="button"
className="btn btn-sm"
disabled={offset <= 0}
onClick={() => update("offset", String(Math.max(offset - limit, 0)), false)}
>
Prev
</button>
<button
type="button"
className="btn btn-sm"
disabled={offset + items.length >= total}
onClick={() => update("offset", String(offset + limit), false)}
>
Next
</button>
<span className="opacity-60">
{total === 0 ? "0" : `${offset + 1}–${offset + items.length}`} of {total}
</span>
</div>
</>
)}
</div>
);
}