blob: 84a880ed8b058586dfb7fd9e464e88c67281dd34 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { DatabaseSync } from 'node:sqlite';
import {
decodeArtifactRecordJsons,
isSafeRelativeArtifactPath,
} from './artifact-metadata-codec.js';
export const SQLITE_ARTIFACT_SCHEMA_VERSION = 3;
export function migrateSqliteArtifactDatabase(db: DatabaseSync): void {
const columns = db.prepare('PRAGMA table_info(artifact_records)').all() as Array<{
name?: unknown;
}>;
const retained: string[] = [];
// Every path the old table named. Whatever is not carried over is a file no
// catalog will name again, and this is the last moment anything knows it is
// there. Unlinking here is not an option: a rollback after one would be
// unrecoverable, so the paths are recorded for the store to reclaim later.
const scanned: string[] = [];
const hasStatusColumn = columns.some(({ name }) => name === 'status');
if (hasStatusColumn || columns.some(({ name }) => name === 'storage_key')) {
const rows = db.prepare('SELECT * FROM artifact_records').all();
for (const row of rows) {
if (typeof row.relative_path === 'string' && isSafeRelativeArtifactPath(row.relative_path)) {
scanned.push(row.relative_path);
}
try {
const parsed = JSON.parse(String(row.record_json));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
if (
parsed.id !== row.artifact_id ||
parsed.sessionId !== row.session_id ||
parsed.createdAt !== row.created_at ||
parsed.relativePath !== row.relative_path
)
continue;
if (
[hasStatusColumn ? row.status : undefined, parsed.status].some(
(value) => value !== undefined && value !== null && value !== 'live',
)
)
continue;
delete parsed.status;
retained.push(JSON.stringify(parsed));
} catch {}
}
db.exec('DROP TABLE artifact_records');
}
db.exec(`
CREATE TABLE IF NOT EXISTS artifact_records (
artifact_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
created_at INTEGER NOT NULL CHECK (created_at >= 0),
relative_path TEXT NOT NULL,
record_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS artifact_records_session_order
ON artifact_records(session_id, created_at, artifact_id);
CREATE UNIQUE INDEX IF NOT EXISTS artifact_records_relative_path
ON artifact_records(relative_path);
CREATE TABLE IF NOT EXISTS artifact_upgrade_orphan_paths (
relative_path TEXT PRIMARY KEY
);
`);
const carried = decodeArtifactRecordJsons(retained);
const kept = new Set(carried.map((record) => record.relativePath));
const orphan = db.prepare(`
INSERT INTO artifact_upgrade_orphan_paths VALUES (?)
ON CONFLICT(relative_path) DO NOTHING
`);
for (const relativePath of scanned) if (!kept.has(relativePath)) orphan.run(relativePath);
const insert = db.prepare(`
INSERT INTO artifact_records VALUES (?, ?, ?, ?, ?)
`);
for (const record of carried) {
insert.run(
record.id,
record.sessionId,
record.createdAt,
record.relativePath,
JSON.stringify(record),
);
}
}