mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import type { ApiCredentialRepository } from '@qinglong/runtime-core/api-credential';
|
||||
import type { LocalOwnerBootstrapRepository } from '@qinglong/runtime-core/local-owner-bootstrap';
|
||||
import type { LocalOwnerCredentialRecoveryRepository } from '@qinglong/runtime-core/local-owner-credential-recovery';
|
||||
import type { LocalOwnerPepperReferenceRepository } from '@qinglong/runtime-core/local-owner-pepper';
|
||||
import {
|
||||
assertLocalSqliteOptions,
|
||||
assertLocalSqlitePathBoundary,
|
||||
LocalSqliteConfigurationError,
|
||||
openLocalSqliteClient,
|
||||
type LocalSqliteDatabaseOptions,
|
||||
type LocalSqliteProfile,
|
||||
} from './config';
|
||||
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import { LocalSqliteOwnerBootstrapRepository } from '../local-owner/ownerBootstrapRepository';
|
||||
import { LocalSqliteOwnerCredentialRecoveryRepository } from '../local-owner/ownerCredentialRecoveryRepository';
|
||||
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
|
||||
import {
|
||||
auditLocalSqliteReadiness,
|
||||
type LocalSqliteReadinessEvidence,
|
||||
} from '../readiness/readiness';
|
||||
|
||||
/**
|
||||
* A deliberately narrow and short-lived local authority. It is excluded from
|
||||
* the default runtime entry point so a long-running application cannot retain
|
||||
* owner-bootstrap power by accident.
|
||||
*/
|
||||
export interface LocalSqliteBootstrapDatabase {
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly readiness: LocalSqliteReadinessEvidence;
|
||||
readonly apiCredentials: ApiCredentialRepository;
|
||||
readonly ownerBootstrap: LocalOwnerBootstrapRepository;
|
||||
readonly ownerCredentialRecovery: LocalOwnerCredentialRecoveryRepository;
|
||||
readonly ownerPepper: LocalOwnerPepperReferenceRepository;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function openLocalSqliteBootstrapDatabase(
|
||||
options: LocalSqliteDatabaseOptions,
|
||||
): Promise<LocalSqliteBootstrapDatabase> {
|
||||
assertLocalSqliteOptions(options);
|
||||
assertLocalSqlitePathBoundary(options.databasePath, false);
|
||||
const client = openLocalSqliteClient(options, false);
|
||||
try {
|
||||
const readiness = await auditLocalSqliteReadiness(client);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
const apiCredentials = new LocalSqliteApiCredentialRepository(authority);
|
||||
const ownerBootstrap = new LocalSqliteOwnerBootstrapRepository(authority);
|
||||
const ownerCredentialRecovery =
|
||||
new LocalSqliteOwnerCredentialRecoveryRepository(authority);
|
||||
const ownerPepper = new LocalSqliteOwnerPepperRepository(authority);
|
||||
let closePromise: Promise<void> | undefined;
|
||||
return Object.freeze({
|
||||
profile: options.profile,
|
||||
readiness,
|
||||
apiCredentials,
|
||||
ownerBootstrap,
|
||||
ownerCredentialRecovery,
|
||||
ownerPepper,
|
||||
close() {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = authority.close();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isOpen) client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
LocalSqliteConfigurationError,
|
||||
type LocalSqliteDatabaseOptions,
|
||||
type LocalSqliteProfile,
|
||||
};
|
||||
export type { LocalSqliteReadinessEvidence } from '../readiness/readiness';
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
// Shared SQLite storage boundary for path, Profile and connection policy.
|
||||
export type LocalSqliteProfile = 'edge' | 'standalone';
|
||||
|
||||
export interface LocalSqliteDatabaseOptions {
|
||||
readonly databasePath: string;
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly busyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export class LocalSqliteConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_SQLITE_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Local SQLite configuration is invalid: ${message}`);
|
||||
this.name = 'LocalSqliteConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSqliteOptions(
|
||||
options: LocalSqliteDatabaseOptions,
|
||||
): void {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!path.isAbsolute(options.databasePath) ||
|
||||
options.databasePath.length > 4096 ||
|
||||
options.databasePath.includes('\0')
|
||||
) {
|
||||
throw new LocalSqliteConfigurationError(
|
||||
'databasePath must be a bounded absolute path',
|
||||
);
|
||||
}
|
||||
if (options.profile !== 'edge' && options.profile !== 'standalone') {
|
||||
throw new LocalSqliteConfigurationError(
|
||||
'profile must be edge or standalone',
|
||||
);
|
||||
}
|
||||
const busyTimeoutMs = options.busyTimeoutMs ?? 5_000;
|
||||
if (
|
||||
!Number.isSafeInteger(busyTimeoutMs) ||
|
||||
busyTimeoutMs < 100 ||
|
||||
busyTimeoutMs > 30_000
|
||||
) {
|
||||
throw new LocalSqliteConfigurationError(
|
||||
'busyTimeoutMs must be between 100 and 30000',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLocalSqlitePathBoundary(
|
||||
databasePath: string,
|
||||
allowMissing: boolean,
|
||||
): void {
|
||||
const parent = fs.lstatSync(path.dirname(databasePath));
|
||||
if (!parent.isDirectory() || parent.isSymbolicLink()) {
|
||||
throw new LocalSqliteConfigurationError(
|
||||
'database parent must be a real directory',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const target = fs.lstatSync(databasePath);
|
||||
if (!target.isFile() || target.isSymbolicLink()) {
|
||||
throw new LocalSqliteConfigurationError(
|
||||
'database target must be a regular file',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
allowMissing &&
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedJournalMode(profile: LocalSqliteProfile): 'delete' | 'wal' {
|
||||
return profile === 'edge' ? 'delete' : 'wal';
|
||||
}
|
||||
|
||||
/** Opens one bounded local authority; callers own and must close the handle. */
|
||||
export function openLocalSqliteClient(
|
||||
options: LocalSqliteDatabaseOptions,
|
||||
readOnly: boolean,
|
||||
): DatabaseSync {
|
||||
const busyTimeoutMs = options.busyTimeoutMs ?? 5_000;
|
||||
const client = new DatabaseSync(options.databasePath, {
|
||||
allowExtension: false,
|
||||
defensive: true,
|
||||
enableDoubleQuotedStringLiterals: false,
|
||||
enableForeignKeyConstraints: true,
|
||||
readOnly,
|
||||
timeout: busyTimeoutMs,
|
||||
});
|
||||
try {
|
||||
client.enableDefensive(true);
|
||||
client.exec('PRAGMA trusted_schema = OFF');
|
||||
client.exec('PRAGMA recursive_triggers = OFF');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
if (!readOnly) {
|
||||
const expected = expectedJournalMode(options.profile);
|
||||
const journal = client
|
||||
.prepare(`PRAGMA journal_mode = ${expected.toUpperCase()}`)
|
||||
.get() as { journal_mode?: unknown } | undefined;
|
||||
if (journal?.journal_mode !== expected) {
|
||||
throw new LocalSqliteConfigurationError(
|
||||
`${options.profile} database does not support ${expected} journal mode`,
|
||||
);
|
||||
}
|
||||
client.exec('PRAGMA synchronous = FULL');
|
||||
if (options.profile === 'standalone') {
|
||||
client.exec('PRAGMA wal_autocheckpoint = 1000');
|
||||
}
|
||||
client.exec(
|
||||
`PRAGMA journal_size_limit = ${
|
||||
options.profile === 'edge' ? 8 * 1024 * 1024 : 64 * 1024 * 1024
|
||||
}`,
|
||||
);
|
||||
client.exec(
|
||||
`PRAGMA cache_size = ${options.profile === 'edge' ? -4096 : -16384}`,
|
||||
);
|
||||
client.exec(
|
||||
`PRAGMA mmap_size = ${
|
||||
options.profile === 'edge' ? 0 : 64 * 1024 * 1024
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
client.exec('PRAGMA query_only = ON');
|
||||
}
|
||||
return client;
|
||||
} catch (error) {
|
||||
client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Storage compatibility entrypoint for development tooling. Deployment Profile
|
||||
* packages must import /runtime or /migration explicitly so executable DDL is
|
||||
* not pulled into a long-lived process by accident.
|
||||
*/
|
||||
export * from '../runtime/runtimeDatabase';
|
||||
export {
|
||||
localSqliteMigrationDefinition,
|
||||
localSqliteMigrationManifest,
|
||||
migrateLocalSqliteDatabase,
|
||||
migrateLocalSqlitePath,
|
||||
type LocalSqliteMigrationResult,
|
||||
} from '../migration/migration';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
export type SqliteQueryRow = Record<string, unknown>;
|
||||
|
||||
export type SqliteQueryValue = string | number | bigint | Uint8Array | null;
|
||||
|
||||
export interface SqlitePersistenceErrorContract {
|
||||
readonly invalidRowValue: (property: string) => Error;
|
||||
readonly invalidJson: (property: string) => Error;
|
||||
readonly unsupportedRowValue: (property: string) => Error;
|
||||
readonly duplicateIdentityRows: () => Error;
|
||||
readonly mapDriverError: (error: unknown) => Error;
|
||||
}
|
||||
|
||||
export interface SqlitePersistencePrimitives {
|
||||
readonly requiredString: (row: SqliteQueryRow, property: string) => string;
|
||||
readonly optionalString: (
|
||||
row: SqliteQueryRow,
|
||||
property: string,
|
||||
) => string | undefined;
|
||||
readonly requiredInteger: (row: SqliteQueryRow, property: string) => number;
|
||||
readonly requiredBlob: (row: SqliteQueryRow, property: string) => Buffer;
|
||||
readonly optionalInteger: (
|
||||
row: SqliteQueryRow,
|
||||
property: string,
|
||||
) => number | undefined;
|
||||
readonly requiredBoolean: (row: SqliteQueryRow, property: string) => boolean;
|
||||
readonly requiredJson: (row: SqliteQueryRow, property: string) => unknown;
|
||||
readonly requiredEnum: <T extends string>(
|
||||
row: SqliteQueryRow,
|
||||
property: string,
|
||||
allowed: readonly T[],
|
||||
) => T;
|
||||
readonly queryRows: (
|
||||
client: DatabaseSync,
|
||||
sql: string,
|
||||
values?: readonly SqliteQueryValue[],
|
||||
) => SqliteQueryRow[];
|
||||
readonly singleRow: (rows: SqliteQueryRow[]) => SqliteQueryRow | null;
|
||||
}
|
||||
|
||||
export function sqliteDriverErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const value = (error as { code?: unknown }).code;
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
export function sqliteDriverErrorNumber(error: unknown): number | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const value = (error as { errcode?: unknown }).errcode;
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
||||
export function sqliteDriverErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : '';
|
||||
}
|
||||
|
||||
export function isSqliteDriverError(error: unknown): boolean {
|
||||
return (
|
||||
sqliteDriverErrorNumber(error) !== undefined ||
|
||||
sqliteDriverErrorCode(error)?.startsWith('ERR_SQLITE_') === true
|
||||
);
|
||||
}
|
||||
|
||||
export function createSqlitePersistencePrimitives(
|
||||
errors: SqlitePersistenceErrorContract,
|
||||
): SqlitePersistencePrimitives {
|
||||
function requiredString(row: SqliteQueryRow, property: string): string {
|
||||
const value = row[property];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw errors.invalidRowValue(property);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(
|
||||
row: SqliteQueryRow,
|
||||
property: string,
|
||||
): string | undefined {
|
||||
const value = row[property];
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value !== 'string') {
|
||||
throw errors.invalidRowValue(property);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredInteger(row: SqliteQueryRow, property: string): number {
|
||||
const value = row[property];
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value)) return value;
|
||||
throw errors.invalidRowValue(property);
|
||||
}
|
||||
|
||||
function requiredBlob(row: SqliteQueryRow, property: string): Buffer {
|
||||
const value = row[property];
|
||||
if (!(value instanceof Uint8Array)) {
|
||||
throw errors.invalidRowValue(property);
|
||||
}
|
||||
return Buffer.from(value);
|
||||
}
|
||||
|
||||
function optionalInteger(
|
||||
row: SqliteQueryRow,
|
||||
property: string,
|
||||
): number | undefined {
|
||||
if (row[property] === null || row[property] === undefined) return undefined;
|
||||
return requiredInteger(row, property);
|
||||
}
|
||||
|
||||
function requiredBoolean(row: SqliteQueryRow, property: string): boolean {
|
||||
const value = row[property];
|
||||
if (value === 0) return false;
|
||||
if (value === 1) return true;
|
||||
throw errors.invalidRowValue(property);
|
||||
}
|
||||
|
||||
function requiredJson(row: SqliteQueryRow, property: string): unknown {
|
||||
const value = requiredString(row, property);
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
throw errors.invalidJson(property);
|
||||
}
|
||||
}
|
||||
|
||||
function requiredEnum<T extends string>(
|
||||
row: SqliteQueryRow,
|
||||
property: string,
|
||||
allowed: readonly T[],
|
||||
): T {
|
||||
const value = requiredString(row, property);
|
||||
if (!allowed.includes(value as T)) {
|
||||
throw errors.unsupportedRowValue(property);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function queryRows(
|
||||
client: DatabaseSync,
|
||||
sql: string,
|
||||
values: readonly SqliteQueryValue[] = [],
|
||||
): SqliteQueryRow[] {
|
||||
try {
|
||||
return client.prepare(sql).all(...values) as unknown as SqliteQueryRow[];
|
||||
} catch (error) {
|
||||
throw errors.mapDriverError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function singleRow(rows: SqliteQueryRow[]): SqliteQueryRow | null {
|
||||
const [row] = rows;
|
||||
if (!row) return null;
|
||||
if (rows.length !== 1) throw errors.duplicateIdentityRows();
|
||||
return row;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
requiredString,
|
||||
optionalString,
|
||||
requiredInteger,
|
||||
requiredBlob,
|
||||
optionalInteger,
|
||||
requiredBoolean,
|
||||
requiredJson,
|
||||
requiredEnum,
|
||||
queryRows,
|
||||
singleRow,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user