import type { AgentImageResponse } from './agent-api-contracts'; import { discardArtifactFiles, isArtifactFilepathAllowed, moveArtifactFilesForDeletion, restoreArtifactFiles } from './agent-file-utils'; import { addMilliseconds, addSeconds, buildRecoveredResponse, computeRetryAfterSeconds, createRequestId, isoDate, parseJson, serializeJson, type AgentArtifactRecord, type AgentRequestRecord, type AgentStateStore, type BeginAgentRequestInput, type BeginAgentRequestResult, type CompleteAgentRequestInput, type FailAgentRequestInput } from './agent-state-store'; import type { AgentErrorBody } from './api-error-response'; import type { FeedbackDeleteOptions, FeedbackRecord, FeedbackStateStore, FeedbackTarget, FeedbackTargetType, FeedbackValue, FeedbackSource } from './feedback-store'; import type { ImageShareRecord, ImageShareStateStore } from './share-store'; import Database from 'better-sqlite3'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; type SqliteRequestRow = { request_id: string; idempotency_key: string; request_hash: string; mode: 'generate' | 'edit'; status: AgentRequestRecord['status']; request_json: string; response_json: string | null; error_json: string | null; locked_until: string | null; created_at: string; updated_at: string; expires_at: string; }; type SqliteArtifactRow = { id: string; request_id: string; filename: string; filepath: string; content_url: string; metadata_url: string; output_format: string; mime_type: string; size_bytes: number; width: number | null; height: number | null; model: string; prompt_hash: string; created_at: string; }; type SqliteShareRow = { token: string; source_filename: string; content_filename: string; mime_type: string; size_bytes: number; created_at: string; access_code_required: number; expires_at: string | null; access_code_salt: string | null; access_code_hash: string | null; }; type SqliteFeedbackRow = { target_type: FeedbackTargetType; target_id: string; value: FeedbackValue; note: string | null; source: FeedbackSource; updated_at: string; }; type SqliteMigrationRow = { id: string; checksum?: string | null; }; export class SqliteAgentStateStore implements AgentStateStore, ImageShareStateStore, FeedbackStateStore { private db: Database.Database | undefined; constructor(private readonly dbPath: string) {} async init(): Promise { fs.mkdirSync(path.dirname(this.dbPath), { recursive: true }); this.db = new Database(this.dbPath); this.db.pragma('foreign_keys = ON'); this.db.pragma('journal_mode = WAL'); this.db.pragma('synchronous = NORMAL'); this.db.pragma('busy_timeout = 5000'); runSqliteMigrations(this.db); } async close(): Promise { const db = this.db; if (!db) return; db.close(); this.db = undefined; } async recoverExpiredRequests(now = new Date()): Promise { const db = this.requireDb(); const nowIso = isoDate(now); const expiredRows = db .prepare( "SELECT * FROM agent_requests WHERE status = 'running' AND locked_until IS NOT NULL AND locked_until < ?" ) .all(nowIso) as SqliteRequestRow[]; const transaction = db.transaction(() => { for (const row of expiredRows) { const record = this.mapRequestRow(row); const artifacts = this.listArtifactsForRequestSync(record.requestId); if (artifacts.length > 0) { db.prepare( "UPDATE agent_requests SET status = 'succeeded', response_json = ?, error_json = NULL, locked_until = NULL, updated_at = ? WHERE request_id = ?" ).run(serializeJson(buildRecoveredResponse(record, artifacts)), nowIso, record.requestId); } else if (record.errorJson) { db.prepare( "UPDATE agent_requests SET status = 'failed', locked_until = NULL, updated_at = ? WHERE request_id = ?" ).run(nowIso, record.requestId); } else { db.prepare( "UPDATE agent_requests SET status = 'orphaned', locked_until = NULL, updated_at = ? WHERE request_id = ?" ).run(nowIso, record.requestId); } } if (expiredRows.length > 0) { db.prepare( 'INSERT INTO agent_recovery_events (id, event_type, details_json, created_at) VALUES (?, ?, ?, ?)' ).run( cryptoRandomId(), 'expired_running_requests', serializeJson({ count: expiredRows.length }), nowIso ); } }); transaction(); return expiredRows.length; } async purgeExpiredRequests(now = new Date()): Promise { const db = this.requireDb(); const nowIso = isoDate(now); const expiredRows = db .prepare( `SELECT r.request_id, a.id AS artifact_id, a.filepath FROM agent_requests r LEFT JOIN agent_artifacts a ON a.request_id = r.request_id WHERE r.expires_at < ? AND r.status IN ('succeeded', 'failed', 'orphaned')` ) .all(nowIso) as Array<{ request_id: string; artifact_id: string | null; filepath: string | null }>; const requestIds = [...new Set(expiredRows.map((row) => row.request_id))]; const artifactIds = [ ...new Set(expiredRows.map((row) => row.artifact_id).filter((id): id is string => typeof id === 'string')) ]; const artifactFilepaths = [ ...new Set( expiredRows .map((row) => row.filepath) .filter( (filepath): filepath is string => typeof filepath === 'string' && isArtifactFilepathAllowed(filepath) ) ) ]; const movedFiles = await moveArtifactFilesForDeletion(artifactFilepaths); const transaction = db.transaction(() => { for (const artifactId of artifactIds) { db.prepare("DELETE FROM result_feedback WHERE target_type = 'agent_artifact' AND target_id = ?").run( artifactId ); } for (const requestId of requestIds) { db.prepare("DELETE FROM result_feedback WHERE target_type = 'agent_request' AND target_id = ?").run( requestId ); db.prepare('DELETE FROM agent_artifacts WHERE request_id = ?').run(requestId); db.prepare('DELETE FROM agent_requests WHERE request_id = ?').run(requestId); } }); try { transaction(); } catch (error) { await restoreArtifactFiles(movedFiles); throw error; } await discardArtifactFiles(movedFiles); return requestIds.length; } async beginRequest(input: BeginAgentRequestInput): Promise { const db = this.requireDb(); const now = input.now ?? new Date(); const nowIso = isoDate(now); const lockedUntil = isoDate(addMilliseconds(now, input.leaseMs)); const expiresAt = isoDate(addSeconds(now, input.ttlSeconds)); db.exec('BEGIN IMMEDIATE'); try { const result = this.beginRequestInTransaction(input, now, nowIso, lockedUntil, expiresAt); db.exec('COMMIT'); return result; } catch (error) { db.exec('ROLLBACK'); throw error; } } async refreshRequestLease(input: { requestId: string; leaseMs: number; now?: Date }): Promise { const now = input.now ?? new Date(); const result = this.requireDb() .prepare( "UPDATE agent_requests SET locked_until = ?, updated_at = ? WHERE request_id = ? AND status IN ('running', 'pending')" ) .run(isoDate(addMilliseconds(now, input.leaseMs)), isoDate(now), input.requestId); return result.changes > 0; } private beginRequestInTransaction( input: BeginAgentRequestInput, now: Date, nowIso: string, lockedUntil: string, expiresAt: string ): BeginAgentRequestResult { const existing = this.getRequestRow(input.idempotencyKey); if (!existing) { const requestId = createRequestId(); const insertResult = this.requireDb() .prepare( `INSERT OR IGNORE INTO agent_requests (request_id, idempotency_key, request_hash, mode, status, request_json, locked_until, created_at, updated_at, expires_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, ?)` ) .run( requestId, input.idempotencyKey, input.requestHash, input.mode, serializeJson(input.requestJson), lockedUntil, nowIso, nowIso, expiresAt ); if (insertResult.changes > 0) { const inserted = this.getRequestRow(input.idempotencyKey); if (!inserted) { throw new Error('idempotency row disappeared during acquisition'); } return { type: 'acquired', record: this.mapRequestRow(inserted) }; } } const current = this.getRequestRow(input.idempotencyKey); if (!current) { throw new Error('idempotency row disappeared during acquisition'); } return this.beginFromExistingRow(current, input, now, nowIso, lockedUntil, expiresAt); } private beginFromExistingRow( existing: SqliteRequestRow, input: BeginAgentRequestInput, now: Date, nowIso: string, lockedUntil: string, expiresAt: string ): BeginAgentRequestResult { const record = this.mapRequestRow(existing); if (existing.request_hash !== input.requestHash) { return { type: 'conflict', record }; } if (existing.status === 'succeeded' && existing.response_json) { return { type: 'replay', record, response: JSON.parse(existing.response_json) as AgentImageResponse }; } if (existing.status === 'failed' && existing.error_json) { return { type: 'failed', record, error: JSON.parse(existing.error_json) as AgentErrorBody }; } if ( (existing.status === 'running' || existing.status === 'pending') && existing.locked_until && existing.locked_until > nowIso ) { return { type: 'in_progress', record, retryAfterSeconds: computeRetryAfterSeconds(existing.locked_until, now) }; } this.requireDb() .prepare( "UPDATE agent_requests SET status = 'running', locked_until = ?, updated_at = ?, expires_at = ? WHERE idempotency_key = ?" ) .run(lockedUntil, nowIso, expiresAt, input.idempotencyKey); return { type: 'acquired', record: this.mapRequestRow(this.getRequestRow(input.idempotencyKey)!) }; } async completeRequest(input: CompleteAgentRequestInput): Promise { const db = this.requireDb(); const nowIso = isoDate(input.now ?? new Date()); const transaction = db.transaction(() => { this.insertArtifacts(input.artifacts); db.prepare( "UPDATE agent_requests SET status = 'succeeded', response_json = ?, error_json = NULL, locked_until = NULL, updated_at = ? WHERE request_id = ?" ).run(serializeJson(input.response), nowIso, input.requestId); }); transaction(); } async saveArtifacts(artifacts: AgentArtifactRecord[]): Promise { const transaction = this.requireDb().transaction(() => this.insertArtifacts(artifacts)); transaction(); } async failRequest(input: FailAgentRequestInput): Promise { const db = this.requireDb(); const nowIso = isoDate(input.now ?? new Date()); db.prepare( "UPDATE agent_requests SET status = 'failed', response_json = NULL, error_json = ?, locked_until = NULL, updated_at = ? WHERE request_id = ?" ).run(serializeJson(input.error), nowIso, input.requestId); } async getRequest(requestId: string): Promise { const row = this.requireDb().prepare('SELECT * FROM agent_requests WHERE request_id = ?').get(requestId) as SqliteRequestRow | undefined; return row ? this.mapRequestRow(row) : undefined; } async getRequestByIdempotencyKey(idempotencyKey: string): Promise { const row = this.requireDb() .prepare('SELECT * FROM agent_requests WHERE idempotency_key = ?') .get(idempotencyKey) as SqliteRequestRow | undefined; return row ? this.mapRequestRow(row) : undefined; } async getArtifact(id: string): Promise { const row = this.requireDb().prepare('SELECT * FROM agent_artifacts WHERE id = ?').get(id) as SqliteArtifactRow | undefined; return row ? this.mapArtifactRow(row) : undefined; } async listArtifactsForRequest(requestId: string): Promise { return this.listArtifactsForRequestSync(requestId); } async listArtifactFilepaths(): Promise { const rows = this.requireDb() .prepare('SELECT DISTINCT filepath FROM agent_artifacts ORDER BY filepath ASC') .all() as Array<{ filepath: string }>; return rows.map((row) => row.filepath); } async deleteArtifact(id: string): Promise { const result = this.requireDb().transaction(() => { const deleteResult = this.requireDb().prepare('DELETE FROM agent_artifacts WHERE id = ?').run(id); if (deleteResult.changes > 0) { this.requireDb() .prepare("DELETE FROM result_feedback WHERE target_type = 'agent_artifact' AND target_id = ?") .run(id); } return deleteResult; })(); return result.changes > 0; } async upsertFeedback(record: FeedbackRecord): Promise { this.requireDb() .prepare( `INSERT INTO result_feedback (target_type, target_id, value, note, source, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(target_type, target_id) DO UPDATE SET value = excluded.value, note = excluded.note, source = excluded.source, updated_at = excluded.updated_at WHERE result_feedback.updated_at <= excluded.updated_at` ) .run( record.targetType, record.targetId, record.value, record.note ?? null, record.source, record.updatedAt ); } async upsertFeedbackBatch(records: FeedbackRecord[]): Promise { if (records.length === 0) return; const statement = this.requireDb().prepare( `INSERT INTO result_feedback (target_type, target_id, value, note, source, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(target_type, target_id) DO UPDATE SET value = excluded.value, note = excluded.note, source = excluded.source, updated_at = excluded.updated_at WHERE result_feedback.updated_at <= excluded.updated_at` ); this.requireDb().transaction(() => { for (const record of records) { statement.run( record.targetType, record.targetId, record.value, record.note ?? null, record.source, record.updatedAt ); } })(); } async readFeedback(targetType: FeedbackTargetType, targetId: string): Promise { const row = this.requireDb() .prepare('SELECT * FROM result_feedback WHERE target_type = ? AND target_id = ?') .get(targetType, targetId) as SqliteFeedbackRow | undefined; return row ? this.mapFeedbackRow(row) : undefined; } async listFeedbackByTargets(targets: FeedbackTarget[]): Promise { const records: FeedbackRecord[] = []; for (const target of targets) { const record = await this.readFeedback(target.targetType, target.targetId); if (record) records.push(record); } return records; } async deleteFeedbackByTargets(targets: FeedbackTarget[], options: FeedbackDeleteOptions = {}): Promise { let deleted = 0; for (const target of targets) { const result = options.deletedAt ? this.requireDb() .prepare( 'DELETE FROM result_feedback WHERE target_type = ? AND target_id = ? AND updated_at <= ?' ) .run(target.targetType, target.targetId, options.deletedAt) : this.requireDb() .prepare('DELETE FROM result_feedback WHERE target_type = ? AND target_id = ?') .run(target.targetType, target.targetId); deleted += result.changes; } return deleted; } async createImageShareRecord(record: ImageShareRecord): Promise { this.requireDb() .prepare( `INSERT INTO image_shares (token, source_filename, content_filename, mime_type, size_bytes, created_at, access_code_required, expires_at, access_code_salt, access_code_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( record.token, record.sourceFilename, record.contentFilename, record.mimeType, record.sizeBytes, record.createdAt, record.accessCodeRequired ? 1 : 0, record.expiresAt ?? null, record.accessCodeSalt ?? null, record.accessCodeHash ?? null ); } async readImageShareRecord(token: string): Promise { const row = this.requireDb().prepare('SELECT * FROM image_shares WHERE token = ?').get(token) as SqliteShareRow | undefined; return row ? this.mapShareRow(row) : undefined; } async deleteExpiredImageShareRecords(nowIso: string): Promise { const db = this.requireDb(); const rows = db .prepare( 'SELECT * FROM image_shares WHERE expires_at IS NOT NULL AND expires_at < ? ORDER BY expires_at ASC, token ASC' ) .all(nowIso) as SqliteShareRow[]; const transaction = db.transaction(() => { for (const row of rows) { db.prepare('DELETE FROM image_shares WHERE token = ?').run(row.token); } }); transaction(); return rows.map((row) => this.mapShareRow(row)); } async listImageShareRecords(): Promise { const rows = this.requireDb() .prepare('SELECT * FROM image_shares ORDER BY created_at ASC, token ASC') .all() as SqliteShareRow[]; return rows.map((row) => this.mapShareRow(row)); } private requireDb(): Database.Database { if (!this.db) { throw new Error('SQLite Agent 状态库尚未初始化。'); } return this.db; } private getRequestRow(idempotencyKey: string): SqliteRequestRow | undefined { return this.requireDb() .prepare('SELECT * FROM agent_requests WHERE idempotency_key = ?') .get(idempotencyKey) as SqliteRequestRow | undefined; } private insertArtifacts(artifacts: AgentArtifactRecord[]): void { artifacts.forEach((artifact) => { this.assertArtifactCanBeInserted(artifact); this.requireDb() .prepare( `INSERT INTO agent_artifacts (id, request_id, filename, filepath, content_url, metadata_url, output_format, mime_type, size_bytes, width, height, model, prompt_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING` ) .run( artifact.id, artifact.requestId, artifact.filename, artifact.filepath, artifact.contentUrl, artifact.metadataUrl, artifact.outputFormat, artifact.mimeType, artifact.sizeBytes, artifact.width, artifact.height, artifact.model, artifact.promptHash, artifact.createdAt ); }); } private assertArtifactCanBeInserted(artifact: AgentArtifactRecord): void { const existing = this.requireDb().prepare('SELECT * FROM agent_artifacts WHERE id = ?').get(artifact.id) as SqliteArtifactRow | undefined; if (existing && !sameArtifactRecord(this.mapArtifactRow(existing), artifact)) { throw new Error('artifact metadata conflict'); } } private listArtifactsForRequestSync(requestId: string): AgentArtifactRecord[] { const rows = this.requireDb() .prepare('SELECT * FROM agent_artifacts WHERE request_id = ? ORDER BY created_at ASC') .all(requestId) as SqliteArtifactRow[]; return rows.map((row) => this.mapArtifactRow(row)); } private mapRequestRow(row: SqliteRequestRow): AgentRequestRecord { return { requestId: row.request_id, idempotencyKey: row.idempotency_key, requestHash: row.request_hash, mode: row.mode, status: row.status, requestJson: JSON.parse(row.request_json) as unknown, responseJson: parseJson(row.response_json), errorJson: parseJson(row.error_json), ...(row.locked_until ? { lockedUntil: row.locked_until } : {}), createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at }; } private mapArtifactRow(row: SqliteArtifactRow): AgentArtifactRecord { return { id: row.id, requestId: row.request_id, filename: row.filename, filepath: row.filepath, contentUrl: row.content_url, metadataUrl: row.metadata_url, outputFormat: row.output_format, mimeType: row.mime_type, sizeBytes: row.size_bytes, width: row.width, height: row.height, model: row.model, promptHash: row.prompt_hash, createdAt: row.created_at }; } private mapShareRow(row: SqliteShareRow): ImageShareRecord { return { token: row.token, sourceFilename: row.source_filename, contentFilename: row.content_filename, mimeType: row.mime_type, sizeBytes: row.size_bytes, createdAt: row.created_at, accessCodeRequired: Boolean(row.access_code_required), ...(row.expires_at ? { expiresAt: row.expires_at } : {}), ...(row.access_code_salt ? { accessCodeSalt: row.access_code_salt } : {}), ...(row.access_code_hash ? { accessCodeHash: row.access_code_hash } : {}) }; } private mapFeedbackRow(row: SqliteFeedbackRow): FeedbackRecord { return { targetType: row.target_type, targetId: row.target_id, value: row.value, source: row.source, updatedAt: row.updated_at, ...(row.note ? { note: row.note } : {}) }; } } function cryptoRandomId(): string { return crypto.randomUUID(); } type SqliteMigration = { id: string; sql: string; }; const SQLITE_MIGRATION_TABLE_SCHEMA = ` CREATE TABLE IF NOT EXISTS state_schema_migrations ( id TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL );`; const SQLITE_MIGRATIONS: SqliteMigration[] = [ { id: '001_agent_state_core', sql: ` CREATE TABLE IF NOT EXISTS agent_requests ( request_id TEXT PRIMARY KEY, idempotency_key TEXT NOT NULL UNIQUE, request_hash TEXT NOT NULL, mode TEXT NOT NULL CHECK (mode IN ('generate', 'edit')), status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'orphaned')), request_json TEXT NOT NULL, response_json TEXT, error_json TEXT, locked_until TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expires_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_agent_requests_status_locked_until ON agent_requests(status, locked_until); CREATE INDEX IF NOT EXISTS idx_agent_requests_expires_at ON agent_requests(expires_at); CREATE TABLE IF NOT EXISTS agent_artifacts ( id TEXT PRIMARY KEY, request_id TEXT NOT NULL, filename TEXT NOT NULL UNIQUE, filepath TEXT NOT NULL, content_url TEXT NOT NULL, metadata_url TEXT NOT NULL, output_format TEXT NOT NULL, mime_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, width INTEGER, height INTEGER, model TEXT NOT NULL, prompt_hash TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY(request_id) REFERENCES agent_requests(request_id) ); CREATE INDEX IF NOT EXISTS idx_agent_artifacts_request_id ON agent_artifacts(request_id); CREATE TABLE IF NOT EXISTS agent_recovery_events ( id TEXT PRIMARY KEY, event_type TEXT NOT NULL, details_json TEXT NOT NULL, created_at TEXT NOT NULL ); ` }, { id: '002_image_shares', sql: ` CREATE TABLE IF NOT EXISTS image_shares ( token TEXT PRIMARY KEY, source_filename TEXT NOT NULL, content_filename TEXT NOT NULL UNIQUE, mime_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, created_at TEXT NOT NULL, access_code_required INTEGER NOT NULL CHECK (access_code_required IN (0, 1)), expires_at TEXT, access_code_salt TEXT, access_code_hash TEXT, CHECK ( (access_code_required = 0 AND access_code_salt IS NULL AND access_code_hash IS NULL) OR (access_code_required = 1 AND access_code_salt IS NOT NULL AND access_code_hash IS NOT NULL) ) ); CREATE INDEX IF NOT EXISTS idx_image_shares_expires_at ON image_shares(expires_at); ` }, { id: '003_result_feedback', sql: ` CREATE TABLE IF NOT EXISTS result_feedback ( target_type TEXT NOT NULL CHECK (target_type IN ('page_request', 'agent_request', 'agent_artifact')), target_id TEXT NOT NULL, value TEXT NOT NULL CHECK (value IN ('usable', 'needs_revision')), note TEXT, source TEXT NOT NULL CHECK (source IN ('webui', 'agent')), updated_at TEXT NOT NULL, PRIMARY KEY (target_type, target_id) ); CREATE INDEX IF NOT EXISTS idx_result_feedback_updated_at ON result_feedback(updated_at); ` } ]; function runSqliteMigrations(db: Database.Database): void { db.exec('BEGIN EXCLUSIVE'); try { db.exec(SQLITE_MIGRATION_TABLE_SCHEMA); ensureSqliteMigrationChecksumColumn(db); const appliedRows = db .prepare('SELECT id, checksum FROM state_schema_migrations') .all() as SqliteMigrationRow[]; const applied = new Map(appliedRows.map((row) => [row.id, row.checksum ?? null])); for (const migration of SQLITE_MIGRATIONS) { const checksum = migrationChecksum(migration.sql); if (applied.has(migration.id)) { if (applied.get(migration.id) !== checksum) { throw new Error(`SQLite migration checksum mismatch: ${migration.id}`); } continue; } db.exec(migration.sql); db.prepare('INSERT INTO state_schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)').run( migration.id, checksum, isoDate(new Date()) ); } db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); throw error; } } export const SQLITE_SCHEMA = [SQLITE_MIGRATION_TABLE_SCHEMA, ...SQLITE_MIGRATIONS.map((migration) => migration.sql)] .map((sql) => sql.trim()) .join('\n\n'); function ensureSqliteMigrationChecksumColumn(db: Database.Database): void { const columns = db.prepare('PRAGMA table_info(state_schema_migrations)').all() as Array<{ name: string }>; if (!columns.some((column) => column.name === 'checksum')) { db.exec('ALTER TABLE state_schema_migrations ADD COLUMN checksum TEXT'); } for (const migration of SQLITE_MIGRATIONS) { db.prepare('UPDATE state_schema_migrations SET checksum = ? WHERE id = ? AND checksum IS NULL').run( migrationChecksum(migration.sql), migration.id ); } } function migrationChecksum(sql: string): string { return crypto.createHash('sha256').update(sql.trim()).digest('hex'); } function sameArtifactRecord(left: AgentArtifactRecord, right: AgentArtifactRecord): boolean { return JSON.stringify(left) === JSON.stringify(right); }