614 lines
21 KiB
TypeScript
614 lines
21 KiB
TypeScript
// SPEC: PJ2026-010401080313 Workbench实时权威 draft-2026-07-20-p0-process-session-index.
|
|
// Responsibility: accelerate retained hwlab.event.v1 session queries without creating another business authority.
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
export function createWorkbenchKafkaSessionIndex(options = {}) {
|
|
const topic = text(options.topic);
|
|
const maxEvents = positiveInteger(options.maxEvents);
|
|
const maxEventsPerSession = positiveInteger(options.maxEventsPerSession);
|
|
const bootstrapTimeoutMs = positiveInteger(options.bootstrapTimeoutMs);
|
|
const rebuildIntervalMs = positiveInteger(options.rebuildIntervalMs);
|
|
const rebuildCooldownMs = positiveInteger(options.rebuildCooldownMs);
|
|
const scanLimit = positiveInteger(options.scanLimit);
|
|
const logger = options.logger ?? console;
|
|
const nowMs = typeof options.nowMs === "function" ? options.nowMs : Date.now;
|
|
if (!topic || !maxEvents || !maxEventsPerSession || !bootstrapTimeoutMs || !rebuildIntervalMs || !rebuildCooldownMs || !scanLimit || typeof options.queryAll !== "function") {
|
|
throw indexError("workbench_kafka_session_index_config_invalid", "Workbench Kafka session index requires complete bounded configuration.");
|
|
}
|
|
|
|
let ready = false;
|
|
let stopped = false;
|
|
let rebuilding = false;
|
|
let rebuildTimer = null;
|
|
let lastRebuildStartedAtMs = null;
|
|
let lastRebuildCompletedAtMs = null;
|
|
let lastFailure = null;
|
|
let bootstrapPromise = null;
|
|
let pendingLiveOverflow = false;
|
|
let state = emptyState();
|
|
const pendingLive = [];
|
|
|
|
function start() {
|
|
if (bootstrapPromise) return ready ? Promise.resolve(true) : bootstrapPromise;
|
|
bootstrapPromise = rebuild("bootstrap");
|
|
rebuildTimer = setInterval(() => {
|
|
void rebuild("interval");
|
|
}, rebuildIntervalMs);
|
|
rebuildTimer.unref?.();
|
|
return bootstrapPromise;
|
|
}
|
|
|
|
function stop() {
|
|
stopped = true;
|
|
if (rebuildTimer) clearInterval(rebuildTimer);
|
|
rebuildTimer = null;
|
|
pendingLive.length = 0;
|
|
}
|
|
|
|
function observeLive(envelope, transport) {
|
|
if (stopped || envelope?.schema !== "hwlab.event.v1") return;
|
|
const record = normalizeRecord({ topic, partition: transport?.partition, offset: transport?.offset, key: sessionId(envelope), timestamp: null, value: envelope });
|
|
if (!record) return;
|
|
if (rebuilding) {
|
|
if (pendingLive.length >= maxEvents) {
|
|
pendingLiveOverflow = true;
|
|
lastFailure = warning("workbench_kafka_session_index_live_buffer_overflow", "Session index rebuild live buffer exceeded its YAML-owned bound.");
|
|
} else {
|
|
pendingLive.push(record);
|
|
}
|
|
}
|
|
if (ready) addRecord(state, record);
|
|
}
|
|
|
|
async function rebuild(reason) {
|
|
if (stopped || rebuilding) return false;
|
|
const startedAtMs = nowMs();
|
|
if (lastRebuildStartedAtMs !== null && reason !== "bootstrap" && startedAtMs - lastRebuildStartedAtMs < rebuildCooldownMs) return false;
|
|
rebuilding = true;
|
|
lastRebuildStartedAtMs = startedAtMs;
|
|
pendingLive.length = 0;
|
|
pendingLiveOverflow = false;
|
|
try {
|
|
const result = await options.queryAll({
|
|
limit: maxEvents,
|
|
scanLimit,
|
|
timeoutMs: bootstrapTimeoutMs
|
|
});
|
|
if (!completeQuery(result)) {
|
|
lastFailure = warning("workbench_kafka_session_index_bootstrap_incomplete", `Session index ${reason} did not reach the Kafka retention barrier.`);
|
|
logWarning(lastFailure, { reason, completionReason: result?.completionReason ?? null });
|
|
return false;
|
|
}
|
|
const candidate = emptyState();
|
|
candidate.endOffsets = normalizeEndOffsets(result.endOffsets);
|
|
for (const raw of Array.isArray(result.events) ? result.events : []) {
|
|
const record = normalizeRecord(raw);
|
|
if (!record) continue;
|
|
addRecord(candidate, record);
|
|
}
|
|
if (pendingLiveOverflow) {
|
|
throw indexError("workbench_kafka_session_index_live_buffer_overflow", "Session index rebuild cannot prove completeness after its live buffer overflowed.");
|
|
}
|
|
for (const record of pendingLive.splice(0)) addRecord(candidate, record);
|
|
state = candidate;
|
|
ready = true;
|
|
lastFailure = null;
|
|
lastRebuildCompletedAtMs = nowMs();
|
|
logger.log?.(JSON.stringify({
|
|
code: "workbench-kafka-session-index-ready",
|
|
reason,
|
|
indexedEventCount: state.records.length,
|
|
indexedSessionCount: state.bySession.size,
|
|
incompleteSessionCount: state.incompleteSessions.size,
|
|
durationMs: lastRebuildCompletedAtMs - startedAtMs,
|
|
valuesPrinted: false
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
lastFailure = warning(error?.code ?? "workbench_kafka_session_index_bootstrap_failed", errorMessage(error));
|
|
logWarning(lastFailure, { reason });
|
|
return false;
|
|
} finally {
|
|
rebuilding = false;
|
|
}
|
|
}
|
|
|
|
function query(params = {}) {
|
|
const startedAtMs = nowMs();
|
|
const requestedSessionIds = normalizeSessionIds(params.sessionIds, params.sessionId);
|
|
const requestedSessionId = requestedSessionIds.length === 1 ? requestedSessionIds[0] : null;
|
|
const requestedTraceId = text(params.traceId);
|
|
const incompleteRequestedSession = requestedSessionIds.find((sessionId) => state.incompleteSessions.has(sessionId));
|
|
const fallbackReason = !ready
|
|
? "index-not-ready"
|
|
: incompleteRequestedSession
|
|
? "session-capacity-exceeded"
|
|
: requestedSessionIds.length === 0 && !state.globalComplete
|
|
? "global-capacity-exceeded"
|
|
: null;
|
|
if (fallbackReason) return {
|
|
hit: false,
|
|
fallbackReason,
|
|
status: status(),
|
|
warning: warning("workbench_kafka_session_index_fallback", `Session index cannot prove a complete result (${fallbackReason}).`)
|
|
};
|
|
const requestedSessionSet = new Set(requestedSessionIds);
|
|
const source = requestedSessionIds.length === 1
|
|
? state.bySession.get(requestedSessionIds[0]) ?? []
|
|
: requestedSessionIds.length > 1
|
|
? state.records.filter((record) => requestedSessionSet.has(indexedSessionId(record)))
|
|
: state.records;
|
|
const events = source.filter((record) => matchesTrace(record.value, requestedTraceId));
|
|
const limit = positiveInteger(params.limit);
|
|
if (limit && events.length > limit) {
|
|
return {
|
|
hit: false,
|
|
fallbackReason: "matched-event-limit",
|
|
status: status(),
|
|
warning: warning("workbench_kafka_session_index_fallback", "Session index result exceeds the request matched-event limit.")
|
|
};
|
|
}
|
|
const elapsedMs = Math.max(0, nowMs() - startedAtMs);
|
|
const firstScannedOffsets = partitionOffsets(events, "first");
|
|
const lastScannedOffsets = partitionOffsets(events, "last");
|
|
return {
|
|
hit: true,
|
|
result: {
|
|
ok: true,
|
|
stream: "hwlab",
|
|
topic,
|
|
groupId: null,
|
|
count: events.length,
|
|
scannedCount: events.length,
|
|
parsedCount: events.length,
|
|
matchedCount: events.length,
|
|
invalidJsonCount: 0,
|
|
filterRejectedCount: 0,
|
|
keyRejectedCount: 0,
|
|
excludedPostBarrierCount: 0,
|
|
completionReason: "session-index",
|
|
completion: {
|
|
reason: "session-index",
|
|
complete: true,
|
|
barrierReached: true,
|
|
retentionStartVerified: true,
|
|
retentionStartMoved: false,
|
|
timeout: false,
|
|
matchedEventLimitReached: false,
|
|
scanLimitReached: false,
|
|
aborted: false,
|
|
valuesRedacted: true
|
|
},
|
|
reachedEndOffsets: true,
|
|
endOffsets: [...state.endOffsets.values()].sort((left, right) => left.partition - right.partition),
|
|
endOffsetsAvailable: state.endOffsets.size > 0,
|
|
endOffsetsError: null,
|
|
firstScannedOffsets,
|
|
lastScannedOffsets,
|
|
limit: limit ?? null,
|
|
scanLimit: positiveInteger(params.scanLimit),
|
|
timeoutMs: positiveInteger(params.timeoutMs),
|
|
filters: compact({ sessionIds: requestedSessionIds.length > 0 ? requestedSessionIds : null, sessionId: requestedSessionId, traceId: requestedTraceId }),
|
|
partitionKeyScoped: Boolean(text(params.partitionKey)),
|
|
targetPartition: events.length > 0 ? events[0].partition : null,
|
|
timing: {
|
|
endOffsetSnapshotMs: 0,
|
|
groupOffsetsMs: 0,
|
|
consumerConnectMs: 0,
|
|
consumerSubscribeMs: 0,
|
|
scanMs: 0,
|
|
cleanupMs: 0,
|
|
indexLookupMs: elapsedMs,
|
|
totalMs: elapsedMs,
|
|
valuesPrinted: false
|
|
},
|
|
index: {
|
|
hit: true,
|
|
source: "process-session-index",
|
|
indexedEventCount: state.records.length,
|
|
indexedSessionCount: state.bySession.size,
|
|
incompleteSessionCount: state.incompleteSessions.size,
|
|
valuesRedacted: true
|
|
},
|
|
events,
|
|
valuesPrinted: false
|
|
}
|
|
};
|
|
}
|
|
|
|
function status() {
|
|
return {
|
|
enabled: true,
|
|
ready,
|
|
rebuilding,
|
|
indexedEventCount: state.records.length,
|
|
indexedSessionCount: state.bySession.size,
|
|
incompleteSessionCount: state.incompleteSessions.size,
|
|
globalComplete: state.globalComplete,
|
|
lastRebuildStartedAt: iso(lastRebuildStartedAtMs),
|
|
lastRebuildCompletedAt: iso(lastRebuildCompletedAtMs),
|
|
warning: lastFailure,
|
|
blocking: false,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function sessionSummaries(params = {}) {
|
|
if (!ready || !state.globalComplete) {
|
|
const reason = !ready ? "index-not-ready" : "global-capacity-exceeded";
|
|
return {
|
|
ok: false,
|
|
sessions: [],
|
|
warning: warning("workbench_kafka_session_summary_unavailable", `Session summary index is unavailable (${reason}).`),
|
|
status: status(),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
const requestedSessionIds = normalizeSessionIds(params.sessionIds, params.sessionId);
|
|
const incompleteSessionId = requestedSessionIds.find((sessionId) => state.incompleteSessions.has(sessionId));
|
|
if (incompleteSessionId) {
|
|
return {
|
|
ok: false,
|
|
sessions: [],
|
|
warning: warning("workbench_kafka_session_rail_incomplete", "Session rail projection cannot use an incomplete session bucket."),
|
|
status: status(),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
const sessionIds = requestedSessionIds.length > 0 ? requestedSessionIds : [...state.railBySession.keys()].sort();
|
|
const sessions = sessionIds
|
|
.map((id) => state.railBySession.get(id))
|
|
.filter((summary) => Boolean(summary?.firstUserMessagePreview));
|
|
return { ok: true, sessions, warning: null, status: status(), valuesRedacted: true };
|
|
}
|
|
|
|
function emptyState() {
|
|
return {
|
|
records: [],
|
|
byTransport: new Map(),
|
|
bySession: new Map(),
|
|
railBySession: new Map(),
|
|
incompleteSessions: new Set(),
|
|
endOffsets: new Map(),
|
|
globalComplete: true
|
|
};
|
|
}
|
|
|
|
function addRecord(target, record) {
|
|
const transportKey = `${record.topic}:${record.partition}:${record.offset}`;
|
|
if (target.byTransport.has(transportKey)) return;
|
|
target.byTransport.set(transportKey, record);
|
|
target.records.push(record);
|
|
updateEndOffset(target.endOffsets, record.partition, record.offset);
|
|
const id = indexedSessionId(record);
|
|
if (id) {
|
|
const bucket = target.bySession.get(id) ?? [];
|
|
bucket.push(record);
|
|
target.bySession.set(id, bucket);
|
|
target.railBySession.set(id, applyWorkbenchKafkaRailRecord(target.railBySession.get(id) ?? emptyRailSummary(id), record));
|
|
if (bucket.length > maxEventsPerSession) {
|
|
const removed = bucket.shift();
|
|
if (removed) removeRecord(target, removed);
|
|
target.incompleteSessions.add(id);
|
|
}
|
|
}
|
|
if (target.records.length > maxEvents) {
|
|
const removed = target.records[0];
|
|
removeRecord(target, removed);
|
|
const removedSessionId = indexedSessionId(removed);
|
|
if (removedSessionId) target.incompleteSessions.add(removedSessionId);
|
|
target.globalComplete = false;
|
|
}
|
|
}
|
|
|
|
function removeRecord(target, record) {
|
|
if (!record) return;
|
|
const transportKey = `${record.topic}:${record.partition}:${record.offset}`;
|
|
target.byTransport.delete(transportKey);
|
|
const recordIndex = target.records.indexOf(record);
|
|
if (recordIndex >= 0) target.records.splice(recordIndex, 1);
|
|
const id = indexedSessionId(record);
|
|
const bucket = id ? target.bySession.get(id) : null;
|
|
if (bucket) {
|
|
const bucketIndex = bucket.indexOf(record);
|
|
if (bucketIndex >= 0) bucket.splice(bucketIndex, 1);
|
|
if (bucket.length === 0) target.bySession.delete(id);
|
|
}
|
|
}
|
|
|
|
function logWarning(entry, fields = {}) {
|
|
logger.warn?.(JSON.stringify({
|
|
code: entry.code,
|
|
component: "workbench-kafka-session-index",
|
|
message: entry.message,
|
|
blocking: false,
|
|
...fields,
|
|
valuesPrinted: false
|
|
}));
|
|
}
|
|
|
|
return {
|
|
start,
|
|
stop,
|
|
observeLive,
|
|
query,
|
|
sessionSummaries,
|
|
status,
|
|
rebuild,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
export function workbenchKafkaRailSummary(sessionIdValue, recordsValue) {
|
|
const id = text(sessionIdValue);
|
|
if (!id) return null;
|
|
const records = Array.isArray(recordsValue) ? recordsValue : [];
|
|
let summary = emptyRailSummary(id);
|
|
for (const record of records) {
|
|
summary = applyWorkbenchKafkaRailRecord(summary, record);
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
function applyWorkbenchKafkaRailRecord(previous, record) {
|
|
const id = text(previous?.sessionId);
|
|
if (!id || indexedSessionId(record) !== id || !recordIsNewerThanRailRevision(record, previous.revision)) return previous;
|
|
const event = record.value?.event && typeof record.value.event === "object" ? record.value.event : {};
|
|
const eventType = firstText(event.type, event.eventType);
|
|
const traceId = firstText(record.value?.traceId, event.traceId, record.value?.context?.traceId);
|
|
const next = {
|
|
...previous,
|
|
revision: railRevision(record)
|
|
};
|
|
if (eventType === "user") {
|
|
const userText = firstText(event.text, event.message);
|
|
const delivery = firstText(event.delivery, event.commandType)?.toLowerCase();
|
|
const userTurnTraceId = delivery === "steer" ? firstText(event.targetTraceId, traceId) : traceId;
|
|
const preservesTerminalStatus = userTurnTraceId === previous.lastTraceId && railStatusIsTerminal(previous.status);
|
|
return {
|
|
...next,
|
|
firstUserMessagePreview: previous.firstUserMessagePreview || (userText ? userText.slice(0, 240) : null),
|
|
lastUserMessageAt: firstText(event.submittedAt, event.createdAt, previous.lastUserMessageAt),
|
|
lastTraceId: userTurnTraceId,
|
|
status: preservesTerminalStatus ? previous.status : "running"
|
|
};
|
|
}
|
|
if (!previous.lastTraceId || traceId !== previous.lastTraceId) return next;
|
|
if (railEventIsTerminal(event)) return {
|
|
...next,
|
|
status: normalizeRailStatus(firstText(event.terminalStatus, event.status)) ?? "completed"
|
|
};
|
|
if (!railStatusIsTerminal(previous.status)) return { ...next, status: "running" };
|
|
return next;
|
|
}
|
|
|
|
function emptyRailSummary(sessionId) {
|
|
return {
|
|
sessionId,
|
|
firstUserMessagePreview: null,
|
|
lastUserMessageAt: null,
|
|
lastTraceId: null,
|
|
status: "idle",
|
|
revision: null,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function railRevision(record) {
|
|
return {
|
|
topic: record.topic,
|
|
partition: record.partition,
|
|
offset: record.offset,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function recordIsNewerThanRailRevision(record, revision) {
|
|
if (!revision) return true;
|
|
if (record.topic !== revision.topic || record.partition !== revision.partition) return false;
|
|
return compareDecimalOffsets(record.offset, revision.offset) > 0;
|
|
}
|
|
|
|
function compareDecimalOffsets(left, right) {
|
|
const leftValue = String(left ?? "").replace(/^0+(?=\d)/u, "");
|
|
const rightValue = String(right ?? "").replace(/^0+(?=\d)/u, "");
|
|
if (!/^\d+$/u.test(leftValue) || !/^\d+$/u.test(rightValue)) return -1;
|
|
if (leftValue.length !== rightValue.length) return leftValue.length - rightValue.length;
|
|
return leftValue.localeCompare(rightValue);
|
|
}
|
|
|
|
function railEventIsTerminal(event) {
|
|
return event?.terminal === true || firstText(event?.eventType) === "terminal" || firstText(event?.type) === "result";
|
|
}
|
|
|
|
function normalizeRailStatus(value) {
|
|
const normalized = firstText(value)?.toLowerCase();
|
|
if (!normalized) return null;
|
|
if (normalized === "cancelled") return "canceled";
|
|
return normalized;
|
|
}
|
|
|
|
function railStatusIsTerminal(value) {
|
|
return ["completed", "failed", "blocked", "timeout", "canceled", "interrupted", "expired"].includes(normalizeRailStatus(value));
|
|
}
|
|
|
|
function normalizeRecord(raw) {
|
|
const recordTopic = text(raw?.topic);
|
|
const partition = integer(raw?.partition);
|
|
const offset = offsetValue(raw?.offset);
|
|
const value = raw?.value;
|
|
if (!recordTopic || partition === null || offset === null || value?.schema !== "hwlab.event.v1") return null;
|
|
return {
|
|
topic: recordTopic,
|
|
partition,
|
|
offset,
|
|
key: text(raw?.key),
|
|
timestamp: text(raw?.timestamp),
|
|
valueSha256: text(raw?.valueSha256) ?? sha256(JSON.stringify(value)),
|
|
value
|
|
};
|
|
}
|
|
|
|
function completeQuery(result) {
|
|
return result?.completion?.complete === true
|
|
&& result?.completion?.barrierReached === true
|
|
&& result?.completion?.retentionStartVerified === true
|
|
&& result?.reachedEndOffsets === true
|
|
&& result?.endOffsetsAvailable === true;
|
|
}
|
|
|
|
function normalizeEndOffsets(value) {
|
|
const result = new Map();
|
|
for (const entry of Array.isArray(value) ? value : []) {
|
|
const partition = integer(entry?.partition);
|
|
const endOffset = offsetValue(entry?.endOffset);
|
|
if (partition === null || endOffset === null) continue;
|
|
result.set(partition, {
|
|
partition,
|
|
startOffset: offsetValue(entry?.startOffset),
|
|
endOffset
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function updateEndOffset(endOffsets, partition, offset) {
|
|
const next = nextOffset(offset);
|
|
if (next === null) return;
|
|
const existing = endOffsets.get(partition);
|
|
if (!existing || compareOffset(next, existing.endOffset) > 0) {
|
|
endOffsets.set(partition, {
|
|
partition,
|
|
startOffset: existing?.startOffset ?? null,
|
|
endOffset: next
|
|
});
|
|
}
|
|
}
|
|
|
|
function sessionId(value) {
|
|
return firstText(
|
|
value?.sessionId,
|
|
value?.hwlabSessionId,
|
|
value?.event?.sessionId,
|
|
value?.event?.hwlabSessionId,
|
|
value?.context?.sessionId,
|
|
value?.context?.hwlabSessionId
|
|
);
|
|
}
|
|
|
|
function normalizeSessionIds(values, fallback = null) {
|
|
const source = Array.isArray(values) ? values : [fallback];
|
|
return [...new Set(source.map((value) => text(value)).filter(Boolean))].sort();
|
|
}
|
|
|
|
function indexedSessionId(record) {
|
|
const envelopeSessionId = sessionId(record?.value);
|
|
const key = text(record?.key);
|
|
if (!envelopeSessionId) return null;
|
|
if (key && key !== envelopeSessionId) return null;
|
|
return envelopeSessionId;
|
|
}
|
|
|
|
function matchesTrace(value, traceId) {
|
|
if (!traceId) return true;
|
|
return [
|
|
value?.traceId,
|
|
value?.targetTraceId,
|
|
value?.event?.traceId,
|
|
value?.event?.targetTraceId,
|
|
value?.context?.traceId,
|
|
value?.context?.targetTraceId,
|
|
value?.sourceEvent?.traceId
|
|
].some((candidate) => text(candidate) === traceId);
|
|
}
|
|
|
|
function partitionOffsets(records, mode) {
|
|
const byPartition = new Map();
|
|
for (const record of records) {
|
|
const existing = byPartition.get(record.partition);
|
|
if (existing === undefined || (mode === "first" ? compareOffset(record.offset, existing) < 0 : compareOffset(record.offset, existing) > 0)) {
|
|
byPartition.set(record.partition, record.offset);
|
|
}
|
|
}
|
|
return [...byPartition.entries()]
|
|
.map(([partition, offset]) => ({ partition, offset }))
|
|
.sort((left, right) => left.partition - right.partition);
|
|
}
|
|
|
|
function warning(code, message) {
|
|
return { code, message, blocking: false, valuesRedacted: true };
|
|
}
|
|
|
|
function indexError(code, message) {
|
|
const error = new Error(message);
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
function errorMessage(error) {
|
|
return error instanceof Error ? error.message : String(error ?? "unknown error");
|
|
}
|
|
|
|
function compact(value) {
|
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== ""));
|
|
}
|
|
|
|
function firstText(...values) {
|
|
for (const value of values) {
|
|
const result = text(value);
|
|
if (result) return result;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function text(value) {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function integer(value) {
|
|
const result = Number(value);
|
|
return Number.isInteger(result) && result >= 0 ? result : null;
|
|
}
|
|
|
|
function positiveInteger(value) {
|
|
const result = Number(value);
|
|
return Number.isInteger(result) && result > 0 ? result : null;
|
|
}
|
|
|
|
function offsetValue(value) {
|
|
const result = text(String(value ?? ""));
|
|
if (!result) return null;
|
|
try {
|
|
return BigInt(result) >= 0n ? result : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function nextOffset(value) {
|
|
try {
|
|
return String(BigInt(String(value)) + 1n);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function compareOffset(left, right) {
|
|
try {
|
|
const leftValue = BigInt(String(left));
|
|
const rightValue = BigInt(String(right));
|
|
return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0;
|
|
} catch {
|
|
return String(left).localeCompare(String(right));
|
|
}
|
|
}
|
|
|
|
function iso(value) {
|
|
return Number.isFinite(value) ? new Date(value).toISOString() : null;
|
|
}
|
|
|
|
function sha256(value) {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|