feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,252 @@
import {
normalizeLocalExecutionContextRecipe,
normalizeLocalTaskExecutionRevision,
type LocalExecutionContextRecipe,
type LocalTaskExecutionRevision,
} from '@qinglong/runtime-core/local-dispatch';
import type { LocalCommandTaskExecutionPlan } from '@qinglong/runtime-core/task-definition-execution-compiler';
import type { DatabaseSync } from 'node:sqlite';
type Row = Record<string, unknown>;
export class LocalSqliteDispatchDefinitionConflictError extends Error {
constructor() {
super('Local SQLite dispatch definition identity already has other content');
this.name = 'LocalSqliteDispatchDefinitionConflictError';
}
}
function sqliteConstraint(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
if (
'code' in error &&
typeof error.code === 'string' &&
error.code.startsWith('SQLITE_CONSTRAINT')
) {
return true;
}
return (
'errcode' in error &&
Number.isSafeInteger(error.errcode) &&
((error.errcode as number) & 0xff) === 19
);
}
function requiredString(row: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string') {
throw new TypeError(`Stored local dispatch ${key} is invalid`);
}
return value;
}
function optionalString(row: Row, key: string): string | undefined {
const value = row[key];
if (value === null) return undefined;
return requiredString(row, key);
}
function requiredInteger(row: Row, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value)) {
throw new TypeError(`Stored local dispatch ${key} is invalid`);
}
return value as number;
}
function optionalInteger(row: Row, key: string): number | undefined {
const value = row[key];
if (value === null) return undefined;
return requiredInteger(row, key);
}
function requiredJson(row: Row, key: string): unknown {
try {
return JSON.parse(requiredString(row, key)) as unknown;
} catch {
throw new TypeError(`Stored local dispatch ${key} is invalid`);
}
}
function sameRecipeContent(
left: LocalExecutionContextRecipe,
right: LocalExecutionContextRecipe,
): boolean {
return (
left.contextRef === right.contextRef &&
left.contentDigest === right.contentDigest &&
JSON.stringify(left.environment) === JSON.stringify(right.environment)
);
}
function sameRevisionContent(
left: LocalTaskExecutionRevision,
right: LocalTaskExecutionRevision,
): boolean {
return (
left.projectId === right.projectId &&
left.taskId === right.taskId &&
left.taskRevision === right.taskRevision &&
left.contentDigest === right.contentDigest
);
}
/**
* Synchronous transaction-scoped storage primitive. Public repositories own
* admission/error mapping; this class never opens a transaction or queue.
*/
export class LocalSqliteDispatchDefinitionStore {
constructor(private readonly client: DatabaseSync) {}
resolveRecipe(contextRef: string): LocalExecutionContextRecipe | null {
const row = this.client
.prepare(
`SELECT "context_ref" AS "contextRef",
"environment_json" AS "environmentJson",
"content_digest" AS "contentDigest",
"created_at_ms" AS "createdAtMs"
FROM "QingLong3LocalExecutionContextRecipes"
WHERE "context_ref" = ?`,
)
.get(contextRef) as Row | undefined;
if (!row) return null;
return normalizeLocalExecutionContextRecipe({
contextRef: requiredString(row, 'contextRef'),
environment: requiredJson(
row,
'environmentJson',
) as LocalExecutionContextRecipe['environment'],
contentDigest: requiredString(row, 'contentDigest'),
createdAtMs: requiredInteger(row, 'createdAtMs'),
});
}
resolveRevision(identity: {
readonly projectId: string;
readonly taskId: string;
readonly taskRevision: string;
}): LocalTaskExecutionRevision | null {
const row = this.client
.prepare(
`SELECT "project_id" AS "projectId", "task_id" AS "taskId",
"task_revision" AS "taskRevision",
"executor_type" AS "executorType",
"command_json" AS "commandJson",
"working_directory" AS "workingDirectory",
"timeout_ms" AS "timeoutMs", "context_ref" AS "contextRef",
"content_digest" AS "contentDigest",
"created_at_ms" AS "createdAtMs"
FROM "QingLong3LocalTaskExecutionRevisions"
WHERE "project_id" = ? AND "task_id" = ? AND "task_revision" = ?`,
)
.get(identity.projectId, identity.taskId, identity.taskRevision) as
| Row
| undefined;
if (!row) return null;
const executorType = requiredString(row, 'executorType');
if (executorType !== 'local_process') {
throw new TypeError('Stored local dispatch executorType is invalid');
}
const workingDirectory = optionalString(row, 'workingDirectory');
const timeoutMs = optionalInteger(row, 'timeoutMs');
return normalizeLocalTaskExecutionRevision({
projectId: requiredString(row, 'projectId'),
taskId: requiredString(row, 'taskId'),
taskRevision: requiredString(row, 'taskRevision'),
executorType,
command: requiredJson(
row,
'commandJson',
) as LocalTaskExecutionRevision['command'],
...(workingDirectory === undefined ? {} : { workingDirectory }),
...(timeoutMs === undefined ? {} : { timeoutMs }),
contextRef: requiredString(row, 'contextRef'),
contentDigest: requiredString(row, 'contentDigest'),
createdAtMs: requiredInteger(row, 'createdAtMs'),
});
}
appendRecipe(
value: LocalExecutionContextRecipe,
): 'inserted' | 'existing' {
const recipe = normalizeLocalExecutionContextRecipe(value);
try {
this.client
.prepare(
`INSERT INTO "QingLong3LocalExecutionContextRecipes" (
"context_ref", "environment_json", "content_digest", "created_at_ms"
) VALUES (?, ?, ?, ?)`,
)
.run(
recipe.contextRef,
JSON.stringify(recipe.environment),
recipe.contentDigest,
recipe.createdAtMs,
);
return 'inserted';
} catch (error) {
if (!sqliteConstraint(error)) throw error;
const existing = this.resolveRecipe(recipe.contextRef);
if (existing && sameRecipeContent(existing, recipe)) return 'existing';
throw new LocalSqliteDispatchDefinitionConflictError();
}
}
appendRevision(
value: LocalTaskExecutionRevision,
): 'inserted' | 'existing' {
const revision = normalizeLocalTaskExecutionRevision(value);
try {
this.client
.prepare(
`INSERT INTO "QingLong3LocalTaskExecutionRevisions" (
"project_id", "task_id", "task_revision", "executor_type",
"command_json", "working_directory", "timeout_ms",
"context_ref", "content_digest", "created_at_ms"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
revision.projectId,
revision.taskId,
revision.taskRevision,
revision.executorType,
JSON.stringify(revision.command),
revision.workingDirectory ?? null,
revision.timeoutMs ?? null,
revision.contextRef,
revision.contentDigest,
revision.createdAtMs,
);
return 'inserted';
} catch (error) {
if (!sqliteConstraint(error)) throw error;
const existing = this.resolveRevision(revision);
if (existing && sameRevisionContent(existing, revision)) {
return 'existing';
}
throw new LocalSqliteDispatchDefinitionConflictError();
}
}
appendPlan(plan: LocalCommandTaskExecutionPlan): Readonly<{
recipe: 'inserted' | 'existing';
revision: 'inserted' | 'existing';
}> {
const recipe = normalizeLocalExecutionContextRecipe(plan.contextRecipe);
const revision = normalizeLocalTaskExecutionRevision(
plan.executionRevision,
);
if (
revision.projectId !== plan.source.projectId ||
revision.taskId !== plan.source.taskId ||
revision.taskRevision !== plan.source.taskRevision ||
revision.contextRef !== recipe.contextRef
) {
throw new TypeError('Local command execution plan identities do not match');
}
return Object.freeze({
recipe: this.appendRecipe(recipe),
revision: this.appendRevision(revision),
});
}
}
@@ -0,0 +1,261 @@
import type { ApiCredentialRepository } from '@qinglong/runtime-core/api-credential';
import type { LocalOwnerPepperRepository } from '@qinglong/runtime-core/local-owner-pepper';
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
import type { SecuritySubject } from '@qinglong/runtime-core/security';
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
import type { TaskDefinitionSource } from '@qinglong/runtime-core/task-definition';
import {
TaskDefinitionAdministrationAuthorizationFenceConflictError,
TaskDefinitionAdministrationMutationConflictError,
normalizeAuthorizedTaskDefinitionRevisionMutation,
type AuthorizedTaskDefinitionRevisionMutation,
type TaskDefinitionAdministrationRepository,
} from '@qinglong/runtime-core/task-definition-administration';
import { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
import {
assertLocalSqliteOptions,
assertLocalSqlitePathBoundary,
openLocalSqliteClient,
type LocalSqliteDatabaseOptions,
type LocalSqliteProfile,
} from '../storage/config';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
import { LocalSqliteOwnerPepperRepository } from '../local-owner/ownerPepperRepository';
import {
confirmLocalSqliteAuthenticatedUserCredentialFence,
LocalSqliteAuthenticatedManagementFenceError,
type LocalSqliteAuthenticatedUserCredentialFence,
} from '../administration/packageManagement';
import {
auditLocalSqliteReadiness,
type LocalSqliteReadinessEvidence,
} from '../readiness/readiness';
import {
LOCAL_ROLE_BINDING_SELECT,
insertLocalSecurityAudit,
localRoleBindingFromRow,
localSecurityAuditFromRow,
sameSecurityAuditSemantic,
} from '../security/securityPersistence';
import { LocalSqliteSecurityAuthorityStore } from '../security/securityAuthorityStore';
import { LocalSqliteTaskDefinitionRepository } from './taskDefinitionRepository';
type Row = Record<string, unknown>;
const AUDIT_SELECT = `
"event_id" AS "eventId",
"request_id" AS "requestId",
"operation_id" AS "operationId",
"project_id" AS "auditProjectId",
"subject_type" AS "subjectType",
"subject_id" AS "subjectId",
"authentication_id" AS "authenticationId",
"outcome" AS "outcome",
"reasons_json" AS "reasonsJson",
"fence_project_version" AS "fenceProjectVersion",
"fence_binding_version" AS "fenceBindingVersion",
"occurred_at_ms" AS "occurredAtMs"`;
export interface LocalSqliteTaskDefinitionAdministrationDatabase {
readonly profile: LocalSqliteProfile;
readonly readiness: LocalSqliteReadinessEvidence;
readonly apiCredentials: ApiCredentialRepository;
readonly ownerPepper: Pick<LocalOwnerPepperRepository, 'resolveKey'>;
readonly projectPolicy: ProjectPolicyRepository;
readonly taskDefinitions: TaskDefinitionSource;
readonly taskDefinitionAdministration: TaskDefinitionAdministrationRepository;
readonly securityAudit: SecurityAuditSink;
activateUserCredentialFence(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
): void;
close(): Promise<void>;
}
function integer(row: Row | undefined, key: string): number {
const value = row?.[key];
if (!Number.isSafeInteger(value)) {
throw new TaskDefinitionAdministrationAuthorizationFenceConflictError();
}
return value as number;
}
function sameCredentialFence(
left: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
right: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
): boolean {
return (
left.credentialId === right.credentialId &&
left.credentialVersion === right.credentialVersion &&
left.pepperKeyId === right.pepperKeyId &&
left.materialDigest === right.materialDigest &&
left.subjectType === right.subjectType &&
left.subjectId === right.subjectId &&
left.secretDigest === right.secretDigest &&
left.notBeforeAtMs === right.notBeforeAtMs &&
left.expiresAtMs === right.expiresAtMs
);
}
class LocalSqliteTaskDefinitionAdministrationRepository
implements TaskDefinitionAdministrationRepository
{
constructor(
private readonly authority: LocalSqliteOperationAuthority,
private readonly taskDefinitions: LocalSqliteTaskDefinitionRepository,
private readonly beforeMutation: (actor: Readonly<SecuritySubject>) => void,
) {}
private confirmAuthorization(
mutation: Readonly<AuthorizedTaskDefinitionRevisionMutation>,
): void {
this.beforeMutation(mutation.actor);
const client = this.authority.client;
const project = client
.prepare(
`SELECT "status" AS "status", "version" AS "version"
FROM "QingLong3Projects" WHERE "id" = ?`,
)
.get(mutation.command.projectId) as Row | undefined;
const bindingRow = client
.prepare(
`SELECT ${LOCAL_ROLE_BINDING_SELECT}
FROM "QingLong3ProjectRoleBindings"
WHERE "project_id" = ? AND "subject_type" = ?
AND "subject_id" = ?
ORDER BY "version" DESC LIMIT 1`,
)
.get(
mutation.command.projectId,
mutation.actor.type,
mutation.actor.id,
) as Row | undefined;
if (
!project ||
project.status !== 'active' ||
integer(project, 'version') !== mutation.fence.projectVersion ||
!bindingRow
) {
throw new TaskDefinitionAdministrationAuthorizationFenceConflictError();
}
const binding = localRoleBindingFromRow(bindingRow);
if (
binding.version !== mutation.fence.bindingVersion ||
binding.state !== 'active'
) {
throw new TaskDefinitionAdministrationAuthorizationFenceConflictError();
}
}
appendAuthorizedTaskDefinitionRevision(
input: AuthorizedTaskDefinitionRevisionMutation,
) {
const mutation = normalizeAuthorizedTaskDefinitionRevisionMutation(input);
return this.taskDefinitions.appendTaskDefinitionRevision(
mutation.command,
({ replay }) => {
this.confirmAuthorization(mutation);
const auditRow = this.authority.client
.prepare(
`SELECT ${AUDIT_SELECT}
FROM "QingLong3SecurityAuditEvents"
WHERE "event_id" = ?`,
)
.get(mutation.audit.eventId) as Row | undefined;
if (replay) {
if (
!auditRow ||
!sameSecurityAuditSemantic(
localSecurityAuditFromRow(auditRow),
mutation.audit,
)
) {
throw new TaskDefinitionAdministrationMutationConflictError();
}
return;
}
if (auditRow) {
throw new TaskDefinitionAdministrationMutationConflictError();
}
insertLocalSecurityAudit(this.authority.client, mutation.audit);
},
);
}
}
export async function openLocalSqliteTaskDefinitionAdministrationDatabase(
options: LocalSqliteDatabaseOptions,
): Promise<LocalSqliteTaskDefinitionAdministrationDatabase> {
assertLocalSqliteOptions(options);
assertLocalSqlitePathBoundary(options.databasePath, false);
const client = openLocalSqliteClient(options, false);
try {
const readiness = await auditLocalSqliteReadiness(client);
const authority = new LocalSqliteOperationAuthority(client);
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
const taskRepository = new LocalSqliteTaskDefinitionRepository(authority);
let activeFence:
| Readonly<LocalSqliteAuthenticatedUserCredentialFence>
| undefined;
const projectPolicy: ProjectPolicyRepository = Object.freeze({
resolve: (
...[projectId, subject]: Parameters<ProjectPolicyRepository['resolve']>
) => securityAuthority.resolve(projectId, subject),
append: (...[command]: Parameters<ProjectPolicyRepository['append']>) =>
securityAuthority.append(command),
});
const taskDefinitionAdministration =
new LocalSqliteTaskDefinitionAdministrationRepository(
authority,
taskRepository,
(actor) => {
if (
!activeFence ||
actor.type !== activeFence.subjectType ||
actor.id !== activeFence.subjectId
) {
throw new LocalSqliteAuthenticatedManagementFenceError();
}
confirmLocalSqliteAuthenticatedUserCredentialFence(
authority,
activeFence,
);
},
);
let closePromise: Promise<void> | undefined;
return Object.freeze({
profile: options.profile,
readiness,
apiCredentials: new LocalSqliteApiCredentialRepository(authority),
ownerPepper: new LocalSqliteOwnerPepperRepository(authority),
projectPolicy,
taskDefinitions: Object.freeze({
findCurrentTaskDefinition:
taskRepository.findCurrentTaskDefinition.bind(taskRepository),
findTaskDefinitionRevision:
taskRepository.findTaskDefinitionRevision.bind(taskRepository),
listTaskDefinitions:
taskRepository.listTaskDefinitions.bind(taskRepository),
}),
taskDefinitionAdministration,
securityAudit: securityAuthority,
activateUserCredentialFence(
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
) {
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
if (activeFence && !sameCredentialFence(activeFence, fence)) {
throw new LocalSqliteAuthenticatedManagementFenceError();
}
activeFence = Object.freeze({ ...fence });
},
close() {
if (closePromise) return closePromise;
closePromise = authority.close();
return closePromise;
},
});
} catch (error) {
if (client.isOpen) client.close();
throw error;
}
}
@@ -0,0 +1,549 @@
import {
InvalidTaskDefinitionError,
TaskDefinitionConflictError,
TaskDefinitionUnavailableError,
assertTaskDefinitionIdentifier,
assertTaskDefinitionPageSize,
assertTaskDefinitionRevision,
createTaskDefinitionRecord,
normalizeAppendTaskDefinitionRevisionCommand,
normalizeTaskDefinitionCursor,
normalizeTaskDefinitionRecord,
type AppendTaskDefinitionRevisionCommand,
type TaskDefinitionPage,
type TaskDefinitionRecord,
type TaskDefinitionRepository,
} from '@qinglong/runtime-core/task-definition';
import type { LocalCommandTaskExecutionPlan } from '@qinglong/runtime-core/task-definition-execution-compiler';
import {
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
TaskSpecSemanticRegistry,
createBuiltInTaskSpecSemanticRegistry,
} from '@qinglong/runtime-core/task-spec-semantic';
import type { DatabaseSync } from 'node:sqlite';
import {
LocalSqliteDispatchDefinitionConflictError,
LocalSqliteDispatchDefinitionStore,
} from './dispatchDefinitionStore';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
type TaskDefinitionRow = Record<string, unknown>;
type LocalExecutionCompiler = (
definition: TaskDefinitionRecord,
semanticRegistry: TaskSpecSemanticRegistry,
) => LocalCommandTaskExecutionPlan;
export interface LocalSqliteTaskDefinitionTransactionContext {
readonly command: Readonly<AppendTaskDefinitionRevisionCommand>;
readonly replay: Readonly<TaskDefinitionRecord> | null;
}
export type LocalSqliteTaskDefinitionTransactionHook = (
context: Readonly<LocalSqliteTaskDefinitionTransactionContext>,
) => void;
const SELECT_FIELDS = `
head."project_id" AS "projectId",
head."task_id" AS "taskId",
revision."revision" AS "revision",
revision."mutation_id" AS "mutationId",
revision."name" AS "name",
revision."description" AS "description",
revision."kind" AS "kind",
revision."spec_json" AS "specJson",
revision."labels_json" AS "labelsJson",
revision."enabled" AS "enabled",
revision."content_digest" AS "contentDigest",
head."created_at_ms" AS "createdAtMs",
revision."created_at_ms" AS "updatedAtMs"`;
function text(row: TaskDefinitionRow, key: string): string {
const value = row[key];
if (typeof value !== 'string') throw new TaskDefinitionUnavailableError();
return value;
}
function nullableText(row: TaskDefinitionRow, key: string): string | undefined {
const value = row[key];
if (value === null) return undefined;
if (typeof value !== 'string') throw new TaskDefinitionUnavailableError();
return value;
}
function integer(row: TaskDefinitionRow, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value)) {
throw new TaskDefinitionUnavailableError();
}
return value as number;
}
function parseJson(row: TaskDefinitionRow, key: string): unknown {
try {
return JSON.parse(text(row, key));
} catch {
throw new TaskDefinitionUnavailableError();
}
}
function record(row: TaskDefinitionRow): TaskDefinitionRecord {
try {
return normalizeTaskDefinitionRecord({
projectId: text(row, 'projectId'),
taskId: text(row, 'taskId'),
revision: integer(row, 'revision'),
mutationId: text(row, 'mutationId'),
name: text(row, 'name'),
...(row.description === null
? {}
: { description: nullableText(row, 'description') as string }),
kind: text(row, 'kind') as TaskDefinitionRecord['kind'],
spec: parseJson(row, 'specJson') as TaskDefinitionRecord['spec'],
labels: parseJson(row, 'labelsJson') as TaskDefinitionRecord['labels'],
enabled: integer(row, 'enabled') === 1,
contentDigest: text(row, 'contentDigest'),
createdAtMs: integer(row, 'createdAtMs'),
updatedAtMs: integer(row, 'updatedAtMs'),
});
} catch (error) {
if (error instanceof TaskDefinitionUnavailableError) throw error;
throw new TaskDefinitionUnavailableError();
}
}
function sameRecord(left: TaskDefinitionRecord, right: TaskDefinitionRecord) {
return JSON.stringify(left) === JSON.stringify(right);
}
function mapStorageError(error: unknown): Error {
if (
error instanceof TaskDefinitionConflictError ||
error instanceof TaskDefinitionUnavailableError
) {
return error;
}
if (error instanceof LocalSqliteDispatchDefinitionConflictError) {
return new TaskDefinitionConflictError();
}
if (
error &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string' &&
error.code.startsWith('SQLITE_CONSTRAINT')
) {
return new TaskDefinitionConflictError();
}
return new TaskDefinitionUnavailableError();
}
export class LocalSqliteTaskDefinitionRepository
implements TaskDefinitionRepository
{
private readonly authority: LocalSqliteOperationAuthority;
private readonly dispatchDefinitions: LocalSqliteDispatchDefinitionStore;
private readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
constructor(
authority: LocalSqliteOperationAuthority | DatabaseSync,
taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry(),
) {
this.authority =
authority instanceof LocalSqliteOperationAuthority
? authority
: new LocalSqliteOperationAuthority(authority);
this.dispatchDefinitions = new LocalSqliteDispatchDefinitionStore(
this.authority.client,
);
this.taskSpecSemanticRegistry = taskSpecSemanticRegistry;
}
private localExecutionPlan(
definition: TaskDefinitionRecord,
compiler: LocalExecutionCompiler | null,
): LocalCommandTaskExecutionPlan | null {
if (
!definition.enabled ||
definition.kind !== 'command' ||
definition.spec.schema !== BUILT_IN_COMMAND_TASK_SPEC_SCHEMA
) {
return null;
}
if (!compiler) throw new TaskDefinitionUnavailableError();
return compiler(definition, this.taskSpecSemanticRegistry);
}
private assertLocalExecutionPlanPublished(
plan: LocalCommandTaskExecutionPlan,
): void {
const recipe = this.dispatchDefinitions.resolveRecipe(
plan.contextRecipe.contextRef,
);
const revision = this.dispatchDefinitions.resolveRevision({
projectId: plan.executionRevision.projectId,
taskId: plan.executionRevision.taskId,
taskRevision: plan.executionRevision.taskRevision,
});
if (
!recipe ||
!revision ||
recipe.contentDigest !== plan.contextRecipe.contentDigest ||
revision.contentDigest !== plan.executionRevision.contentDigest
) {
throw new TaskDefinitionUnavailableError();
}
}
private enqueue<T>(work: () => T | Promise<T>): Promise<T> {
return this.authority.enqueue(
async () => {
try {
return await work();
} catch (error) {
throw mapStorageError(error);
}
},
() => new TaskDefinitionUnavailableError(),
);
}
private findCurrent(
projectId: string,
taskId: string,
): TaskDefinitionRecord | null {
const row = this.authority.client
.prepare(
`SELECT ${SELECT_FIELDS}
FROM "QingLong3TaskDefinitions" AS head
JOIN "QingLong3TaskDefinitionRevisions" AS revision
ON revision."project_id" = head."project_id"
AND revision."task_id" = head."task_id"
AND revision."revision" = head."current_revision"
WHERE head."project_id" = ? AND head."task_id" = ?`,
)
.get(projectId, taskId) as TaskDefinitionRow | undefined;
return row ? record(row) : null;
}
private findRevision(
projectId: string,
taskId: string,
revision: number,
): TaskDefinitionRecord | null {
const row = this.authority.client
.prepare(
`SELECT ${SELECT_FIELDS}
FROM "QingLong3TaskDefinitions" AS head
JOIN "QingLong3TaskDefinitionRevisions" AS revision
ON revision."project_id" = head."project_id"
AND revision."task_id" = head."task_id"
WHERE head."project_id" = ? AND head."task_id" = ?
AND revision."revision" = ?`,
)
.get(projectId, taskId, revision) as TaskDefinitionRow | undefined;
return row ? record(row) : null;
}
private findByMutation(mutationId: string): TaskDefinitionRecord | null {
const row = this.authority.client
.prepare(
`SELECT ${SELECT_FIELDS}
FROM "QingLong3TaskDefinitionRevisions" AS revision
JOIN "QingLong3TaskDefinitions" AS head
ON head."project_id" = revision."project_id"
AND head."task_id" = revision."task_id"
WHERE revision."mutation_id" = ?`,
)
.get(mutationId) as TaskDefinitionRow | undefined;
return row ? record(row) : null;
}
findCurrentTaskDefinition(
projectId: string,
taskId: string,
): Promise<TaskDefinitionRecord | null> {
assertTaskDefinitionIdentifier(projectId, 'projectId');
assertTaskDefinitionIdentifier(taskId, 'taskId');
return this.enqueue(() => this.findCurrent(projectId, taskId));
}
findTaskDefinitionRevision(
projectId: string,
taskId: string,
revision: number,
): Promise<TaskDefinitionRecord | null> {
assertTaskDefinitionIdentifier(projectId, 'projectId');
assertTaskDefinitionIdentifier(taskId, 'taskId');
assertTaskDefinitionRevision(revision);
return this.enqueue(() => this.findRevision(projectId, taskId, revision));
}
listTaskDefinitions(options: {
readonly projectId: string;
readonly limit: number;
readonly after?: { readonly taskId: string };
}): Promise<TaskDefinitionPage> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new InvalidTaskDefinitionError('list options are invalid');
}
const keys = Object.keys(options).sort();
if (
!keys.includes('limit') ||
!keys.includes('projectId') ||
keys.some((key) => !['after', 'limit', 'projectId'].includes(key))
) {
throw new InvalidTaskDefinitionError(
'list options have an invalid shape',
);
}
assertTaskDefinitionIdentifier(options.projectId, 'projectId');
assertTaskDefinitionPageSize(options.limit);
const after = options.after
? normalizeTaskDefinitionCursor(options.after)
: undefined;
return this.enqueue(() => {
const rows = this.authority.client
.prepare(
`SELECT ${SELECT_FIELDS}
FROM "QingLong3TaskDefinitions" AS head
JOIN "QingLong3TaskDefinitionRevisions" AS revision
ON revision."project_id" = head."project_id"
AND revision."task_id" = head."task_id"
AND revision."revision" = head."current_revision"
WHERE head."project_id" = ? AND head."task_id" > ?
ORDER BY head."task_id"
LIMIT ?`,
)
.all(options.projectId, after?.taskId ?? '', options.limit + 1) as
| TaskDefinitionRow[]
| undefined;
if (!Array.isArray(rows)) throw new TaskDefinitionUnavailableError();
const truncated = rows.length > options.limit;
const definitions = Object.freeze(
rows.slice(0, options.limit).map(record),
);
const last = definitions.at(-1);
return Object.freeze({
definitions,
truncated,
...(truncated && last
? { next: Object.freeze({ taskId: last.taskId }) }
: {}),
});
});
}
appendTaskDefinitionRevision(
input: AppendTaskDefinitionRevisionCommand,
transactionHook?: LocalSqliteTaskDefinitionTransactionHook,
): Promise<
Readonly<{
status: 'created' | 'updated' | 'existing';
definition: TaskDefinitionRecord;
}>
> {
if (
transactionHook !== undefined &&
typeof transactionHook !== 'function'
) {
throw new InvalidTaskDefinitionError('transaction hook is invalid');
}
const normalized = normalizeAppendTaskDefinitionRevisionCommand(input);
const command = Object.freeze({
...normalized,
spec: this.taskSpecSemanticRegistry.normalize({
projectId: normalized.projectId,
taskId: normalized.taskId,
kind: normalized.kind,
spec: normalized.spec,
}),
});
const needsLocalExecutionCompiler =
command.enabled &&
command.kind === 'command' &&
command.spec.schema === BUILT_IN_COMMAND_TASK_SPEC_SCHEMA;
const compiler = needsLocalExecutionCompiler
? import(
'@qinglong/runtime-core/task-definition-execution-compiler'
).then(
({ compileLocalCommandTaskDefinition }) =>
compileLocalCommandTaskDefinition,
() => {
throw new TaskDefinitionUnavailableError();
},
)
: Promise.resolve(null);
return compiler.then((compileLocalExecution) =>
this.authority.enqueue(
async () => {
const client = this.authority.client;
let transactionHookError: unknown;
try {
client.exec('BEGIN IMMEDIATE');
const ownership = client
.prepare(
`SELECT package_name AS "packageName"
FROM "QingLong3PluginPackageTaskOwnerships"
WHERE project_id = ? AND task_id = ?`,
)
.get(command.projectId, command.taskId);
if (ownership) throw new TaskDefinitionConflictError();
const replay = this.findByMutation(command.mutationId);
if (transactionHook) {
let hookResult: void;
try {
hookResult = transactionHook(
Object.freeze({ command, replay }),
);
} catch (error) {
transactionHookError = error;
throw error;
}
if (hookResult !== undefined) {
throw new InvalidTaskDefinitionError(
'transaction hook must not return a value',
);
}
}
if (replay) {
const expected = createTaskDefinitionRecord(
command,
replay.createdAtMs,
);
if (!sameRecord(replay, expected)) {
throw new TaskDefinitionConflictError();
}
const replayPlan = this.localExecutionPlan(
replay,
compileLocalExecution,
);
if (replayPlan)
this.assertLocalExecutionPlanPublished(replayPlan);
client.exec('COMMIT');
return Object.freeze({
status: 'existing' as const,
definition: replay,
});
}
const project = client
.prepare(
`SELECT "status" AS "status"
FROM "QingLong3Projects" WHERE "id" = ?`,
)
.get(command.projectId) as { status?: unknown } | undefined;
if (project?.status !== 'active') {
throw new TaskDefinitionConflictError();
}
const head = client
.prepare(
`SELECT "current_revision" AS "currentRevision",
"created_at_ms" AS "createdAtMs",
"updated_at_ms" AS "updatedAtMs"
FROM "QingLong3TaskDefinitions"
WHERE "project_id" = ? AND "task_id" = ?`,
)
.get(command.projectId, command.taskId) as
| {
currentRevision?: unknown;
createdAtMs?: unknown;
updatedAtMs?: unknown;
}
| undefined;
const currentRevision = head
? integer(head, 'currentRevision')
: null;
const createdAtMs = head
? integer(head, 'createdAtMs')
: command.occurredAtMs;
const previousUpdatedAtMs = head
? integer(head, 'updatedAtMs')
: command.occurredAtMs;
if (
currentRevision !== command.expectedRevision ||
command.occurredAtMs < previousUpdatedAtMs
) {
throw new TaskDefinitionConflictError();
}
const definition = createTaskDefinitionRecord(command, createdAtMs);
const executionPlan = this.localExecutionPlan(
definition,
compileLocalExecution,
);
if (!head) {
client
.prepare(
`INSERT INTO "QingLong3TaskDefinitions" (
"project_id", "task_id", "current_revision",
"created_at_ms", "updated_at_ms"
) VALUES (?, ?, 1, ?, ?)`,
)
.run(
command.projectId,
command.taskId,
definition.createdAtMs,
definition.updatedAtMs,
);
}
client
.prepare(
`INSERT INTO "QingLong3TaskDefinitionRevisions" (
"project_id", "task_id", "revision", "mutation_id",
"name", "description", "kind", "spec_json", "labels_json",
"enabled", "content_digest", "created_at_ms"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
definition.projectId,
definition.taskId,
definition.revision,
definition.mutationId,
definition.name,
definition.description ?? null,
definition.kind,
JSON.stringify(definition.spec),
JSON.stringify(definition.labels),
definition.enabled ? 1 : 0,
definition.contentDigest,
definition.updatedAtMs,
);
if (executionPlan) {
this.dispatchDefinitions.appendPlan(executionPlan);
}
if (head) {
const update = client
.prepare(
`UPDATE "QingLong3TaskDefinitions"
SET "current_revision" = ?, "updated_at_ms" = ?
WHERE "project_id" = ? AND "task_id" = ?
AND "current_revision" = ?`,
)
.run(
definition.revision,
definition.updatedAtMs,
definition.projectId,
definition.taskId,
command.expectedRevision,
);
if (update.changes !== 1) {
throw new TaskDefinitionConflictError();
}
}
client.exec('COMMIT');
return Object.freeze({
status: head ? ('updated' as const) : ('created' as const),
definition,
});
} catch (error) {
if (client.isTransaction) client.exec('ROLLBACK');
if (error === transactionHookError && error instanceof Error) {
throw error;
}
throw mapStorageError(error);
}
},
() => new TaskDefinitionUnavailableError(),
),
);
}
}