mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
1291 lines
48 KiB
JavaScript
1291 lines
48 KiB
JavaScript
import { createEmptyGraph, deserializeGraph } from "../graph/graph.js";
|
||
import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js";
|
||
import {
|
||
BME_DB_SCHEMA_VERSION,
|
||
BME_LEGACY_RETENTION_MS,
|
||
BME_TOMBSTONE_RETENTION_MS,
|
||
buildSnapshotFromGraph,
|
||
} from "./bme-db.js";
|
||
import { normalizeAuthorityBaseUrl } from "../runtime/authority-capabilities.js";
|
||
import { AuthorityHttpClient } from "../runtime/authority-http-client.js";
|
||
|
||
export const AUTHORITY_GRAPH_STORE_KIND = "authority";
|
||
export const AUTHORITY_GRAPH_STORE_MODE = "authority-sql-primary";
|
||
|
||
const META_DEFAULT_LAST_PROCESSED_FLOOR = -1;
|
||
const META_DEFAULT_EXTRACTION_COUNT = 0;
|
||
const DEFAULT_AUTHORITY_SQL_DATABASE = "default";
|
||
const AUTHORITY_SQL_QUERY_ENDPOINT = "/sql/query";
|
||
const AUTHORITY_SQL_EXEC_ENDPOINT = "/sql/exec";
|
||
const AUTHORITY_SQL_TRANSACTION_ENDPOINT = "/sql/transaction";
|
||
const AUTHORITY_SQL_TRANSACTION_BATCH_SIZE = 150;
|
||
const AUTHORITY_SQL_TRANSACTION_MAX_REQUEST_BYTES = 1024 * 1024;
|
||
const AUTHORITY_SQL_TRANSACTION_SAFE_REQUEST_BYTES = 512 * 1024;
|
||
|
||
const AUTHORITY_TABLES = Object.freeze({
|
||
meta: "st_bme_graph_meta",
|
||
nodes: "st_bme_graph_nodes",
|
||
edges: "st_bme_graph_edges",
|
||
tombstones: "st_bme_graph_tombstones",
|
||
});
|
||
|
||
const PERSIST_META_RESERVED_KEYS = new Set([
|
||
"revision",
|
||
"lastModified",
|
||
"nodeCount",
|
||
"edgeCount",
|
||
"tombstoneCount",
|
||
"syncDirty",
|
||
"syncDirtyReason",
|
||
"lastMutationReason",
|
||
]);
|
||
|
||
function normalizeChatId(chatId) {
|
||
return String(chatId ?? "").trim();
|
||
}
|
||
|
||
function normalizeRecordId(value) {
|
||
return String(value ?? "").trim();
|
||
}
|
||
|
||
function normalizeRevision(value) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||
return Math.floor(parsed);
|
||
}
|
||
|
||
function normalizeTimestamp(value, fallbackValue = Date.now()) {
|
||
const parsed = Number(value);
|
||
if (Number.isFinite(parsed)) return Math.floor(parsed);
|
||
return Math.floor(Number(fallbackValue) || Date.now());
|
||
}
|
||
|
||
function normalizeNonNegativeInteger(value, fallback = 0) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) {
|
||
return Math.max(0, Math.floor(Number(fallback) || 0));
|
||
}
|
||
return Math.max(0, Math.floor(parsed));
|
||
}
|
||
|
||
function readPersistCommitNow() {
|
||
if (typeof performance === "object" && typeof performance.now === "function") {
|
||
return performance.now();
|
||
}
|
||
return Date.now();
|
||
}
|
||
|
||
function normalizePersistCommitMs(value = 0) {
|
||
return Math.round((Number(value) || 0) * 10) / 10;
|
||
}
|
||
|
||
function estimatePersistPayloadBytes(value = null) {
|
||
if (value == null) return 0;
|
||
try {
|
||
return JSON.stringify(value).length;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function measureJsonBytes(value = null) {
|
||
let json = "";
|
||
try {
|
||
json = JSON.stringify(value ?? null);
|
||
} catch {
|
||
json = "";
|
||
}
|
||
if (typeof TextEncoder === "function") {
|
||
return new TextEncoder().encode(json).byteLength;
|
||
}
|
||
return json.length * 3;
|
||
}
|
||
|
||
function normalizeSqlTransactionByteBudget(value) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) return AUTHORITY_SQL_TRANSACTION_SAFE_REQUEST_BYTES;
|
||
return Math.max(1024, Math.floor(parsed));
|
||
}
|
||
|
||
function isAuthorityPayloadTooLargeError(error = null) {
|
||
const status = Number(error?.status || error?.payload?.status || 0);
|
||
const category = String(error?.category || error?.payload?.category || "").toLowerCase();
|
||
const code = String(error?.code || error?.payload?.code || error?.payload?.reason || "").toLowerCase();
|
||
const message = String(error?.message || error?.payload?.error || error?.payload?.message || "").toLowerCase();
|
||
return (
|
||
status === 413 ||
|
||
category === "payload-too-large" ||
|
||
category === "limit" ||
|
||
code.includes("length_limit") ||
|
||
code.includes("payload") ||
|
||
message.includes("length limit") ||
|
||
message.includes("payload too large") ||
|
||
message.includes("request entity too large")
|
||
);
|
||
}
|
||
|
||
function createTerminalAuthoritySqlPayloadError(statement = null, cause = null) {
|
||
const estimatedBytes = estimateAuthoritySqlTransactionRequestBytes([statement], DEFAULT_AUTHORITY_SQL_DATABASE);
|
||
const error = new Error(
|
||
`Authority SQL transaction payload is too large for a single graph persistence statement (${estimatedBytes} bytes estimated); refusing endless retry`,
|
||
);
|
||
error.name = "AuthoritySqlPayloadTooLargeError";
|
||
error.status = Number(cause?.status || 413);
|
||
error.code = "authority_sql_payload_too_large";
|
||
error.category = "payload-too-large";
|
||
error.terminal = true;
|
||
error.nonRetryable = true;
|
||
error.estimatedBytes = estimatedBytes;
|
||
error.cause = cause;
|
||
return error;
|
||
}
|
||
|
||
function normalizeAuthorityTransactionStatement(statement = {}) {
|
||
const positional = convertNamedParamsToPositional(String(statement?.sql || ""), statement?.params || {});
|
||
return {
|
||
statement: positional.sql,
|
||
params: positional.params,
|
||
};
|
||
}
|
||
|
||
function estimateAuthoritySqlTransactionRequestBytes(statements = [], database = DEFAULT_AUTHORITY_SQL_DATABASE) {
|
||
return measureJsonBytes({
|
||
database: normalizeRecordId(database) || DEFAULT_AUTHORITY_SQL_DATABASE,
|
||
statements: toArray(statements)
|
||
.filter((statement) => statement?.sql)
|
||
.map(normalizeAuthorityTransactionStatement),
|
||
});
|
||
}
|
||
|
||
function buildAuthoritySqlTransactionChunks(statements = [], options = {}) {
|
||
const normalizedStatements = toArray(statements).filter((statement) => statement?.sql);
|
||
const maxStatements = Math.max(1, Math.floor(Number(options.maxStatements) || AUTHORITY_SQL_TRANSACTION_BATCH_SIZE));
|
||
const maxBytes = normalizeSqlTransactionByteBudget(options.maxBytes);
|
||
const database = normalizeRecordId(options.database) || DEFAULT_AUTHORITY_SQL_DATABASE;
|
||
const chunks = [];
|
||
let current = [];
|
||
|
||
const pushCurrent = () => {
|
||
if (current.length) {
|
||
chunks.push(current);
|
||
current = [];
|
||
}
|
||
};
|
||
|
||
for (const statement of normalizedStatements) {
|
||
if (!current.length) {
|
||
current.push(statement);
|
||
continue;
|
||
}
|
||
|
||
const candidate = [...current, statement];
|
||
const candidateBytes = estimateAuthoritySqlTransactionRequestBytes(candidate, database);
|
||
if (candidate.length > maxStatements || candidateBytes > maxBytes) {
|
||
pushCurrent();
|
||
}
|
||
current.push(statement);
|
||
}
|
||
|
||
pushCurrent();
|
||
return chunks;
|
||
}
|
||
|
||
function toPlainData(value, fallbackValue = null) {
|
||
if (value == null) return fallbackValue;
|
||
if (typeof globalThis.structuredClone === "function") {
|
||
try {
|
||
return globalThis.structuredClone(value);
|
||
} catch {
|
||
}
|
||
}
|
||
try {
|
||
return JSON.parse(JSON.stringify(value));
|
||
} catch {
|
||
return fallbackValue;
|
||
}
|
||
}
|
||
|
||
function toArray(value) {
|
||
return Array.isArray(value) ? value : [];
|
||
}
|
||
|
||
function createDefaultMetaValues(chatId = "", nowMs = Date.now()) {
|
||
const normalizedNow = normalizeTimestamp(nowMs);
|
||
return {
|
||
chatId: normalizeChatId(chatId),
|
||
revision: 0,
|
||
lastProcessedFloor: META_DEFAULT_LAST_PROCESSED_FLOOR,
|
||
extractionCount: META_DEFAULT_EXTRACTION_COUNT,
|
||
lastModified: normalizedNow,
|
||
lastSyncUploadedAt: 0,
|
||
lastSyncDownloadedAt: 0,
|
||
lastSyncedRevision: 0,
|
||
lastBackupUploadedAt: 0,
|
||
lastBackupRestoredAt: 0,
|
||
lastBackupRollbackAt: 0,
|
||
lastBackupFilename: "",
|
||
syncDirtyReason: "",
|
||
deviceId: "",
|
||
nodeCount: 0,
|
||
edgeCount: 0,
|
||
tombstoneCount: 0,
|
||
schemaVersion: BME_DB_SCHEMA_VERSION,
|
||
syncDirty: false,
|
||
migrationCompletedAt: 0,
|
||
migrationSource: "",
|
||
legacyRetentionUntil: 0,
|
||
storagePrimary: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
};
|
||
}
|
||
|
||
function normalizeMode(value = "replace") {
|
||
return String(value || "").toLowerCase() === "merge" ? "merge" : "replace";
|
||
}
|
||
|
||
function parseJsonValue(value, fallbackValue = null) {
|
||
if (value == null) return fallbackValue;
|
||
if (typeof value === "object") return toPlainData(value, fallbackValue);
|
||
try {
|
||
return JSON.parse(String(value));
|
||
} catch {
|
||
return fallbackValue;
|
||
}
|
||
}
|
||
|
||
function stringifyJsonValue(value) {
|
||
return JSON.stringify(toPlainData(value, value));
|
||
}
|
||
|
||
function readRowValue(row = {}, keys = []) {
|
||
for (const key of keys) {
|
||
if (Object.prototype.hasOwnProperty.call(row, key)) {
|
||
return row[key];
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function normalizeSqlRows(result = null) {
|
||
if (Array.isArray(result)) return result;
|
||
if (!result || typeof result !== "object") return [];
|
||
if (Array.isArray(result.rows)) return result.rows;
|
||
if (Array.isArray(result.data)) return result.data;
|
||
if (Array.isArray(result.result?.rows)) return result.result.rows;
|
||
if (Array.isArray(result.results?.[0]?.rows)) return result.results[0].rows;
|
||
return [];
|
||
}
|
||
|
||
function normalizeCountResult(result = null) {
|
||
const row = normalizeSqlRows(result)[0] || {};
|
||
return normalizeNonNegativeInteger(
|
||
readRowValue(row, ["count", "COUNT(*)", "COUNT", "total", "value"]),
|
||
0,
|
||
);
|
||
}
|
||
|
||
function toMetaMap(rows = []) {
|
||
const output = {};
|
||
for (const row of rows) {
|
||
if (!row || typeof row !== "object") continue;
|
||
const key = normalizeRecordId(readRowValue(row, ["key", "meta_key", "metaKey"]));
|
||
if (!key) continue;
|
||
output[key] = parseJsonValue(
|
||
readRowValue(row, ["valueJson", "value_json", "value"]),
|
||
null,
|
||
);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function sanitizeSnapshot(snapshot = {}) {
|
||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
||
return {
|
||
meta: {},
|
||
state: {},
|
||
nodes: [],
|
||
edges: [],
|
||
tombstones: [],
|
||
};
|
||
}
|
||
return {
|
||
meta:
|
||
snapshot.meta && typeof snapshot.meta === "object" && !Array.isArray(snapshot.meta)
|
||
? toPlainData(snapshot.meta, {})
|
||
: {},
|
||
state:
|
||
snapshot.state && typeof snapshot.state === "object" && !Array.isArray(snapshot.state)
|
||
? toPlainData(snapshot.state, {})
|
||
: {},
|
||
nodes: toArray(snapshot.nodes).filter(Boolean).map((node) => toPlainData(node, node)),
|
||
edges: toArray(snapshot.edges).filter(Boolean).map((edge) => toPlainData(edge, edge)),
|
||
tombstones: toArray(snapshot.tombstones).filter(Boolean).map((record) => toPlainData(record, record)),
|
||
};
|
||
}
|
||
|
||
function normalizeStateSnapshot(snapshot = {}) {
|
||
const state = snapshot?.state && typeof snapshot.state === "object" ? snapshot.state : {};
|
||
const meta = snapshot?.meta && typeof snapshot.meta === "object" ? snapshot.meta : {};
|
||
return {
|
||
lastProcessedFloor: Number.isFinite(Number(state.lastProcessedFloor ?? meta.lastProcessedFloor))
|
||
? Number(state.lastProcessedFloor ?? meta.lastProcessedFloor)
|
||
: META_DEFAULT_LAST_PROCESSED_FLOOR,
|
||
extractionCount: Number.isFinite(Number(state.extractionCount ?? meta.extractionCount))
|
||
? Number(state.extractionCount ?? meta.extractionCount)
|
||
: META_DEFAULT_EXTRACTION_COUNT,
|
||
};
|
||
}
|
||
|
||
function applyListOptions(records, options = {}) {
|
||
let nextRecords = toArray(records);
|
||
const orderBy = String(options.orderBy || "updatedAt").trim();
|
||
const reverse = options.reverse !== false;
|
||
nextRecords = nextRecords.sort((left, right) => {
|
||
const leftValue = Number(left?.[orderBy]);
|
||
const rightValue = Number(right?.[orderBy]);
|
||
if (!Number.isFinite(leftValue) && !Number.isFinite(rightValue)) return 0;
|
||
if (!Number.isFinite(leftValue)) return reverse ? 1 : -1;
|
||
if (!Number.isFinite(rightValue)) return reverse ? -1 : 1;
|
||
return reverse ? rightValue - leftValue : leftValue - rightValue;
|
||
});
|
||
const limit = Number(options.limit);
|
||
if (Number.isFinite(limit) && limit > 0) {
|
||
nextRecords = nextRecords.slice(0, Math.floor(limit));
|
||
}
|
||
return toPlainData(nextRecords, []);
|
||
}
|
||
|
||
function normalizeNodeRecords(nodes = [], fallbackNowMs = Date.now()) {
|
||
const nowMs = normalizeTimestamp(fallbackNowMs);
|
||
return toArray(nodes)
|
||
.map((node) => {
|
||
if (!node || typeof node !== "object" || Array.isArray(node)) return null;
|
||
const id = normalizeRecordId(node.id);
|
||
if (!id) return null;
|
||
return {
|
||
...toPlainData(node, node),
|
||
id,
|
||
updatedAt: normalizeTimestamp(node.updatedAt, nowMs),
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function normalizeEdgeRecords(edges = [], fallbackNowMs = Date.now()) {
|
||
const nowMs = normalizeTimestamp(fallbackNowMs);
|
||
return toArray(edges)
|
||
.map((edge) => {
|
||
if (!edge || typeof edge !== "object" || Array.isArray(edge)) return null;
|
||
const id = normalizeRecordId(edge.id);
|
||
if (!id) return null;
|
||
return {
|
||
...toPlainData(edge, edge),
|
||
id,
|
||
fromId: normalizeRecordId(edge.fromId),
|
||
toId: normalizeRecordId(edge.toId),
|
||
updatedAt: normalizeTimestamp(edge.updatedAt, nowMs),
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function normalizeTombstoneRecords(tombstones = [], fallbackNowMs = Date.now()) {
|
||
const nowMs = normalizeTimestamp(fallbackNowMs);
|
||
return toArray(tombstones)
|
||
.map((record) => {
|
||
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
||
const id = normalizeRecordId(record.id);
|
||
if (!id) return null;
|
||
return {
|
||
...toPlainData(record, record),
|
||
id,
|
||
kind: normalizeRecordId(record.kind),
|
||
targetId: normalizeRecordId(record.targetId),
|
||
sourceDeviceId: normalizeRecordId(record.sourceDeviceId),
|
||
deletedAt: normalizeTimestamp(record.deletedAt, nowMs),
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function normalizePayloadRows(rows = []) {
|
||
return normalizeSqlRows(rows)
|
||
.map((row) =>
|
||
parseJsonValue(readRowValue(row, ["payloadJson", "payload_json", "payload"]), null),
|
||
)
|
||
.filter((record) => record && typeof record === "object" && !Array.isArray(record));
|
||
}
|
||
|
||
function normalizeUpsertCountDelta(delta = {}) {
|
||
const source = delta && typeof delta === "object" && !Array.isArray(delta) ? delta : {};
|
||
const next = source.next && typeof source.next === "object" ? source.next : null;
|
||
if (!next) {
|
||
const previous =
|
||
source.previous && typeof source.previous === "object" ? source.previous : null;
|
||
const deltaCounts =
|
||
source.delta && typeof source.delta === "object" ? source.delta : null;
|
||
if (!previous || !deltaCounts) return null;
|
||
return {
|
||
nodes: normalizeNonNegativeInteger(
|
||
Number(previous.nodes || 0) + Number(deltaCounts.nodes || 0),
|
||
0,
|
||
),
|
||
edges: normalizeNonNegativeInteger(
|
||
Number(previous.edges || 0) + Number(deltaCounts.edges || 0),
|
||
0,
|
||
),
|
||
tombstones: normalizeNonNegativeInteger(
|
||
Number(previous.tombstones || 0) + Number(deltaCounts.tombstones || 0),
|
||
0,
|
||
),
|
||
};
|
||
}
|
||
return {
|
||
nodes: normalizeNonNegativeInteger(next.nodes, 0),
|
||
edges: normalizeNonNegativeInteger(next.edges, 0),
|
||
tombstones: normalizeNonNegativeInteger(next.tombstones, 0),
|
||
};
|
||
}
|
||
|
||
export function convertNamedParamsToPositional(sql, params = {}) {
|
||
if (Array.isArray(params)) return { sql, params };
|
||
if (!params || typeof params !== "object") return { sql, params: [] };
|
||
const names = [];
|
||
const positionalSql = sql.replace(/(?<!:):(\w+)/g, (_, name) => {
|
||
names.push(name);
|
||
return "?";
|
||
});
|
||
if (!names.length) return { sql: positionalSql, params: [] };
|
||
const positionalParams = names.map((name) => {
|
||
if (!Object.prototype.hasOwnProperty.call(params, name)) return null;
|
||
return params[name];
|
||
});
|
||
return { sql: positionalSql, params: positionalParams };
|
||
}
|
||
|
||
export class AuthoritySqlHttpClient {
|
||
constructor(options = {}) {
|
||
this.http = new AuthorityHttpClient({
|
||
...options,
|
||
baseUrl: normalizeAuthorityBaseUrl(options.baseUrl),
|
||
});
|
||
this.database = normalizeRecordId(options.database) || DEFAULT_AUTHORITY_SQL_DATABASE;
|
||
}
|
||
|
||
async query(sql, params = {}) {
|
||
const positional = convertNamedParamsToPositional(String(sql || ""), params);
|
||
return await this._request(AUTHORITY_SQL_QUERY_ENDPOINT, {
|
||
database: this.database,
|
||
statement: positional.sql,
|
||
params: positional.params,
|
||
});
|
||
}
|
||
|
||
async execute(sql, params = {}) {
|
||
const positional = convertNamedParamsToPositional(String(sql || ""), params);
|
||
return await this._request(AUTHORITY_SQL_EXEC_ENDPOINT, {
|
||
database: this.database,
|
||
statement: positional.sql,
|
||
params: positional.params,
|
||
});
|
||
}
|
||
|
||
async transaction(statements = []) {
|
||
return await this._request(AUTHORITY_SQL_TRANSACTION_ENDPOINT, {
|
||
database: this.database,
|
||
statements: toArray(statements)
|
||
.filter((statement) => statement?.sql)
|
||
.map(normalizeAuthorityTransactionStatement),
|
||
});
|
||
}
|
||
|
||
async _request(path, body = {}) {
|
||
return await this.http.requestJson(path, {
|
||
method: "POST",
|
||
body,
|
||
session: true,
|
||
});
|
||
}
|
||
}
|
||
|
||
export function createAuthoritySqlClient(options = {}) {
|
||
if (options.sqlClient && typeof options.sqlClient === "object") {
|
||
return options.sqlClient;
|
||
}
|
||
return new AuthoritySqlHttpClient(options);
|
||
}
|
||
|
||
export class AuthorityGraphStore {
|
||
constructor(chatId, options = {}) {
|
||
this.chatId = normalizeChatId(chatId);
|
||
this.options = options;
|
||
this.storeKind = AUTHORITY_GRAPH_STORE_KIND;
|
||
this.storeMode = AUTHORITY_GRAPH_STORE_MODE;
|
||
this.sqlClient = createAuthoritySqlClient(options);
|
||
this._openPromise = null;
|
||
this._opened = false;
|
||
}
|
||
|
||
async open() {
|
||
if (this._opened) return this;
|
||
if (!this._openPromise) {
|
||
this._openPromise = (async () => {
|
||
await this._ensureSchema();
|
||
await this._ensureMetaDefaults();
|
||
this._opened = true;
|
||
return this;
|
||
})().catch((error) => {
|
||
this._openPromise = null;
|
||
this._opened = false;
|
||
throw error;
|
||
});
|
||
}
|
||
return await this._openPromise;
|
||
}
|
||
|
||
async close() {
|
||
if (typeof this.sqlClient?.close === "function") {
|
||
await this.sqlClient.close();
|
||
}
|
||
this._opened = false;
|
||
this._openPromise = null;
|
||
}
|
||
|
||
getStorageDiagnosticsSync() {
|
||
return {
|
||
formatVersion: 1,
|
||
migrationState: "idle",
|
||
resolvedStoreMode: this.storeMode,
|
||
storageKind: this.storeKind,
|
||
browserCacheMode: "minimal",
|
||
};
|
||
}
|
||
|
||
async getMeta(key, fallbackValue = null) {
|
||
await this.open();
|
||
const normalizedKey = normalizeRecordId(key);
|
||
if (!normalizedKey) return fallbackValue;
|
||
const rows = await this._query(
|
||
`SELECT meta_key AS key, value_json AS valueJson FROM ${AUTHORITY_TABLES.meta} WHERE chat_id = :chatId AND meta_key = :key LIMIT 1`,
|
||
{ chatId: this.chatId, key: normalizedKey },
|
||
);
|
||
const row = normalizeSqlRows(rows)[0] || null;
|
||
if (!row) return fallbackValue;
|
||
return parseJsonValue(readRowValue(row, ["valueJson", "value_json", "value"]), fallbackValue);
|
||
}
|
||
|
||
async setMeta(key, value) {
|
||
await this.open();
|
||
const normalizedKey = normalizeRecordId(key);
|
||
if (!normalizedKey) return null;
|
||
const nowMs = Date.now();
|
||
await this._executeStatements([this._upsertMetaStatement(normalizedKey, value, nowMs)]);
|
||
return {
|
||
key: normalizedKey,
|
||
value: toPlainData(value, value),
|
||
updatedAt: nowMs,
|
||
};
|
||
}
|
||
|
||
async patchMeta(record) {
|
||
await this.open();
|
||
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
||
return {};
|
||
}
|
||
const nowMs = Date.now();
|
||
const entries = Object.entries(record).filter(([key]) => normalizeRecordId(key));
|
||
if (!entries.length) return {};
|
||
await this._executeStatements(
|
||
entries.map(([key, value]) => this._upsertMetaStatement(key, value, nowMs)),
|
||
);
|
||
return Object.fromEntries(entries);
|
||
}
|
||
|
||
async getRevision() {
|
||
return normalizeRevision(await this.getMeta("revision", 0));
|
||
}
|
||
|
||
async bumpRevision(reason = "mutation") {
|
||
await this.open();
|
||
const nowMs = Date.now();
|
||
const nextRevision = (await this.getRevision()) + 1;
|
||
await this._executeStatements([
|
||
this._upsertMetaStatement("revision", nextRevision, nowMs),
|
||
this._upsertMetaStatement("lastModified", nowMs, nowMs),
|
||
this._upsertMetaStatement("lastMutationReason", reason, nowMs),
|
||
]);
|
||
return nextRevision;
|
||
}
|
||
|
||
async markSyncDirty(reason = "mutation") {
|
||
await this.patchMeta({
|
||
syncDirty: true,
|
||
syncDirtyReason: String(reason || "mutation"),
|
||
});
|
||
return true;
|
||
}
|
||
|
||
async commitDelta(delta = {}, options = {}) {
|
||
await this.open();
|
||
const commitRequestedAt = readPersistCommitNow();
|
||
const nowMs = Date.now();
|
||
const normalizedDelta = delta && typeof delta === "object" && !Array.isArray(delta) ? delta : {};
|
||
const upsertNodes = normalizeNodeRecords(normalizedDelta.upsertNodes, nowMs);
|
||
const upsertEdges = normalizeEdgeRecords(normalizedDelta.upsertEdges, nowMs);
|
||
const tombstones = normalizeTombstoneRecords(normalizedDelta.tombstones, nowMs);
|
||
const deleteNodeIds = toArray(normalizedDelta.deleteNodeIds).map(normalizeRecordId).filter(Boolean);
|
||
const deleteEdgeIds = toArray(normalizedDelta.deleteEdgeIds).map(normalizeRecordId).filter(Boolean);
|
||
const runtimeMetaPatch =
|
||
normalizedDelta.runtimeMetaPatch &&
|
||
typeof normalizedDelta.runtimeMetaPatch === "object" &&
|
||
!Array.isArray(normalizedDelta.runtimeMetaPatch)
|
||
? normalizedDelta.runtimeMetaPatch
|
||
: {};
|
||
const reason = String(options.reason || "commitDelta");
|
||
const requestedRevision = normalizeRevision(options.requestedRevision);
|
||
const shouldMarkSyncDirty = options.markSyncDirty !== false;
|
||
const payloadBytes = estimatePersistPayloadBytes(normalizedDelta);
|
||
const currentRevision = await this.getRevision();
|
||
const nextRevision = Math.max(currentRevision + 1, requestedRevision);
|
||
const statements = [];
|
||
|
||
for (const id of deleteEdgeIds) statements.push(this._deleteByIdStatement(AUTHORITY_TABLES.edges, id));
|
||
for (const id of deleteNodeIds) statements.push(this._deleteByIdStatement(AUTHORITY_TABLES.nodes, id));
|
||
for (const node of upsertNodes) statements.push(this._upsertNodeStatement(node));
|
||
for (const edge of upsertEdges) statements.push(this._upsertEdgeStatement(edge));
|
||
for (const tombstone of tombstones) statements.push(this._upsertTombstoneStatement(tombstone));
|
||
for (const [rawKey, value] of Object.entries(runtimeMetaPatch)) {
|
||
const key = normalizeRecordId(rawKey);
|
||
if (!key || PERSIST_META_RESERVED_KEYS.has(key)) continue;
|
||
statements.push(this._upsertMetaStatement(key, value, nowMs));
|
||
}
|
||
|
||
let counts = normalizeUpsertCountDelta(normalizedDelta.countDelta);
|
||
if (counts) {
|
||
statements.push(this._upsertMetaStatement("nodeCount", counts.nodes, nowMs));
|
||
statements.push(this._upsertMetaStatement("edgeCount", counts.edges, nowMs));
|
||
statements.push(this._upsertMetaStatement("tombstoneCount", counts.tombstones, nowMs));
|
||
}
|
||
statements.push(this._upsertMetaStatement("chatId", this.chatId, nowMs));
|
||
statements.push(this._upsertMetaStatement("schemaVersion", BME_DB_SCHEMA_VERSION, nowMs));
|
||
statements.push(this._upsertMetaStatement("storagePrimary", AUTHORITY_GRAPH_STORE_KIND, nowMs));
|
||
statements.push(this._upsertMetaStatement("storageMode", AUTHORITY_GRAPH_STORE_MODE, nowMs));
|
||
statements.push(this._upsertMetaStatement("revision", nextRevision, nowMs));
|
||
statements.push(this._upsertMetaStatement("lastModified", nowMs, nowMs));
|
||
statements.push(this._upsertMetaStatement("lastMutationReason", reason, nowMs));
|
||
statements.push(this._upsertMetaStatement("syncDirty", shouldMarkSyncDirty, nowMs));
|
||
statements.push(this._upsertMetaStatement("syncDirtyReason", shouldMarkSyncDirty ? reason : "", nowMs));
|
||
|
||
const transactionStartedAt = readPersistCommitNow();
|
||
await this._executeStatements(statements);
|
||
const transactionMs = readPersistCommitNow() - transactionStartedAt;
|
||
if (!counts) {
|
||
counts = await this._readCounts();
|
||
await this.patchMeta({
|
||
nodeCount: counts.nodes,
|
||
edgeCount: counts.edges,
|
||
tombstoneCount: counts.tombstones,
|
||
});
|
||
}
|
||
|
||
return {
|
||
revision: nextRevision,
|
||
lastModified: nowMs,
|
||
imported: counts,
|
||
delta: {
|
||
upsertNodes: upsertNodes.length,
|
||
upsertEdges: upsertEdges.length,
|
||
deleteNodeIds: deleteNodeIds.length,
|
||
deleteEdgeIds: deleteEdgeIds.length,
|
||
tombstones: tombstones.length,
|
||
},
|
||
diagnostics: {
|
||
storageKind: AUTHORITY_GRAPH_STORE_KIND,
|
||
storeMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
queueWaitMs: 0,
|
||
commitMs: normalizePersistCommitMs(readPersistCommitNow() - commitRequestedAt),
|
||
txMs: normalizePersistCommitMs(transactionMs),
|
||
payloadBytes,
|
||
runtimeMetaKeyCount: Object.keys(runtimeMetaPatch).length,
|
||
browserCacheMode: "minimal",
|
||
},
|
||
};
|
||
}
|
||
|
||
async bulkUpsertNodes(nodes = []) {
|
||
const records = normalizeNodeRecords(nodes);
|
||
if (!records.length) {
|
||
return { upserted: 0, revision: await this.getRevision() };
|
||
}
|
||
const result = await this.commitDelta({ upsertNodes: records }, { reason: "bulkUpsertNodes" });
|
||
return { upserted: records.length, revision: result.revision };
|
||
}
|
||
|
||
async bulkUpsertEdges(edges = []) {
|
||
const records = normalizeEdgeRecords(edges);
|
||
if (!records.length) {
|
||
return { upserted: 0, revision: await this.getRevision() };
|
||
}
|
||
const result = await this.commitDelta({ upsertEdges: records }, { reason: "bulkUpsertEdges" });
|
||
return { upserted: records.length, revision: result.revision };
|
||
}
|
||
|
||
async bulkUpsertTombstones(tombstones = []) {
|
||
const records = normalizeTombstoneRecords(tombstones);
|
||
if (!records.length) {
|
||
return { upserted: 0, revision: await this.getRevision() };
|
||
}
|
||
const result = await this.commitDelta({ tombstones: records }, { reason: "bulkUpsertTombstones" });
|
||
return { upserted: records.length, revision: result.revision };
|
||
}
|
||
|
||
async listNodes(options = {}) {
|
||
await this.open();
|
||
let records = normalizePayloadRows(
|
||
await this._query(`SELECT payload_json AS payloadJson FROM ${AUTHORITY_TABLES.nodes} WHERE chat_id = :chatId`, {
|
||
chatId: this.chatId,
|
||
}),
|
||
);
|
||
if (options.includeDeleted === false) {
|
||
records = records.filter((item) => !Number.isFinite(Number(item?.deletedAt)));
|
||
}
|
||
if (options.includeArchived === false) {
|
||
records = records.filter((item) => !item?.archived);
|
||
}
|
||
if (typeof options.type === "string" && options.type.trim()) {
|
||
records = records.filter((item) => String(item?.type || "") === options.type);
|
||
}
|
||
return applyListOptions(records, options);
|
||
}
|
||
|
||
async listEdges(options = {}) {
|
||
await this.open();
|
||
let records = normalizePayloadRows(
|
||
await this._query(`SELECT payload_json AS payloadJson FROM ${AUTHORITY_TABLES.edges} WHERE chat_id = :chatId`, {
|
||
chatId: this.chatId,
|
||
}),
|
||
);
|
||
if (options.includeDeleted === false) {
|
||
records = records.filter((item) => !Number.isFinite(Number(item?.deletedAt)));
|
||
}
|
||
if (typeof options.relation === "string" && options.relation.trim()) {
|
||
records = records.filter((item) => String(item?.relation || "") === options.relation);
|
||
}
|
||
return applyListOptions(records, options);
|
||
}
|
||
|
||
async listTombstones(options = {}) {
|
||
await this.open();
|
||
let records = normalizePayloadRows(
|
||
await this._query(`SELECT payload_json AS payloadJson FROM ${AUTHORITY_TABLES.tombstones} WHERE chat_id = :chatId`, {
|
||
chatId: this.chatId,
|
||
}),
|
||
);
|
||
if (typeof options.kind === "string" && options.kind.trim()) {
|
||
records = records.filter((item) => String(item?.kind || "") === options.kind);
|
||
}
|
||
if (typeof options.targetId === "string" && options.targetId.trim()) {
|
||
records = records.filter((item) => String(item?.targetId || "") === options.targetId);
|
||
}
|
||
return applyListOptions(records, options);
|
||
}
|
||
|
||
async isEmpty(options = {}) {
|
||
await this.open();
|
||
const counts = await this._readCounts();
|
||
const includeTombstones = options.includeTombstones === true;
|
||
return {
|
||
empty: includeTombstones
|
||
? counts.nodes === 0 && counts.edges === 0 && counts.tombstones === 0
|
||
: counts.nodes === 0 && counts.edges === 0,
|
||
nodes: counts.nodes,
|
||
edges: counts.edges,
|
||
tombstones: counts.tombstones,
|
||
includeTombstones,
|
||
};
|
||
}
|
||
|
||
async importLegacyGraph(legacyGraph, options = {}) {
|
||
await this.open();
|
||
const nowMs = normalizeTimestamp(options.nowMs, Date.now());
|
||
const migrationSource = normalizeRecordId(options.source || "chat_metadata") || "chat_metadata";
|
||
const requestedRetentionMs = Number(options.legacyRetentionMs);
|
||
const legacyRetentionMs =
|
||
Number.isFinite(requestedRetentionMs) && requestedRetentionMs >= 0
|
||
? Math.floor(requestedRetentionMs)
|
||
: BME_LEGACY_RETENTION_MS;
|
||
const legacyRetentionUntil = nowMs + legacyRetentionMs;
|
||
const migrationCompletedAt = normalizeTimestamp(await this.getMeta("migrationCompletedAt", 0), 0);
|
||
if (migrationCompletedAt > 0) {
|
||
const counts = await this._readCounts();
|
||
return {
|
||
migrated: false,
|
||
skipped: true,
|
||
reason: "migration-already-completed",
|
||
revision: await this.getRevision(),
|
||
imported: counts,
|
||
migrationCompletedAt,
|
||
migrationSource,
|
||
legacyRetentionUntil: normalizeTimestamp(await this.getMeta("legacyRetentionUntil", 0), 0),
|
||
};
|
||
}
|
||
const emptyStatus = await this.isEmpty();
|
||
if (!emptyStatus?.empty) {
|
||
return {
|
||
migrated: false,
|
||
skipped: true,
|
||
reason: "authority-store-not-empty",
|
||
revision: await this.getRevision(),
|
||
imported: {
|
||
nodes: emptyStatus.nodes,
|
||
edges: emptyStatus.edges,
|
||
tombstones: emptyStatus.tombstones,
|
||
},
|
||
isEmptyCheck: {
|
||
empty: false,
|
||
nodes: emptyStatus.nodes,
|
||
edges: emptyStatus.edges,
|
||
tombstones: emptyStatus.tombstones,
|
||
},
|
||
migrationCompletedAt: 0,
|
||
migrationSource,
|
||
legacyRetentionUntil,
|
||
};
|
||
}
|
||
const runtimeLegacyGraph = normalizeGraphRuntimeState(
|
||
deserializeGraph(toPlainData(legacyGraph, createEmptyGraph())),
|
||
this.chatId,
|
||
);
|
||
const snapshot = buildSnapshotFromGraph(runtimeLegacyGraph, {
|
||
chatId: this.chatId,
|
||
nowMs,
|
||
revision: normalizeRevision(options.revision ?? runtimeLegacyGraph?.__stBmePersistence?.revision),
|
||
meta: {
|
||
migrationCompletedAt: nowMs,
|
||
migrationSource,
|
||
legacyRetentionUntil,
|
||
storagePrimary: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
},
|
||
});
|
||
const importResult = await this.importSnapshot(snapshot, {
|
||
mode: "replace",
|
||
preserveRevision: true,
|
||
revision: normalizeRevision(options.revision ?? snapshot.meta?.revision),
|
||
markSyncDirty: true,
|
||
});
|
||
return {
|
||
migrated: true,
|
||
skipped: false,
|
||
reason: "migrated",
|
||
revision: importResult.revision,
|
||
imported: toPlainData(importResult.imported, importResult.imported),
|
||
migrationCompletedAt: nowMs,
|
||
migrationSource,
|
||
legacyRetentionUntil,
|
||
};
|
||
}
|
||
|
||
async exportSnapshot(options = {}) {
|
||
await this.open();
|
||
const includeTombstones = options && typeof options === "object" ? options.includeTombstones !== false : options !== false;
|
||
const [metaRows, nodes, edges, tombstones] = await Promise.all([
|
||
this._query(`SELECT meta_key AS key, value_json AS valueJson FROM ${AUTHORITY_TABLES.meta} WHERE chat_id = :chatId`, {
|
||
chatId: this.chatId,
|
||
}),
|
||
this.listNodes(),
|
||
this.listEdges(),
|
||
includeTombstones ? this.listTombstones() : Promise.resolve([]),
|
||
]);
|
||
const metaMap = toMetaMap(normalizeSqlRows(metaRows));
|
||
const meta = {
|
||
...createDefaultMetaValues(this.chatId),
|
||
...metaMap,
|
||
schemaVersion: BME_DB_SCHEMA_VERSION,
|
||
chatId: this.chatId,
|
||
revision: normalizeRevision(metaMap?.revision),
|
||
nodeCount: nodes.length,
|
||
edgeCount: edges.length,
|
||
tombstoneCount: includeTombstones
|
||
? tombstones.length
|
||
: normalizeNonNegativeInteger(metaMap?.tombstoneCount, 0),
|
||
storagePrimary: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
};
|
||
const snapshot = {
|
||
meta,
|
||
nodes,
|
||
edges,
|
||
tombstones: includeTombstones ? tombstones : [],
|
||
state: normalizeStateSnapshot({ meta }),
|
||
};
|
||
if (!includeTombstones) snapshot.__stBmeTombstonesOmitted = true;
|
||
return snapshot;
|
||
}
|
||
|
||
async exportSnapshotProbe() {
|
||
const snapshot = await this.exportSnapshot({ includeTombstones: false });
|
||
return {
|
||
...snapshot,
|
||
nodes: [],
|
||
edges: [],
|
||
tombstones: [],
|
||
__stBmeProbeOnly: true,
|
||
__stBmeTombstonesOmitted: true,
|
||
};
|
||
}
|
||
|
||
async importSnapshot(snapshot, options = {}) {
|
||
await this.open();
|
||
const normalizedSnapshot = sanitizeSnapshot(snapshot);
|
||
const mode = normalizeMode(options.mode);
|
||
const shouldMarkSyncDirty = options.markSyncDirty !== false;
|
||
const nowMs = Date.now();
|
||
const currentRevision = await this.getRevision();
|
||
const nodes = normalizeNodeRecords(normalizedSnapshot.nodes, nowMs);
|
||
const edges = normalizeEdgeRecords(normalizedSnapshot.edges, nowMs);
|
||
const tombstones = normalizeTombstoneRecords(normalizedSnapshot.tombstones, nowMs);
|
||
const state = normalizeStateSnapshot(normalizedSnapshot);
|
||
const metaPatch = {
|
||
...(mode === "replace" ? createDefaultMetaValues(this.chatId, nowMs) : {}),
|
||
...normalizedSnapshot.meta,
|
||
...state,
|
||
chatId: this.chatId,
|
||
schemaVersion: BME_DB_SCHEMA_VERSION,
|
||
storagePrimary: AUTHORITY_GRAPH_STORE_KIND,
|
||
storageMode: AUTHORITY_GRAPH_STORE_MODE,
|
||
};
|
||
delete metaPatch.revision;
|
||
|
||
const statements = [];
|
||
if (mode === "replace") {
|
||
statements.push(this._deleteChatRowsStatement(AUTHORITY_TABLES.nodes));
|
||
statements.push(this._deleteChatRowsStatement(AUTHORITY_TABLES.edges));
|
||
statements.push(this._deleteChatRowsStatement(AUTHORITY_TABLES.tombstones));
|
||
statements.push(this._deleteChatRowsStatement(AUTHORITY_TABLES.meta));
|
||
}
|
||
for (const node of nodes) statements.push(this._upsertNodeStatement(node));
|
||
for (const edge of edges) statements.push(this._upsertEdgeStatement(edge));
|
||
for (const tombstone of tombstones) statements.push(this._upsertTombstoneStatement(tombstone));
|
||
for (const [key, value] of Object.entries(metaPatch)) {
|
||
if (!normalizeRecordId(key)) continue;
|
||
statements.push(this._upsertMetaStatement(key, value, nowMs));
|
||
}
|
||
|
||
const incomingRevision = normalizeRevision(normalizedSnapshot.meta?.revision);
|
||
const explicitRevision = normalizeRevision(options.revision);
|
||
const requestedRevision = Number.isFinite(Number(options.revision))
|
||
? explicitRevision
|
||
: options.preserveRevision
|
||
? incomingRevision
|
||
: currentRevision + 1;
|
||
const nextRevision = Math.max(currentRevision + 1, requestedRevision);
|
||
statements.push(this._upsertMetaStatement("revision", nextRevision, nowMs));
|
||
statements.push(this._upsertMetaStatement("lastModified", nowMs, nowMs));
|
||
statements.push(this._upsertMetaStatement("lastMutationReason", "importSnapshot", nowMs));
|
||
statements.push(this._upsertMetaStatement("syncDirty", shouldMarkSyncDirty, nowMs));
|
||
statements.push(this._upsertMetaStatement("syncDirtyReason", "importSnapshot", nowMs));
|
||
statements.push(this._upsertMetaStatement("nodeCount", nodes.length, nowMs));
|
||
statements.push(this._upsertMetaStatement("edgeCount", edges.length, nowMs));
|
||
statements.push(this._upsertMetaStatement("tombstoneCount", tombstones.length, nowMs));
|
||
await this._executeStatements(statements);
|
||
|
||
return {
|
||
mode,
|
||
revision: nextRevision,
|
||
imported: {
|
||
nodes: nodes.length,
|
||
edges: edges.length,
|
||
tombstones: tombstones.length,
|
||
},
|
||
};
|
||
}
|
||
|
||
async clearAll() {
|
||
await this.open();
|
||
const nowMs = Date.now();
|
||
const nextRevision = (await this.getRevision()) + 1;
|
||
await this._executeStatements([
|
||
this._deleteChatRowsStatement(AUTHORITY_TABLES.nodes),
|
||
this._deleteChatRowsStatement(AUTHORITY_TABLES.edges),
|
||
this._deleteChatRowsStatement(AUTHORITY_TABLES.tombstones),
|
||
this._upsertMetaStatement("revision", nextRevision, nowMs),
|
||
this._upsertMetaStatement("chatId", this.chatId, nowMs),
|
||
this._upsertMetaStatement("schemaVersion", BME_DB_SCHEMA_VERSION, nowMs),
|
||
this._upsertMetaStatement("storagePrimary", AUTHORITY_GRAPH_STORE_KIND, nowMs),
|
||
this._upsertMetaStatement("storageMode", AUTHORITY_GRAPH_STORE_MODE, nowMs),
|
||
this._upsertMetaStatement("nodeCount", 0, nowMs),
|
||
this._upsertMetaStatement("edgeCount", 0, nowMs),
|
||
this._upsertMetaStatement("tombstoneCount", 0, nowMs),
|
||
this._upsertMetaStatement("lastProcessedFloor", META_DEFAULT_LAST_PROCESSED_FLOOR, nowMs),
|
||
this._upsertMetaStatement("extractionCount", META_DEFAULT_EXTRACTION_COUNT, nowMs),
|
||
this._upsertMetaStatement("lastModified", nowMs, nowMs),
|
||
this._upsertMetaStatement("lastMutationReason", "clearAll", nowMs),
|
||
this._upsertMetaStatement("syncDirty", true, nowMs),
|
||
this._upsertMetaStatement("syncDirtyReason", "clearAll", nowMs),
|
||
]);
|
||
return {
|
||
cleared: true,
|
||
revision: nextRevision,
|
||
};
|
||
}
|
||
|
||
async pruneExpiredTombstones(nowMs = Date.now()) {
|
||
await this.open();
|
||
const normalizedNow = normalizeTimestamp(nowMs, Date.now());
|
||
const cutoffMs = normalizedNow - BME_TOMBSTONE_RETENTION_MS;
|
||
const rows = await this._query(
|
||
`SELECT record_id AS id FROM ${AUTHORITY_TABLES.tombstones} WHERE chat_id = :chatId AND deleted_at < :cutoffMs`,
|
||
{ chatId: this.chatId, cutoffMs },
|
||
);
|
||
const ids = normalizeSqlRows(rows)
|
||
.map((row) => normalizeRecordId(readRowValue(row, ["id", "record_id", "recordId"])))
|
||
.filter(Boolean);
|
||
if (!ids.length) {
|
||
return {
|
||
pruned: 0,
|
||
revision: await this.getRevision(),
|
||
};
|
||
}
|
||
const nextRevision = (await this.getRevision()) + 1;
|
||
await this._executeStatements([
|
||
...ids.map((id) => this._deleteByIdStatement(AUTHORITY_TABLES.tombstones, id)),
|
||
this._upsertMetaStatement("revision", nextRevision, normalizedNow),
|
||
this._upsertMetaStatement("lastModified", normalizedNow, normalizedNow),
|
||
this._upsertMetaStatement("lastMutationReason", "pruneExpiredTombstones", normalizedNow),
|
||
this._upsertMetaStatement("syncDirty", true, normalizedNow),
|
||
this._upsertMetaStatement("syncDirtyReason", "pruneExpiredTombstones", normalizedNow),
|
||
]);
|
||
const counts = await this._readCounts();
|
||
await this.patchMeta({ tombstoneCount: counts.tombstones });
|
||
return {
|
||
pruned: ids.length,
|
||
revision: nextRevision,
|
||
};
|
||
}
|
||
|
||
async _ensureSchema() {
|
||
await this._executeStatements([
|
||
{
|
||
sql: `CREATE TABLE IF NOT EXISTS ${AUTHORITY_TABLES.meta} (chat_id TEXT NOT NULL, meta_key TEXT NOT NULL, value_json TEXT, updated_at INTEGER, PRIMARY KEY(chat_id, meta_key))`,
|
||
params: {},
|
||
},
|
||
{
|
||
sql: `CREATE TABLE IF NOT EXISTS ${AUTHORITY_TABLES.nodes} (chat_id TEXT NOT NULL, record_id TEXT NOT NULL, payload_json TEXT NOT NULL, node_type TEXT, source_floor INTEGER, archived INTEGER, updated_at INTEGER, deleted_at INTEGER, PRIMARY KEY(chat_id, record_id))`,
|
||
params: {},
|
||
},
|
||
{
|
||
sql: `CREATE TABLE IF NOT EXISTS ${AUTHORITY_TABLES.edges} (chat_id TEXT NOT NULL, record_id TEXT NOT NULL, payload_json TEXT NOT NULL, from_id TEXT, to_id TEXT, relation TEXT, source_floor INTEGER, updated_at INTEGER, deleted_at INTEGER, PRIMARY KEY(chat_id, record_id))`,
|
||
params: {},
|
||
},
|
||
{
|
||
sql: `CREATE TABLE IF NOT EXISTS ${AUTHORITY_TABLES.tombstones} (chat_id TEXT NOT NULL, record_id TEXT NOT NULL, payload_json TEXT NOT NULL, tombstone_kind TEXT, target_id TEXT, deleted_at INTEGER, source_device_id TEXT, PRIMARY KEY(chat_id, record_id))`,
|
||
params: {},
|
||
},
|
||
]);
|
||
}
|
||
|
||
async _ensureMetaDefaults() {
|
||
const nowMs = Date.now();
|
||
const defaultMeta = createDefaultMetaValues(this.chatId, nowMs);
|
||
const metaRows = await this._query(
|
||
`SELECT meta_key AS key, value_json AS valueJson FROM ${AUTHORITY_TABLES.meta} WHERE chat_id = :chatId`,
|
||
{ chatId: this.chatId },
|
||
);
|
||
const existingMeta = toMetaMap(normalizeSqlRows(metaRows));
|
||
const statements = [];
|
||
for (const [key, value] of Object.entries(defaultMeta)) {
|
||
if (Object.prototype.hasOwnProperty.call(existingMeta, key)) continue;
|
||
statements.push(this._upsertMetaStatement(key, value, nowMs));
|
||
}
|
||
await this._executeStatements(statements);
|
||
}
|
||
|
||
async _readCounts() {
|
||
const [nodes, edges, tombstones] = await Promise.all([
|
||
this._query(`SELECT COUNT(*) AS count FROM ${AUTHORITY_TABLES.nodes} WHERE chat_id = :chatId`, { chatId: this.chatId }),
|
||
this._query(`SELECT COUNT(*) AS count FROM ${AUTHORITY_TABLES.edges} WHERE chat_id = :chatId`, { chatId: this.chatId }),
|
||
this._query(`SELECT COUNT(*) AS count FROM ${AUTHORITY_TABLES.tombstones} WHERE chat_id = :chatId`, { chatId: this.chatId }),
|
||
]);
|
||
return {
|
||
nodes: normalizeCountResult(nodes),
|
||
edges: normalizeCountResult(edges),
|
||
tombstones: normalizeCountResult(tombstones),
|
||
};
|
||
}
|
||
|
||
async _query(sql, params = {}) {
|
||
if (typeof this.sqlClient?.query === "function") {
|
||
return await this.sqlClient.query(sql, params);
|
||
}
|
||
if (typeof this.sqlClient === "function") {
|
||
return await this.sqlClient({ action: "query", sql, params });
|
||
}
|
||
throw new Error("Authority SQL query unavailable");
|
||
}
|
||
|
||
async _execute(sql, params = {}) {
|
||
if (typeof this.sqlClient?.execute === "function") {
|
||
return await this.sqlClient.execute(sql, params);
|
||
}
|
||
if (typeof this.sqlClient === "function") {
|
||
return await this.sqlClient({ action: "execute", sql, params });
|
||
}
|
||
throw new Error("Authority SQL execute unavailable");
|
||
}
|
||
|
||
async _executeStatements(statements = []) {
|
||
const normalizedStatements = toArray(statements).filter((statement) => statement?.sql);
|
||
if (!normalizedStatements.length) return null;
|
||
|
||
if (typeof this.sqlClient?.transaction === "function") {
|
||
let lastResult = null;
|
||
const chunks = this._buildTransactionChunks(normalizedStatements);
|
||
for (const batch of chunks) {
|
||
lastResult = await this._executeTransactionChunk(batch);
|
||
}
|
||
return lastResult;
|
||
}
|
||
|
||
const upsertStatements = [];
|
||
const deleteStatements = [];
|
||
for (const stmt of normalizedStatements) {
|
||
if (stmt.sql.trim().toUpperCase().startsWith("DELETE")) {
|
||
deleteStatements.push(stmt);
|
||
} else {
|
||
upsertStatements.push(stmt);
|
||
}
|
||
}
|
||
let result = null;
|
||
for (const stmt of upsertStatements) {
|
||
result = await this._execute(stmt.sql, stmt.params || {});
|
||
}
|
||
for (const stmt of deleteStatements) {
|
||
result = await this._execute(stmt.sql, stmt.params || {});
|
||
}
|
||
if (deleteStatements.length > 0 && upsertStatements.length > 0) {
|
||
console.warn("[ST-BME] _executeStatements fallback 路径执行:先 upsert 后 delete,无事务保护", {
|
||
chatId: this.chatId,
|
||
upsertCount: upsertStatements.length,
|
||
deleteCount: deleteStatements.length,
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
_buildTransactionChunks(statements = []) {
|
||
const budget = normalizeSqlTransactionByteBudget(
|
||
this.options?.sqlTransactionMaxBytes ?? this.options?.authoritySqlTransactionMaxBytes,
|
||
);
|
||
const maxBytes = Math.min(
|
||
AUTHORITY_SQL_TRANSACTION_MAX_REQUEST_BYTES - 64 * 1024,
|
||
Math.max(1024, budget),
|
||
);
|
||
return buildAuthoritySqlTransactionChunks(statements, {
|
||
database: this.sqlClient?.database || DEFAULT_AUTHORITY_SQL_DATABASE,
|
||
maxStatements: this.options?.sqlTransactionBatchSize ?? AUTHORITY_SQL_TRANSACTION_BATCH_SIZE,
|
||
maxBytes,
|
||
});
|
||
}
|
||
|
||
async _executeTransactionChunk(batch = []) {
|
||
const normalizedBatch = toArray(batch).filter((statement) => statement?.sql);
|
||
if (!normalizedBatch.length) return null;
|
||
try {
|
||
return await this.sqlClient.transaction(normalizedBatch);
|
||
} catch (error) {
|
||
if (!isAuthorityPayloadTooLargeError(error)) {
|
||
throw error;
|
||
}
|
||
if (normalizedBatch.length <= 1) {
|
||
throw createTerminalAuthoritySqlPayloadError(normalizedBatch[0], error);
|
||
}
|
||
const mid = Math.max(1, Math.floor(normalizedBatch.length / 2));
|
||
const left = normalizedBatch.slice(0, mid);
|
||
const right = normalizedBatch.slice(mid);
|
||
const leftResult = await this._executeTransactionChunk(left);
|
||
const rightResult = await this._executeTransactionChunk(right);
|
||
return rightResult ?? leftResult;
|
||
}
|
||
}
|
||
|
||
_upsertMetaStatement(key, value, nowMs = Date.now()) {
|
||
return {
|
||
sql: `INSERT INTO ${AUTHORITY_TABLES.meta} (chat_id, meta_key, value_json, updated_at) VALUES (:chatId, :key, :valueJson, :updatedAt) ON CONFLICT(chat_id, meta_key) DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at`,
|
||
params: {
|
||
chatId: this.chatId,
|
||
key: normalizeRecordId(key),
|
||
valueJson: stringifyJsonValue(value),
|
||
updatedAt: normalizeTimestamp(nowMs),
|
||
},
|
||
};
|
||
}
|
||
|
||
_upsertNodeStatement(node) {
|
||
return {
|
||
sql: `INSERT INTO ${AUTHORITY_TABLES.nodes} (chat_id, record_id, payload_json, node_type, source_floor, archived, updated_at, deleted_at) VALUES (:chatId, :id, :payloadJson, :type, :sourceFloor, :archived, :updatedAt, :deletedAt) ON CONFLICT(chat_id, record_id) DO UPDATE SET payload_json = excluded.payload_json, node_type = excluded.node_type, source_floor = excluded.source_floor, archived = excluded.archived, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at`,
|
||
params: {
|
||
chatId: this.chatId,
|
||
id: node.id,
|
||
payloadJson: stringifyJsonValue(node),
|
||
type: String(node.type || ""),
|
||
sourceFloor: Number.isFinite(Number(node.sourceFloor)) ? Number(node.sourceFloor) : null,
|
||
archived: node.archived === true ? 1 : 0,
|
||
updatedAt: normalizeTimestamp(node.updatedAt),
|
||
deletedAt: Number.isFinite(Number(node.deletedAt)) ? Number(node.deletedAt) : null,
|
||
},
|
||
};
|
||
}
|
||
|
||
_upsertEdgeStatement(edge) {
|
||
return {
|
||
sql: `INSERT INTO ${AUTHORITY_TABLES.edges} (chat_id, record_id, payload_json, from_id, to_id, relation, source_floor, updated_at, deleted_at) VALUES (:chatId, :id, :payloadJson, :fromId, :toId, :relation, :sourceFloor, :updatedAt, :deletedAt) ON CONFLICT(chat_id, record_id) DO UPDATE SET payload_json = excluded.payload_json, from_id = excluded.from_id, to_id = excluded.to_id, relation = excluded.relation, source_floor = excluded.source_floor, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at`,
|
||
params: {
|
||
chatId: this.chatId,
|
||
id: edge.id,
|
||
payloadJson: stringifyJsonValue(edge),
|
||
fromId: normalizeRecordId(edge.fromId),
|
||
toId: normalizeRecordId(edge.toId),
|
||
relation: String(edge.relation || ""),
|
||
sourceFloor: Number.isFinite(Number(edge.sourceFloor)) ? Number(edge.sourceFloor) : null,
|
||
updatedAt: normalizeTimestamp(edge.updatedAt),
|
||
deletedAt: Number.isFinite(Number(edge.deletedAt)) ? Number(edge.deletedAt) : null,
|
||
},
|
||
};
|
||
}
|
||
|
||
_upsertTombstoneStatement(tombstone) {
|
||
return {
|
||
sql: `INSERT INTO ${AUTHORITY_TABLES.tombstones} (chat_id, record_id, payload_json, tombstone_kind, target_id, deleted_at, source_device_id) VALUES (:chatId, :id, :payloadJson, :kind, :targetId, :deletedAt, :sourceDeviceId) ON CONFLICT(chat_id, record_id) DO UPDATE SET payload_json = excluded.payload_json, tombstone_kind = excluded.tombstone_kind, target_id = excluded.target_id, deleted_at = excluded.deleted_at, source_device_id = excluded.source_device_id`,
|
||
params: {
|
||
chatId: this.chatId,
|
||
id: tombstone.id,
|
||
payloadJson: stringifyJsonValue(tombstone),
|
||
kind: normalizeRecordId(tombstone.kind),
|
||
targetId: normalizeRecordId(tombstone.targetId),
|
||
deletedAt: normalizeTimestamp(tombstone.deletedAt),
|
||
sourceDeviceId: normalizeRecordId(tombstone.sourceDeviceId),
|
||
},
|
||
};
|
||
}
|
||
|
||
_deleteByIdStatement(tableName, id) {
|
||
return {
|
||
sql: `DELETE FROM ${tableName} WHERE chat_id = :chatId AND record_id = :id`,
|
||
params: {
|
||
chatId: this.chatId,
|
||
id: normalizeRecordId(id),
|
||
},
|
||
};
|
||
}
|
||
|
||
_deleteChatRowsStatement(tableName) {
|
||
return {
|
||
sql: `DELETE FROM ${tableName} WHERE chat_id = :chatId`,
|
||
params: {
|
||
chatId: this.chatId,
|
||
},
|
||
};
|
||
}
|
||
}
|