Files
openclaw/extensions/matrix/doctor-contract-api.test.ts
Peter Steinberger 8cb53c7b55 perf(doctor): keep bundled doctor contract closures dependency-light (#120698)
* perf(doctor): keep bundled doctor contract closures dependency-light

Doctor contract enumeration cold-loads each plugin's doctor-contract-api
closure via jiti, so a static value import of openclaw/plugin-sdk/runtime-doctor
pulled the state-db/kysely graph (~4.3s per closure) into
listPluginDoctorLegacyConfigRules / listPluginDoctorStateMigrationEntries.

- migrate all light doctor-contract closures (66 files) to the
  dependency-light openclaw/plugin-sdk/runtime-doctor-migrations subpath
- voice-call: load detect/repairOpenClawStateDatabaseSchema* lazily inside
  the migration bodies; keep only a type-only static runtime-doctor import
- matrix: split pure credential record shapes/normalizers into
  credentials-state.ts so the doctor closure no longer imports the sync
  plugin-state store through credentials-read
- guard: doctor-contract-closure-guard.test.ts now forbids static value
  imports of runtime-doctor in closures alongside agent-runtime

* fix(matrix): keep credential revocation record type module-local

Knip production scan flags the export as consumer-less; the type is only
referenced by the exported union and revocation guard signature.
2026-08-08 17:51:31 -07:00

1037 lines
40 KiB
TypeScript

// Matrix tests cover doctor contract state migrations.
import "fake-indexeddb/auto";
import { createHash } from "node:crypto";
import fs from "node:fs";
import fsPromises from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createPersistentDedupeImportEntry,
type PersistentDedupeEntry,
} from "openclaw/plugin-sdk/persistent-dedupe";
import type {
OpenKeyedStoreOptions,
PluginStateKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
getPluginStateCapacityForTests,
importPluginStateEntriesForDoctorForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type { PluginDoctorStateMigrationContext } from "openclaw/plugin-sdk/runtime-doctor-migrations";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
import { SqliteBackedMatrixSyncStore } from "./src/matrix/client/file-sync-store.js";
import { openMatrixStorageMetaStoreOptions } from "./src/matrix/client/storage.js";
import {
MATRIX_CREDENTIALS_MAX_ENTRIES,
MATRIX_CREDENTIALS_NAMESPACE,
matrixCredentialsStoreKey,
type MatrixCredentialStateRecord,
type MatrixStoredCredentialRecord,
} from "./src/matrix/credentials-state.js";
import {
MATRIX_IDB_SNAPSHOT_FILENAME,
MATRIX_RECOVERY_KEY_FILENAME,
openMatrixIdbSnapshotStoreOptions,
readMatrixIdbSnapshotJson,
readMatrixRecoveryKeyStateForPath,
scoreMatrixCryptoStateInStore,
writeMatrixIdbSnapshotJson,
type MatrixIdbSnapshotRecord,
} from "./src/matrix/crypto-state-store.js";
import { importNewestInboundDedupeMarkers } from "./src/matrix/monitor/inbound-dedupe-migration.js";
import {
createMatrixInboundEventDeduper,
MATRIX_INBOUND_DEDUPE_TTL_MS,
resolveMatrixInboundDedupeStateNamespace,
} from "./src/matrix/monitor/inbound-dedupe.js";
import { restoreIdbFromDisk } from "./src/matrix/sdk/idb-persistence.js";
import {
clearAllIndexedDbState,
readDatabaseRecords,
} from "./src/matrix/sdk/idb-persistence.test-helpers.js";
import { installMatrixTestRuntime } from "./src/test-runtime.js";
import { useAutoCleanupTempDirTracker } from "./test-support.js";
const DOCTOR_IDB_DATABASE_PREFIX = "openclaw-matrix-doctor-test";
function createContext(env?: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
getPluginStateCapacity() {
return getPluginStateCapacityForTests("matrix", env);
},
importPluginStateEntries(options, entries) {
importPluginStateEntriesForDoctorForTests("matrix", options, entries);
},
openPluginStateKeyedStore: <T>(options: OpenKeyedStoreOptions): PluginStateKeyedStore<T> =>
createPluginStateKeyedStoreForTests<T>("matrix", options),
};
}
function createMigrationParams(stateDir: string) {
const env = { OPENCLAW_STATE_DIR: stateDir };
return {
config: {} as OpenClawConfig,
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createContext(env),
};
}
function migrationById(id: string) {
const migration = stateMigrations.find((entry) => entry.id === id);
if (!migration) {
throw new Error(`missing migration ${id}`);
}
return migration;
}
describe("matrix doctor contract state migrations", () => {
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
beforeEach(() => {
resetPluginStateStoreForTests();
installMatrixTestRuntime();
});
afterEach(async () => {
await clearAllIndexedDbState({ databasePrefix: DOCTOR_IDB_DATABASE_PREFIX });
vi.restoreAllMocks();
resetPluginStateStoreForTests();
});
it("imports account credentials into SQLite before archiving the JSON", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const credentialsDir = path.join(stateDir, "credentials", "matrix");
const filePath = path.join(credentialsDir, "credentials-ops.json");
const credentials = {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token",
deviceId: "DEVICE123",
createdAt: "2026-07-01T12:00:00.000Z",
lastUsedAt: "2026-07-02T12:00:00.000Z",
};
fs.mkdirSync(credentialsDir, { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(credentials));
const migration = migrationById("matrix-credentials-json-to-plugin-state");
const params = createMigrationParams(stateDir);
await expect(migration.detectLegacyState(params)).resolves.toEqual({
preview: ["Matrix credential JSON can migrate to SQLite (1 file)"],
});
const result = await migration.migrateLegacyState(params);
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
"Migrated Matrix credentials for account ops to SQLite",
expect.stringContaining("Archived Matrix credentials legacy source"),
]);
const store = params.context.openPluginStateKeyedStore<MatrixStoredCredentialRecord>({
namespace: MATRIX_CREDENTIALS_NAMESPACE,
maxEntries: MATRIX_CREDENTIALS_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
await expect(store.lookup(matrixCredentialsStoreKey("ops"))).resolves.toEqual({
accountId: "ops",
...credentials,
});
expect(fs.existsSync(`${filePath}.migrated`)).toBe(true);
});
it("archives legacy credentials without restoring an explicitly cleared account", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const credentialsDir = path.join(stateDir, "credentials", "matrix");
const filePath = path.join(credentialsDir, "credentials-ops.json");
fs.mkdirSync(credentialsDir, { recursive: true });
fs.writeFileSync(
filePath,
JSON.stringify({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "legacy-token",
createdAt: "2026-07-01T12:00:00.000Z",
}),
);
const params = createMigrationParams(stateDir);
const credentialStore = params.context.openPluginStateKeyedStore<MatrixCredentialStateRecord>({
namespace: MATRIX_CREDENTIALS_NAMESPACE,
maxEntries: MATRIX_CREDENTIALS_MAX_ENTRIES,
overflowPolicy: "reject-new",
});
await credentialStore.register(matrixCredentialsStoreKey("ops"), {
accountId: "ops",
kind: "revoked",
revokedAt: "2026-07-02T12:00:00.000Z",
});
const result = await migrationById(
"matrix-credentials-json-to-plugin-state",
).migrateLegacyState(params);
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
"Archived revoked Matrix credential legacy source for account ops",
expect.stringContaining("Archived Matrix credentials legacy source"),
]);
expect(fs.existsSync(`${filePath}.migrated`)).toBe(true);
});
it("migrates legacy sync cache JSON to SQLite plugin state", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const storageRootDir = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot",
"token-hash",
);
fs.mkdirSync(storageRootDir, { recursive: true });
fs.writeFileSync(
path.join(storageRootDir, "bot-storage.json"),
JSON.stringify({
version: 1,
savedSync: {
nextBatch: "legacy-token",
accountData: [],
roomsData: {
join: {},
invite: {},
leave: {},
knock: {},
},
},
cleanShutdown: true,
}),
);
const migration = migrationById("matrix-sync-cache-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: [`Matrix sync cache JSON can migrate to SQLite: ${storageRootDir}`],
});
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
`Migrated Matrix sync cache JSON to SQLite for ${storageRootDir}`,
`Archived Matrix sync cache legacy source -> ${path.join(storageRootDir, "bot-storage.json")}.migrated`,
],
warnings: [],
});
const store = new SqliteBackedMatrixSyncStore(storageRootDir);
expect(store.hasSavedSync()).toBe(true);
expect(store.hasSavedSyncFromCleanShutdown()).toBe(true);
await expect(store.getSavedSyncToken()).resolves.toBe("legacy-token");
const sourcePath = path.join(storageRootDir, "bot-storage.json");
const archivePath = `${sourcePath}.migrated`;
expect(fs.existsSync(sourcePath)).toBe(false);
fs.copyFileSync(archivePath, sourcePath);
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [`Removed already-archived Matrix sync cache legacy source ${sourcePath}`],
warnings: [],
notices: [
`Kept existing Matrix sync cache in SQLite and archived the legacy source for ${storageRootDir}`,
],
});
fs.writeFileSync(
sourcePath,
JSON.stringify({
version: 1,
savedSync: {
nextBatch: "newer-legacy-token",
accountData: [],
roomsData: { join: {}, invite: {}, leave: {}, knock: {} },
},
cleanShutdown: true,
}),
);
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [`Archived Matrix sync cache legacy source -> ${sourcePath}.migrated.2`],
warnings: [],
notices: [
`Kept existing Matrix sync cache in SQLite and archived the legacy source for ${storageRootDir}`,
],
});
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [],
warnings: [],
});
fs.writeFileSync(sourcePath, `${fs.readFileSync(`${sourcePath}.migrated.2`, "utf8")} `, "utf8");
fs.mkdirSync(`${sourcePath}.migrated.3`);
const failedArchive = await migration.migrateLegacyState(createMigrationParams(stateDir));
expect(failedArchive.changes).toEqual([]);
expect(failedArchive.warnings).toEqual([
expect.stringContaining("Failed archiving Matrix sync cache legacy source"),
]);
expect(failedArchive.notices).toBeUndefined();
});
it("migrates Matrix storage metadata JSON to SQLite plugin state", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const storageRootDir = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot",
"token-hash",
);
fs.mkdirSync(storageRootDir, { recursive: true });
fs.writeFileSync(
path.join(storageRootDir, "storage-meta.json"),
JSON.stringify({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accountId: "default",
accessTokenHash: "token-hash",
deviceId: "DEVICE",
currentTokenStateClaimed: true,
}),
);
const migration = migrationById("matrix-storage-meta-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: [`Matrix storage metadata JSON can migrate to SQLite: ${storageRootDir}`],
});
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
`Migrated Matrix storage metadata JSON to SQLite for ${storageRootDir}`,
`Archived Matrix storage metadata legacy source -> ${path.join(storageRootDir, "storage-meta.json")}.migrated`,
],
warnings: [],
});
const store = createPluginStateKeyedStoreForTests<Record<string, unknown>>(
"matrix",
openMatrixStorageMetaStoreOptions(storageRootDir),
);
await expect(store.lookup("current")).resolves.toMatchObject({
deviceId: "DEVICE",
currentTokenStateClaimed: true,
});
expect(fs.existsSync(path.join(storageRootDir, "storage-meta.json"))).toBe(false);
});
it("does not archive the legacy flat sync cache into an unread SQLite root", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const flatRoot = path.join(stateDir, "matrix");
fs.mkdirSync(flatRoot, { recursive: true });
fs.writeFileSync(
path.join(flatRoot, "bot-storage.json"),
JSON.stringify({
next_batch: "flat-token",
rooms: { join: {} },
account_data: { events: [] },
}),
);
const migration = migrationById("matrix-sync-cache-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toBeNull();
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [],
warnings: [],
});
expect(fs.existsSync(path.join(flatRoot, "bot-storage.json"))).toBe(true);
});
it("migrates Matrix recovery-key JSON to SQLite plugin state", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const storageRootDir = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot",
"token-hash",
);
fs.mkdirSync(storageRootDir, { recursive: true });
fs.writeFileSync(
path.join(storageRootDir, "recovery-key.json"),
JSON.stringify({
version: 1,
createdAt: "2026-03-12T00:00:00.000Z",
keyId: "SSSS",
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
}),
);
const migration = migrationById("matrix-recovery-key-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: [`Matrix recovery-key JSON can migrate to SQLite: ${storageRootDir}`],
});
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
`Migrated Matrix recovery-key JSON to SQLite for ${storageRootDir}`,
`Archived Matrix recovery key legacy source -> ${path.join(storageRootDir, "recovery-key.json")}.migrated`,
],
warnings: [],
});
expect(
readMatrixRecoveryKeyStateForPath(path.join(storageRootDir, MATRIX_RECOVERY_KEY_FILENAME))
?.keyId,
).toBe("SSSS");
expect(fs.existsSync(path.join(storageRootDir, "recovery-key.json"))).toBe(false);
});
it("migrates legacy Matrix crypto state and restores the snapshot from SQLite", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const storageRootDir = path.join(stateDir, "matrix");
fs.mkdirSync(storageRootDir, { recursive: true });
const snapshotPath = path.join(storageRootDir, MATRIX_IDB_SNAPSHOT_FILENAME);
const snapshotDatabaseName = `${DOCTOR_IDB_DATABASE_PREFIX}::matrix-sdk-crypto`;
const snapshot = [
{
name: snapshotDatabaseName,
version: 1,
stores: [
{
name: "sessions",
keyPath: null,
autoIncrement: false,
indexes: [],
records: [{ key: "room-1", value: { session: "abc123" } }],
},
],
},
];
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot));
fs.writeFileSync(
path.join(storageRootDir, "legacy-crypto-migration.json"),
JSON.stringify({
version: 1,
source: "matrix-bot-sdk-rust",
accountId: "default",
deviceId: "DEVICE",
roomKeyCounts: { total: 2, backedUp: 2 },
backupVersion: "1",
decryptionKeyImported: true,
restoreStatus: "pending",
detectedAt: "2026-03-12T00:00:00.000Z",
lastError: null,
}),
);
const migration = migrationById("matrix-legacy-crypto-migration-json-to-plugin-state");
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: [
`Matrix legacy crypto migration JSON can migrate to SQLite: ${storageRootDir}`,
`Matrix IndexedDB snapshot JSON can migrate to SQLite: ${storageRootDir}`,
],
});
const result = await migration.migrateLegacyState(createMigrationParams(stateDir));
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
`Migrated Matrix legacy crypto migration JSON to SQLite for ${storageRootDir}`,
`Archived Matrix legacy crypto migration legacy source -> ${path.join(storageRootDir, "legacy-crypto-migration.json")}.migrated`,
`Migrated Matrix IndexedDB snapshot JSON to SQLite for ${storageRootDir}`,
expect.stringMatching(/^Archived Matrix IndexedDB snapshot legacy source -> /u),
]);
const archivePath = result.changes[3]?.split(" -> ")[1];
expect(archivePath).toMatch(/crypto-idb-snapshot\.json\.migrated-\d{4}-/u);
expect(JSON.parse(fs.readFileSync(archivePath ?? "", "utf8"))).toEqual(snapshot);
expect(scoreMatrixCryptoStateInStore(storageRootDir)).toBe(5);
expect(JSON.parse(readMatrixIdbSnapshotJson(storageRootDir) ?? "null")).toEqual(snapshot);
expect(fs.existsSync(path.join(storageRootDir, "legacy-crypto-migration.json"))).toBe(false);
expect(fs.existsSync(snapshotPath)).toBe(false);
await expect(restoreIdbFromDisk(snapshotPath)).resolves.toBe(true);
await expect(
readDatabaseRecords({
name: snapshotDatabaseName,
storeName: "sessions",
}),
).resolves.toEqual([{ key: "room-1", value: { session: "abc123" } }]);
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toBeNull();
});
it("archives an invalid legacy snapshot for recovery and unblocks runtime", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const storageRootDir = path.join(stateDir, "matrix");
const snapshotPath = path.join(storageRootDir, MATRIX_IDB_SNAPSHOT_FILENAME);
fs.mkdirSync(storageRootDir, { recursive: true });
fs.writeFileSync(snapshotPath, "not-json");
const result = await migrationById(
"matrix-legacy-crypto-migration-json-to-plugin-state",
).migrateLegacyState(createMigrationParams(stateDir));
expect(result.warnings).toEqual([
`Matrix IndexedDB snapshot legacy source is invalid for ${storageRootDir}; archived without import`,
]);
expect(result.changes).toEqual([
expect.stringMatching(/^Archived Matrix IndexedDB snapshot legacy source -> /u),
]);
const archivePath = result.changes[0]?.split(" -> ")[1];
expect(fs.readFileSync(archivePath ?? "", "utf8")).toBe("not-json");
expect(fs.existsSync(snapshotPath)).toBe(false);
await expect(restoreIdbFromDisk(snapshotPath)).resolves.toBe(false);
});
it("repairs invalid snapshots, archives equivalents, and preserves conflicts", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const partialRoot = path.join(stateDir, "matrix", "accounts", "partial");
const conflictRoot = path.join(stateDir, "matrix", "accounts", "conflict");
const equivalentRoot = path.join(stateDir, "matrix", "accounts", "equivalent");
const invalidRoot = path.join(stateDir, "matrix", "accounts", "invalid");
fs.mkdirSync(partialRoot, { recursive: true });
fs.mkdirSync(conflictRoot, { recursive: true });
fs.mkdirSync(equivalentRoot, { recursive: true });
fs.mkdirSync(invalidRoot, { recursive: true });
const partialSnapshot = [{ name: "partial-source", version: 1, stores: [] }];
const conflictSnapshot = [{ name: "conflicting-source", version: 1, stores: [] }];
const equivalentSnapshot = [{ name: "equivalent", version: 1, stores: [] }];
const invalidReplacement = [{ name: "invalid-replacement", version: 1, stores: [] }];
fs.writeFileSync(
path.join(partialRoot, MATRIX_IDB_SNAPSHOT_FILENAME),
JSON.stringify(partialSnapshot),
);
fs.writeFileSync(
path.join(conflictRoot, MATRIX_IDB_SNAPSHOT_FILENAME),
JSON.stringify(conflictSnapshot),
);
fs.writeFileSync(
path.join(equivalentRoot, MATRIX_IDB_SNAPSHOT_FILENAME),
JSON.stringify(equivalentSnapshot, null, 2),
);
fs.writeFileSync(
path.join(invalidRoot, MATRIX_IDB_SNAPSHOT_FILENAME),
JSON.stringify(invalidReplacement),
);
const partialStore = createContext().openPluginStateKeyedStore<MatrixIdbSnapshotRecord>(
openMatrixIdbSnapshotStoreOptions(partialRoot),
);
await partialStore.register("current:snapshot:interrupted:0", {
kind: "snapshot-chunk",
index: 0,
data: "[",
});
const currentSnapshot = JSON.stringify([{ name: "current", version: 1, stores: [] }]);
writeMatrixIdbSnapshotJson({
storageRootDir: conflictRoot,
snapshotJson: currentSnapshot,
databaseCount: 1,
});
writeMatrixIdbSnapshotJson({
storageRootDir: equivalentRoot,
snapshotJson: JSON.stringify(equivalentSnapshot),
databaseCount: 1,
});
writeMatrixIdbSnapshotJson({
storageRootDir: invalidRoot,
snapshotJson: JSON.stringify({ malformed: true }),
databaseCount: 1,
});
const result = await migrationById(
"matrix-legacy-crypto-migration-json-to-plugin-state",
).migrateLegacyState(createMigrationParams(stateDir));
expect(result.changes).toEqual([
expect.stringContaining("Archived Matrix IndexedDB snapshot legacy source"),
expect.stringContaining("Archived Matrix IndexedDB snapshot legacy source"),
`Repaired partial or invalid Matrix IndexedDB snapshot SQLite state for ${invalidRoot}`,
expect.stringContaining("Archived Matrix IndexedDB snapshot legacy source"),
`Repaired partial or invalid Matrix IndexedDB snapshot SQLite state for ${partialRoot}`,
expect.stringContaining("Archived Matrix IndexedDB snapshot legacy source"),
]);
expect(result.warnings).toEqual([]);
expect(result.notices).toEqual([
`Kept the canonical Matrix IndexedDB snapshot in SQLite and archived a differing legacy source for ${conflictRoot}`,
]);
const conflictArchivePath = result.changes[0]?.split(" -> ")[1];
expect(JSON.parse(fs.readFileSync(conflictArchivePath ?? "", "utf8"))).toEqual(
conflictSnapshot,
);
expect(JSON.parse(readMatrixIdbSnapshotJson(partialRoot) ?? "null")).toEqual(partialSnapshot);
expect(JSON.parse(readMatrixIdbSnapshotJson(invalidRoot) ?? "null")).toEqual(
invalidReplacement,
);
expect(readMatrixIdbSnapshotJson(conflictRoot)).toBe(currentSnapshot);
expect(fs.existsSync(path.join(partialRoot, MATRIX_IDB_SNAPSHOT_FILENAME))).toBe(false);
expect(fs.existsSync(path.join(conflictRoot, MATRIX_IDB_SNAPSHOT_FILENAME))).toBe(false);
expect(fs.existsSync(path.join(equivalentRoot, MATRIX_IDB_SNAPSHOT_FILENAME))).toBe(false);
expect(fs.existsSync(path.join(invalidRoot, MATRIX_IDB_SNAPSHOT_FILENAME))).toBe(false);
});
it("detects, imports, and retires schema-v1 inbound dedupe rows without upgrading the source", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const sqliteRoot = path.join(
stateDir,
"matrix",
"accounts",
"ops",
"matrix.example.org__bot",
"token-a",
);
const jsonRoot = path.join(
stateDir,
"matrix",
"accounts",
"home",
"matrix.example.org__bot",
"token-b",
);
fs.mkdirSync(sqliteRoot, { recursive: true });
fs.mkdirSync(jsonRoot, { recursive: true });
const roomId = "!room:example.org";
const now = Date.now();
const legacyKey = (accountId: string, eventId: string) =>
`${accountId}:${createHash("sha256")
.update(accountId)
.update("\0")
.update(roomId)
.update("\0")
.update(eventId)
.digest("hex")}`;
// >=2026.6 shape in a historical schema-v1 database. It intentionally has
// none of the current schema's migration/audit tables.
const legacyDatabasePath = path.join(sqliteRoot, "state", "openclaw.sqlite");
fs.mkdirSync(path.dirname(legacyDatabasePath), { recursive: true });
const legacyDb = new DatabaseSync(legacyDatabasePath);
try {
legacyDb.exec(`
CREATE TABLE plugin_state_entries (
plugin_id TEXT NOT NULL,
namespace TEXT NOT NULL,
entry_key TEXT NOT NULL,
value_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (plugin_id, namespace, entry_key)
) STRICT;
PRAGMA user_version = 1;
`);
const insert = legacyDb.prepare(`
INSERT INTO plugin_state_entries (
plugin_id, namespace, entry_key, value_json, created_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?)
`);
insert.run(
"matrix",
"inbound-dedupe",
legacyKey("ops", "$committed"),
JSON.stringify({ roomId, eventId: "$committed", ts: now - 60_000 }),
1,
now + 60_000,
);
insert.run(
"matrix",
"inbound-dedupe",
legacyKey("ops", "$stale"),
JSON.stringify({
roomId,
eventId: "$stale",
ts: now - 31 * 24 * 60 * 60 * 1000,
}),
2,
null,
);
insert.run(
"matrix",
"inbound-dedupe",
legacyKey("ops", "$expired-corrupt"),
"not-json",
3,
now - 1,
);
insert.run(
"matrix",
"inbound-dedupe-migrations",
"ops:legacy-json-marker",
JSON.stringify({ importedAt: now }),
4,
now + 60_000,
);
insert.run("matrix", "credentials", "keep-matrix", '{"keep":true}', 5, null);
insert.run("other-plugin", "inbound-dedupe", "keep-other", '{"keep":true}', 6, null);
} finally {
legacyDb.close();
}
// <=2026.5 shape: raw inbound-dedupe.json plus storage-meta.json identity.
fs.writeFileSync(
path.join(jsonRoot, "inbound-dedupe.json"),
JSON.stringify({
version: 1,
entries: [{ key: `${roomId}|$json-committed`, ts: now - 60_000 }],
}),
);
fs.writeFileSync(
path.join(jsonRoot, "storage-meta.json"),
JSON.stringify({ accountId: "home", userId: "@home:example.org" }),
);
const migration = migrationById("matrix-inbound-dedupe-to-claimable-dedupe");
const detectParams = createMigrationParams(stateDir);
await expect(migration.detectLegacyState(detectParams)).resolves.toEqual({
preview: ["Matrix inbound dedupe legacy sources need a one-time migration scan"],
});
const detectedDb = new DatabaseSync(legacyDatabasePath, { readOnly: true });
try {
expect(detectedDb.prepare("PRAGMA user_version").get()).toEqual({ user_version: 1 });
expect(
detectedDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.all(),
).toEqual([{ name: "plugin_state_entries" }]);
} finally {
detectedDb.close();
}
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
"Migrated Matrix inbound dedupe markers to the claimable dedupe store (2 of 3 entries)",
`Retired Matrix inbound dedupe rows for ${sqliteRoot}`,
`Archived Matrix inbound dedupe legacy source -> ${path.join(jsonRoot, "inbound-dedupe.json")}.migrated`,
"Recorded Matrix inbound dedupe migration completion (1 SQLite roots, 1 JSON roots scanned)",
],
warnings: [],
});
// Pre-upgrade markers must keep deduping through the new runtime guard.
const dedupeEnv = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const opsDeduper = createMatrixInboundEventDeduper({
auth: { accountId: "ops" },
env: dedupeEnv,
});
await expect(opsDeduper.claim({ roomId, eventId: "$committed" })).resolves.toEqual({
kind: "duplicate",
});
const staleClaim = await opsDeduper.claim({ roomId, eventId: "$stale" });
expect(staleClaim.kind).toBe("claimed");
if (staleClaim.kind === "claimed") {
staleClaim.handle.release();
}
const homeDeduper = createMatrixInboundEventDeduper({
auth: { accountId: "home" },
env: dedupeEnv,
});
await expect(homeDeduper.claim({ roomId, eventId: "$json-committed" })).resolves.toEqual({
kind: "duplicate",
});
// Only the two retired Matrix namespaces are deleted. The source remains
// schema v1, with unrelated Matrix and other-plugin state untouched.
const retiredDb = new DatabaseSync(legacyDatabasePath, { readOnly: true });
try {
expect(retiredDb.prepare("PRAGMA user_version").get()).toEqual({ user_version: 1 });
expect(
retiredDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.all(),
).toEqual([{ name: "plugin_state_entries" }]);
expect(
retiredDb
.prepare(
`SELECT plugin_id, namespace, entry_key, value_json
FROM plugin_state_entries
ORDER BY plugin_id ASC, namespace ASC, entry_key ASC`,
)
.all(),
).toEqual([
{
plugin_id: "matrix",
namespace: "credentials",
entry_key: "keep-matrix",
value_json: '{"keep":true}',
},
{
plugin_id: "other-plugin",
namespace: "inbound-dedupe",
entry_key: "keep-other",
value_json: '{"keep":true}',
},
]);
} finally {
retiredDb.close();
}
expect(fs.existsSync(path.join(jsonRoot, "inbound-dedupe.json"))).toBe(false);
expect(fs.existsSync(path.join(jsonRoot, "inbound-dedupe.json.migrated"))).toBe(true);
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toBeNull();
});
it("records an empty legacy scan and then skips historical databases", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const migration = migrationById("matrix-inbound-dedupe-to-claimable-dedupe");
const params = createMigrationParams(stateDir);
await expect(migration.detectLegacyState(params)).resolves.toEqual({
preview: ["Matrix inbound dedupe legacy sources need a one-time migration scan"],
});
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
changes: [
"Recorded Matrix inbound dedupe migration completion (0 SQLite roots, 0 JSON roots scanned)",
],
warnings: [],
});
const lateDatabasePath = path.join(
stateDir,
"matrix",
"accounts",
"late",
"matrix.example.org__bot",
"token-a",
"state",
"openclaw.sqlite",
);
fs.mkdirSync(path.dirname(lateDatabasePath), { recursive: true });
fs.writeFileSync(lateDatabasePath, "the completed migration must not open this database");
await expect(migration.detectLegacyState(params)).resolves.toBeNull();
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
changes: [],
warnings: [],
});
});
it("withholds completion after a directory read failure and imports the source on retry", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const blockedDir = path.join(stateDir, "matrix", "accounts", "home");
const jsonRoot = path.join(blockedDir, "matrix.example.org__bot", "token-a");
const jsonPath = path.join(jsonRoot, "inbound-dedupe.json");
const roomId = "!room:example.org";
const eventId = "$found-on-retry";
fs.mkdirSync(jsonRoot, { recursive: true });
fs.writeFileSync(
jsonPath,
JSON.stringify({
version: 1,
entries: [{ key: `${roomId}|${eventId}`, ts: Date.now() - 60_000 }],
}),
);
fs.writeFileSync(
path.join(jsonRoot, "storage-meta.json"),
JSON.stringify({ accountId: "home", userId: "@home:example.org" }),
);
const originalReaddir = fsPromises.readdir.bind(fsPromises);
const readdirSpy = vi.spyOn(fsPromises, "readdir").mockImplementation(async (...args) => {
if (path.resolve(String(args[0])) === path.resolve(blockedDir)) {
throw Object.assign(new Error("injected directory read failure"), { code: "EACCES" });
}
return originalReaddir(...args);
});
const migration = migrationById("matrix-inbound-dedupe-to-claimable-dedupe");
const params = createMigrationParams(stateDir);
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
changes: [],
warnings: [
`Failed scanning Matrix inbound dedupe sources under ${blockedDir}: Error: injected directory read failure`,
],
});
await expect(migration.detectLegacyState(params)).resolves.toEqual({
preview: ["Matrix inbound dedupe legacy sources need a one-time migration scan"],
});
readdirSpy.mockRestore();
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
changes: [
"Migrated Matrix inbound dedupe markers to the claimable dedupe store (1 of 1 entries)",
`Archived Matrix inbound dedupe legacy source -> ${jsonPath}.migrated`,
"Recorded Matrix inbound dedupe migration completion (0 SQLite roots, 1 JSON roots scanned)",
],
warnings: [],
});
const deduper = createMatrixInboundEventDeduper({
auth: { accountId: "home" },
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
});
await expect(deduper.claim({ roomId, eventId })).resolves.toEqual({ kind: "duplicate" });
await expect(migration.detectLegacyState(params)).resolves.toBeNull();
});
it("ignores an invalid legacy-scan completion receipt", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const params = createMigrationParams(stateDir);
const receiptStore = params.context.openPluginStateKeyedStore<{
version: number;
completedAt: number;
}>({
namespace: "inbound-dedupe-migration-state",
maxEntries: 4,
overflowPolicy: "reject-new",
env: params.env,
});
await receiptStore.register("sqlite-json-to-claimable-v1", {
version: 1,
completedAt: -1,
});
await expect(
migrationById("matrix-inbound-dedupe-to-claimable-dedupe").detectLegacyState(params),
).resolves.toEqual({
preview: ["Matrix inbound dedupe legacy sources need a one-time migration scan"],
});
});
it("archives malformed inbound dedupe JSON without importing it", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const jsonRoot = path.join(
stateDir,
"matrix",
"accounts",
"home",
"matrix.example.org__bot",
"token-a",
);
fs.mkdirSync(jsonRoot, { recursive: true });
const jsonPath = path.join(jsonRoot, "inbound-dedupe.json");
fs.writeFileSync(jsonPath, "not-json");
const migration = migrationById("matrix-inbound-dedupe-to-claimable-dedupe");
await expect(migration.migrateLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
changes: [
"Migrated Matrix inbound dedupe markers to the claimable dedupe store (0 of 0 entries)",
`Archived Matrix inbound dedupe legacy source -> ${jsonPath}.migrated`,
],
warnings: [
`Matrix inbound dedupe JSON for ${jsonRoot} is malformed; archived without import`,
],
});
expect(fs.existsSync(jsonPath)).toBe(false);
expect(fs.existsSync(`${jsonPath}.migrated`)).toBe(true);
await expect(migration.detectLegacyState(createMigrationParams(stateDir))).resolves.toEqual({
preview: ["Matrix inbound dedupe legacy sources need a one-time migration scan"],
});
});
it("keeps inbound dedupe sources when retention-aware import is unavailable", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const jsonRoot = path.join(
stateDir,
"matrix",
"accounts",
"home",
"matrix.example.org__bot",
"token-a",
);
fs.mkdirSync(jsonRoot, { recursive: true });
const jsonPath = path.join(jsonRoot, "inbound-dedupe.json");
fs.writeFileSync(
jsonPath,
JSON.stringify({
version: 1,
entries: [{ key: "!room:example.org|$legacy", ts: Date.now() - 60_000 }],
}),
);
const params = createMigrationParams(stateDir);
delete params.context.importPluginStateEntries;
const migration = migrationById("matrix-inbound-dedupe-to-claimable-dedupe");
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
changes: [],
warnings: [
"Failed importing Matrix inbound dedupe markers: Error: retention-aware Matrix inbound dedupe import is unavailable; left legacy sources in place",
],
});
expect(fs.existsSync(jsonPath)).toBe(true);
expect(fs.existsSync(`${jsonPath}.migrated`)).toBe(false);
await expect(migration.detectLegacyState(params)).resolves.toEqual({
preview: ["Matrix inbound dedupe legacy sources need a one-time migration scan"],
});
});
it("keeps newer runtime dedupe rows when legacy imports hit capacity", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const env = { OPENCLAW_STATE_DIR: stateDir };
const io = { context: createContext(env), env };
const roomId = "!room:example.org";
const now = Date.now();
const store = createPluginStateKeyedStoreForTests<PersistentDedupeEntry>("matrix", {
namespace: resolveMatrixInboundDedupeStateNamespace(),
maxEntries: 3,
defaultTtlMs: MATRIX_INBOUND_DEDUPE_TTL_MS,
env: io.env,
});
const runtimeEntry = createPersistentDedupeImportEntry({
key: `ops\0${roomId}\0$runtime`,
seenAt: now,
});
await store.register(runtimeEntry.key, runtimeEntry.value);
// Both legacy rows fit, but retain their source age below the runtime row.
await expect(
importNewestInboundDedupeMarkers({
io,
now,
stateMaxEntries: 3,
markers: [
{ accountId: "ops", roomId, eventId: "$old", ts: now - 60_000 },
{ accountId: "ops", roomId, eventId: "$newer", ts: now - 30_000 },
],
}),
).resolves.toEqual({ imported: 2, total: 2 });
// The next runtime-equivalent insert evicts the oldest legacy row, not the
// newer legacy marker or the row committed after upgrade.
const nextRuntimeEntry = createPersistentDedupeImportEntry({
key: `ops\0${roomId}\0$next-runtime`,
seenAt: now + 1,
});
await store.register(nextRuntimeEntry.key, nextRuntimeEntry.value);
const keys = (await store.entries()).map((entry) => entry.value.key).toSorted();
expect(keys).toEqual(
[
`ops\0${roomId}\0$newer`,
`ops\0${roomId}\0$runtime`,
`ops\0${roomId}\0$next-runtime`,
].toSorted(),
);
});
it("preserves a legacy inbound dedupe marker's remaining TTL", async () => {
const stateDir = tempDirs.make("openclaw-matrix-doctor-");
const env = { OPENCLAW_STATE_DIR: stateDir };
const io = { context: createContext(env), env };
const now = 2_000_000_000_000;
const remainingTtlMs = 1_000;
const roomId = "!room:example.org";
const eventId = "$near-expiry";
const key = `ops\0${roomId}\0${eventId}`;
const markerTs = now - MATRIX_INBOUND_DEDUPE_TTL_MS + remainingTtlMs;
const storedEntry = createPersistentDedupeImportEntry({ key, seenAt: markerTs });
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
await expect(
importNewestInboundDedupeMarkers({
io,
now,
markers: [
{
accountId: "ops",
roomId,
eventId,
ts: markerTs,
},
],
}),
).resolves.toEqual({ imported: 1, total: 1 });
const store = createPluginStateKeyedStoreForTests<PersistentDedupeEntry>("matrix", {
namespace: resolveMatrixInboundDedupeStateNamespace(),
maxEntries: 20_000,
defaultTtlMs: MATRIX_INBOUND_DEDUPE_TTL_MS,
env,
});
nowSpy.mockReturnValue(now + remainingTtlMs - 1);
await expect(store.lookup(storedEntry.key)).resolves.toEqual(storedEntry.value);
nowSpy.mockReturnValue(now + remainingTtlMs + 1);
await expect(store.lookup(storedEntry.key)).resolves.toBeUndefined();
});
});