mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): rehearse legacy sqlite upgrade
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user