feat(ql3): rehearse legacy sqlite upgrade

This commit is contained in:
whyour
2026-08-21 00:52:58 +08:00
parent 4ea156f189
commit c9e41812cb
23 changed files with 1533 additions and 23 deletions
@@ -1,7 +1,7 @@
#!/usr/bin/env node
// Keep the one-shot adoption binary beside its lifecycle command.
import { runLegacyCrontabAdoptionCommandFile } from './adoption';
import { runLocalAdoptionProductCommandFile } from './adoptionCommand';
const USAGE =
'Usage: ql3-adoption run --command-file /absolute/private-command.json';
@@ -40,7 +40,7 @@ async function main(argv: readonly string[]): Promise<void> {
return;
}
try {
const result = await runLegacyCrontabAdoptionCommandFile(argv[2]!);
const result = await runLocalAdoptionProductCommandFile(argv[2]!);
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
const candidate = error as {
@@ -0,0 +1,42 @@
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { runLegacyCrontabAdoptionCommandFile } from './adoption';
import {
isLocalSqliteAdoptionProductOperation,
type LocalSqliteAdoptionProductOperation,
} from './sqlite-adoption/contract';
import type { LocalSqliteAdoptionProductCommandResult } from './sqlite-adoption/command';
export type LocalAdoptionProductCommandResult =
| Awaited<ReturnType<typeof runLegacyCrontabAdoptionCommandFile>>
| LocalSqliteAdoptionProductCommandResult;
function operation(value: unknown): unknown {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as { readonly operation?: unknown }).operation
: undefined;
}
export async function runLocalAdoptionProductCommandFile(
commandFilePath: string,
): Promise<Readonly<LocalAdoptionProductCommandResult>> {
let candidate: unknown;
try {
candidate = readPrivateLocalCommandFile(commandFilePath);
} catch {
// Preserve the established legacy error mapping for unreadable files.
return runLegacyCrontabAdoptionCommandFile(commandFilePath);
}
const selected = operation(candidate);
if (!isLocalSqliteAdoptionProductOperation(selected)) {
return runLegacyCrontabAdoptionCommandFile(commandFilePath);
}
const { runLocalSqliteAdoptionProductCommand } = await import(
'./sqlite-adoption/command.js'
);
return runLocalSqliteAdoptionProductCommand(
candidate as {
readonly operation: LocalSqliteAdoptionProductOperation;
},
);
}
@@ -0,0 +1,431 @@
import fs from 'node:fs';
import path from 'node:path';
import {
inspectLegacySqlitePath,
prepareLocalSqliteActivation,
stageLocalSqliteAdoption,
verifyLocalSqliteAdoption,
type LegacySqliteAdoptionPlan,
type LocalSqliteActivation,
type LocalSqliteAdoptionManifest,
} from '@qinglong/local-admin';
import {
LocalSqliteAdoptionCliConfigurationError,
normalizeLocalSqliteAdoptionProductCommand,
type LocalSqliteAdoptionProductCommand,
} from './contract';
interface StableFileIdentity {
readonly path: string;
readonly device: bigint;
readonly inode: bigint;
readonly size: bigint;
readonly modifiedAtNs: bigint;
readonly changedAtNs: bigint;
readonly mode: number;
readonly uid: number;
}
interface StableDirectoryIdentity {
readonly path: string;
readonly device: bigint;
readonly inode: bigint;
readonly mode: number;
readonly uid: number;
}
export type LocalSqliteAdoptionProductCommandResult = Readonly<{
schemaVersion: 1;
operation: LocalSqliteAdoptionProductCommand['operation'];
status: 'inspected' | 'staged' | 'verified' | 'prepared';
evidence: Readonly<Record<string, unknown>>;
}>;
function currentUid(): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== process.geteuid()
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'real and effective POSIX users must match',
);
}
return process.getuid();
}
function inside(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate);
return (
relative !== '' &&
relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
function directoryIdentity(
directoryPath: string,
uid: number,
label: string,
): StableDirectoryIdentity {
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(directoryPath, { bigint: true });
} catch (error) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} is unavailable`,
error,
);
}
const mode = Number(stat.mode) & 0o777;
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
mode !== 0o700 ||
fs.realpathSync(directoryPath) !== directoryPath
) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} must be an owner-controlled 0700 canonical directory`,
);
}
return Object.freeze({
path: directoryPath,
device: stat.dev,
inode: stat.ino,
mode,
uid,
});
}
function fileIdentity(
filePath: string,
uid: number,
label: string,
requirePrivateMode: boolean,
): StableFileIdentity {
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(filePath, { bigint: true });
} catch (error) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} is unavailable`,
error,
);
}
const mode = Number(stat.mode) & 0o777;
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.nlink !== 1n ||
Number(stat.uid) !== uid ||
(requirePrivateMode ? mode !== 0o600 : (mode & 0o022) !== 0) ||
fs.realpathSync(filePath) !== filePath ||
stat.size < 1n
) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} identity or mode is invalid`,
);
}
return Object.freeze({
path: filePath,
device: stat.dev,
inode: stat.ino,
size: stat.size,
modifiedAtNs: stat.mtimeNs,
changedAtNs: stat.ctimeNs,
mode,
uid,
});
}
function sameDirectory(expected: StableDirectoryIdentity): void {
const actual = directoryIdentity(
expected.path,
expected.uid,
'authority directory',
);
if (
actual.device !== expected.device ||
actual.inode !== expected.inode ||
actual.mode !== expected.mode
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'authority directory changed during command execution',
);
}
}
function sameFile(expected: StableFileIdentity): void {
const actual = fileIdentity(
expected.path,
expected.uid,
'authority file',
expected.mode === 0o600,
);
if (
actual.device !== expected.device ||
actual.inode !== expected.inode ||
actual.size !== expected.size ||
actual.modifiedAtNs !== expected.modifiedAtNs ||
actual.changedAtNs !== expected.changedAtNs ||
actual.mode !== expected.mode
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'authority file changed during command execution',
);
}
}
function assertMissing(filePath: string, label: string): void {
try {
fs.lstatSync(filePath);
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return;
}
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} cannot be inspected`,
error,
);
}
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} must not already exist`,
);
}
function authorityProof(command: Readonly<LocalSqliteAdoptionProductCommand>): {
readonly uid: number;
verify(): void;
verifyCreated(paths: readonly string[]): void;
} {
const uid = currentUid();
const options = command.options;
const root = directoryIdentity(options.deploymentRoot, uid, 'deploymentRoot');
const sourcePaths =
'sourcePath' in options ? [options.sourcePath] : ([] as string[]);
const immutablePaths = [
...sourcePaths,
...(command.operation === 'local-sqlite.adoption.verify' ||
command.operation === 'local-sqlite.activation.prepare'
? [
command.options.targetPath,
command.options.recoveryPath,
command.options.manifestPath,
]
: []),
];
const files = immutablePaths.map((candidate) =>
fileIdentity(
candidate,
uid,
candidate === ('sourcePath' in options ? options.sourcePath : undefined)
? 'legacy source'
: 'adoption evidence',
candidate !== ('sourcePath' in options ? options.sourcePath : undefined),
),
);
const outputPaths =
command.operation === 'local-sqlite.adoption.stage'
? [
command.options.targetPath,
command.options.recoveryPath,
command.options.manifestPath,
]
: command.operation === 'local-sqlite.activation.prepare'
? [command.options.activationPath]
: [];
const outputDirectories = [...new Set(outputPaths.map(path.dirname))].map(
(directory) => {
if (
!inside(options.deploymentRoot, directory) &&
directory !== options.deploymentRoot
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'adoption outputs must remain inside deploymentRoot',
);
}
return directoryIdentity(directory, uid, 'output directory');
},
);
for (const outputPath of outputPaths) {
if (!inside(options.deploymentRoot, outputPath)) {
throw new LocalSqliteAdoptionCliConfigurationError(
'adoption outputs must remain inside deploymentRoot',
);
}
assertMissing(outputPath, 'adoption output');
}
const uniqueFiles = new Set(
files.map((entry) => `${entry.device}:${entry.inode}`),
);
if (uniqueFiles.size !== files.length) {
throw new LocalSqliteAdoptionCliConfigurationError(
'adoption authority files must not share an inode',
);
}
return Object.freeze({
uid,
verify() {
if (currentUid() !== uid) {
throw new LocalSqliteAdoptionCliConfigurationError(
'POSIX user changed during command execution',
);
}
sameDirectory(root);
for (const directory of outputDirectories) sameDirectory(directory);
for (const file of files) sameFile(file);
},
verifyCreated(paths: readonly string[]) {
for (const filePath of paths) {
fileIdentity(filePath, uid, 'created adoption output', true);
}
},
});
}
function planEvidence(plan: Readonly<LegacySqliteAdoptionPlan>) {
return Object.freeze({
profile: plan.profile,
planDigest: plan.planDigest,
source: Object.freeze({
fileName: plan.source.fileName,
pathDigest: plan.source.pathDigest,
bytes: plan.source.bytes,
}),
catalog: Object.freeze({
digest: plan.catalog.digest,
objectCount: plan.catalog.objectCount,
tableCount: plan.catalog.tableNames.length,
tableNames: plan.catalog.tableNames,
}),
tasks: plan.tasks,
});
}
function adoptionEvidence(manifest: Readonly<LocalSqliteAdoptionManifest>) {
return Object.freeze({
profile: manifest.profile,
planDigest: manifest.planDigest,
manifestDigest: manifest.manifestDigest,
createdAtMs: manifest.createdAtMs,
source: Object.freeze({
fileName: manifest.source.fileName,
pathDigest: manifest.source.pathDigest,
bytes: manifest.source.bytes,
}),
catalog: Object.freeze({
digest: manifest.catalog.digest,
objectCount: manifest.catalog.objectCount,
tableCount: manifest.catalog.tableNames.length,
}),
tasks: manifest.tasks,
recovery: manifest.recovery,
target: manifest.target,
readiness: manifest.readiness,
});
}
function activationEvidence(activation: Readonly<LocalSqliteActivation>) {
return Object.freeze({ ...activation });
}
export async function runLocalSqliteAdoptionProductCommand(
value: unknown,
): Promise<LocalSqliteAdoptionProductCommandResult> {
const command = normalizeLocalSqliteAdoptionProductCommand(value);
const proof = authorityProof(command);
if (command.operation === 'local-sqlite.adoption.inspect') {
const options = command.options;
const plan = inspectLegacySqlitePath({
sourcePath: options.sourcePath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
});
proof.verify();
return Object.freeze({
schemaVersion: 1,
operation: command.operation,
status: 'inspected',
evidence: planEvidence(plan),
});
}
if (command.operation === 'local-sqlite.adoption.stage') {
const options = command.options;
const manifest = await stageLocalSqliteAdoption({
sourcePath: options.sourcePath,
targetPath: options.targetPath,
recoveryPath: options.recoveryPath,
manifestPath: options.manifestPath,
profile: options.profile,
expectedPlanDigest: options.expectedPlanDigest,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
});
proof.verify();
proof.verifyCreated([
options.targetPath,
options.recoveryPath,
options.manifestPath,
]);
return Object.freeze({
schemaVersion: 1,
operation: command.operation,
status: 'staged',
evidence: adoptionEvidence(manifest),
});
}
if (command.operation === 'local-sqlite.adoption.verify') {
const options = command.options;
const manifest = await verifyLocalSqliteAdoption({
targetPath: options.targetPath,
recoveryPath: options.recoveryPath,
manifestPath: options.manifestPath,
});
if (manifest.profile !== options.profile) {
throw new LocalSqliteAdoptionCliConfigurationError(
'verified adoption profile drifted',
);
}
proof.verify();
return Object.freeze({
schemaVersion: 1,
operation: command.operation,
status: 'verified',
evidence: adoptionEvidence(manifest),
});
}
const options = command.options;
const activation = await prepareLocalSqliteActivation({
sourcePath: options.sourcePath,
targetPath: options.targetPath,
recoveryPath: options.recoveryPath,
manifestPath: options.manifestPath,
activationPath: options.activationPath,
expectedManifestDigest: options.expectedManifestDigest,
});
if (activation.profile !== options.profile) {
throw new LocalSqliteAdoptionCliConfigurationError(
'prepared activation profile drifted',
);
}
proof.verify();
proof.verifyCreated([options.activationPath]);
return Object.freeze({
schemaVersion: 1,
operation: command.operation,
status: 'prepared',
evidence: activationEvidence(activation),
});
}
@@ -0,0 +1,244 @@
import path from 'node:path';
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const MAX_PATH_BYTES = 4_096;
export type LocalSqliteAdoptionProductOperation =
| 'local-sqlite.adoption.inspect'
| 'local-sqlite.adoption.stage'
| 'local-sqlite.adoption.verify'
| 'local-sqlite.activation.prepare';
interface LocalSqliteAdoptionCommandOptionsBase {
readonly deploymentRoot: string;
readonly profile: 'edge' | 'standalone';
}
export interface InspectLocalSqliteAdoptionCommand {
readonly schemaVersion: 1;
readonly operation: 'local-sqlite.adoption.inspect';
readonly options: LocalSqliteAdoptionCommandOptionsBase & {
readonly sourcePath: string;
readonly legacyTimezone?: string;
};
}
export interface StageLocalSqliteAdoptionCommand {
readonly schemaVersion: 1;
readonly operation: 'local-sqlite.adoption.stage';
readonly options: LocalSqliteAdoptionCommandOptionsBase & {
readonly sourcePath: string;
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
readonly expectedPlanDigest: string;
readonly legacyTimezone?: string;
};
}
export interface VerifyLocalSqliteAdoptionCommand {
readonly schemaVersion: 1;
readonly operation: 'local-sqlite.adoption.verify';
readonly options: LocalSqliteAdoptionCommandOptionsBase & {
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
};
}
export interface PrepareLocalSqliteActivationCommand {
readonly schemaVersion: 1;
readonly operation: 'local-sqlite.activation.prepare';
readonly options: LocalSqliteAdoptionCommandOptionsBase & {
readonly sourcePath: string;
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
readonly activationPath: string;
readonly expectedManifestDigest: string;
};
}
export type LocalSqliteAdoptionProductCommand =
| InspectLocalSqliteAdoptionCommand
| StageLocalSqliteAdoptionCommand
| VerifyLocalSqliteAdoptionCommand
| PrepareLocalSqliteActivationCommand;
export class LocalSqliteAdoptionCliConfigurationError extends TypeError {
readonly code = 'LOCAL_SQLITE_ADOPTION_CLI_CONFIGURATION_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(`Local SQLite adoption CLI configuration is invalid: ${message}`);
this.name = 'LocalSqliteAdoptionCliConfigurationError';
}
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
function boundedPath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.parse(value).root === value ||
path.normalize(value) !== value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new LocalSqliteAdoptionCliConfigurationError(
`${label} must be a normalized bounded absolute non-root path`,
);
}
return value;
}
function optionalTimezone(options: Record<string, unknown>): string[] {
return options.legacyTimezone === undefined ? [] : ['legacyTimezone'];
}
function assertTimezone(value: unknown): void {
if (
value !== undefined &&
(typeof value !== 'string' ||
value.length < 1 ||
value.length > 128 ||
/[\0\r\n]/.test(value))
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'legacyTimezone is invalid',
);
}
}
function assertDistinct(paths: readonly string[]): void {
const resolved = paths.map((value) => path.resolve(value));
if (new Set(resolved).size !== resolved.length) {
throw new LocalSqliteAdoptionCliConfigurationError(
'SQLite adoption paths must be distinct',
);
}
}
export function isLocalSqliteAdoptionProductOperation(
value: unknown,
): value is LocalSqliteAdoptionProductOperation {
return (
value === 'local-sqlite.adoption.inspect' ||
value === 'local-sqlite.adoption.stage' ||
value === 'local-sqlite.adoption.verify' ||
value === 'local-sqlite.activation.prepare'
);
}
export function normalizeLocalSqliteAdoptionProductCommand(
value: unknown,
): Readonly<LocalSqliteAdoptionProductCommand> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['schemaVersion', 'operation', 'options'])
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'command shape is invalid',
);
}
const candidate = value as Record<string, unknown>;
if (
candidate.schemaVersion !== 1 ||
!isLocalSqliteAdoptionProductOperation(candidate.operation) ||
!candidate.options ||
typeof candidate.options !== 'object' ||
Array.isArray(candidate.options)
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'command value is invalid',
);
}
const options = candidate.options as Record<string, unknown>;
const expected =
candidate.operation === 'local-sqlite.adoption.inspect'
? [
'deploymentRoot',
...optionalTimezone(options),
'profile',
'sourcePath',
]
: candidate.operation === 'local-sqlite.adoption.stage'
? [
'deploymentRoot',
'expectedPlanDigest',
...optionalTimezone(options),
'manifestPath',
'profile',
'recoveryPath',
'sourcePath',
'targetPath',
]
: candidate.operation === 'local-sqlite.adoption.verify'
? [
'deploymentRoot',
'manifestPath',
'profile',
'recoveryPath',
'targetPath',
]
: [
'activationPath',
'deploymentRoot',
'expectedManifestDigest',
'manifestPath',
'profile',
'recoveryPath',
'sourcePath',
'targetPath',
];
if (
!exactKeys(options, expected) ||
(options.profile !== 'edge' && options.profile !== 'standalone')
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'command options are invalid',
);
}
assertTimezone(options.legacyTimezone);
boundedPath(options.deploymentRoot, 'deploymentRoot');
for (const key of expected.filter((name) => name.endsWith('Path'))) {
boundedPath(options[key], key);
}
if (
candidate.operation === 'local-sqlite.adoption.stage' &&
(typeof options.expectedPlanDigest !== 'string' ||
!DIGEST_PATTERN.test(options.expectedPlanDigest))
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'expectedPlanDigest is invalid',
);
}
if (
candidate.operation === 'local-sqlite.activation.prepare' &&
(typeof options.expectedManifestDigest !== 'string' ||
!DIGEST_PATTERN.test(options.expectedManifestDigest))
) {
throw new LocalSqliteAdoptionCliConfigurationError(
'expectedManifestDigest is invalid',
);
}
assertDistinct(
[
options.sourcePath,
options.targetPath,
options.recoveryPath,
options.manifestPath,
options.activationPath,
].filter((entry): entry is string => typeof entry === 'string'),
);
return Object.freeze(value as LocalSqliteAdoptionProductCommand);
}