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,425 @@
import {
assertLocalSchedulePageSize,
normalizeLocalScheduleCandidate,
type CommitLocalScheduleDecisionCommand,
type CommitLocalScheduleDecisionResult,
type LocalScheduleCandidate,
type LocalScheduleCandidatePage,
type LocalScheduleStore,
} from '@qinglong/runtime-core/local-scheduler';
import type { DatabaseSync } from 'node:sqlite';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
type Row = Record<string, unknown>;
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
export class LocalSqliteScheduleUnavailableError extends Error {
readonly code = 'LOCAL_SCHEDULE_UNAVAILABLE';
constructor(message = 'Local SQLite schedule storage is unavailable') {
super(message);
this.name = 'LocalSqliteScheduleUnavailableError';
}
}
function string(row: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string') {
throw new LocalSqliteScheduleUnavailableError();
}
return value;
}
function integer(row: Row, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value)) {
throw new LocalSqliteScheduleUnavailableError();
}
return value as number;
}
function nullableInteger(row: Row, key: string): number | null {
return row[key] === null ? null : integer(row, key);
}
function candidate(row: Row): LocalScheduleCandidate {
let spec: unknown;
try {
spec = JSON.parse(string(row, 'specJson'));
} catch {
throw new LocalSqliteScheduleUnavailableError();
}
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
throw new LocalSqliteScheduleUnavailableError();
}
const config = (spec as { config?: unknown }).config;
if (!config || typeof config !== 'object' || Array.isArray(config)) {
throw new LocalSqliteScheduleUnavailableError();
}
const values = config as Record<string, unknown>;
return normalizeLocalScheduleCandidate({
projectId: string(row, 'projectId'),
triggerId: string(row, 'triggerId'),
triggerRevision: integer(row, 'triggerRevision'),
triggerContentDigest: string(row, 'triggerContentDigest'),
triggerUpdatedAtMs: integer(row, 'triggerUpdatedAtMs'),
taskId: string(row, 'taskId'),
taskRevision: integer(row, 'taskRevision'),
taskContentDigest: string(row, 'taskContentDigest'),
expression: values.expression as string,
timezone: values.timezone as string,
misfirePolicy: values.misfirePolicy as 'skip' | 'fire_once',
stateVersion: integer(row, 'stateVersion'),
nextFireAtMs: nullableInteger(row, 'nextFireAtMs'),
});
}
const CANDIDATE_SELECT = `
head."project_id" AS "projectId",
head."trigger_id" AS "triggerId",
head."current_revision" AS "triggerRevision",
head."updated_at_ms" AS "triggerUpdatedAtMs",
revision."content_digest" AS "triggerContentDigest",
revision."task_id" AS "taskId",
revision."task_revision" AS "taskRevision",
revision."task_content_digest" AS "taskContentDigest",
revision."spec_json" AS "specJson",
schedule."state_version" AS "stateVersion",
schedule."next_fire_at_ms" AS "nextFireAtMs"
`;
function sameCandidate(
left: LocalScheduleCandidate,
right: LocalScheduleCandidate,
): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function assertCommitShape(command: CommitLocalScheduleDecisionCommand): void {
if (!command || typeof command !== 'object' || Array.isArray(command)) {
throw new TypeError('Local schedule commit command is invalid');
}
const allowed = new Set([
'attemptId',
'createdEventId',
'decision',
'queuedEventId',
'runId',
]);
if (
!Object.keys(command).includes('decision') ||
Object.keys(command).some((key) => !allowed.has(key))
) {
throw new TypeError('Local schedule commit command shape is invalid');
}
}
export class LocalSqliteScheduleRepository implements LocalScheduleStore {
private readonly authority: LocalSqliteOperationAuthority;
private readonly client: DatabaseSync;
constructor(client: DatabaseSync | LocalSqliteOperationAuthority) {
this.authority =
client instanceof LocalSqliteOperationAuthority
? client
: new LocalSqliteOperationAuthority(client);
this.client = this.authority.client;
}
private enqueue<T>(work: () => Promise<T>): Promise<T> {
return this.authority.enqueue(
work,
() => new LocalSqliteScheduleUnavailableError(),
);
}
listLocalScheduleCandidates(options: {
readonly observedAtMs: number;
readonly limit: number;
}): Promise<LocalScheduleCandidatePage> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).sort().join(',') !== 'limit,observedAtMs' ||
!Number.isSafeInteger(options.observedAtMs) ||
options.observedAtMs < 0
) {
throw new TypeError('Local schedule list options are invalid');
}
assertLocalSchedulePageSize(options.limit);
return this.enqueue(async () => {
const rows = this.client
.prepare(
`SELECT ${CANDIDATE_SELECT}
FROM "QingLong3LocalTriggerSchedules" AS schedule
JOIN "QingLong3Triggers" AS head
ON head."project_id" = schedule."project_id"
AND head."trigger_id" = schedule."trigger_id"
AND head."current_revision" = schedule."trigger_revision"
JOIN "QingLong3TriggerRevisions" AS revision
ON revision."project_id" = head."project_id"
AND revision."trigger_id" = head."trigger_id"
AND revision."revision" = head."current_revision"
JOIN "QingLong3Projects" AS project
ON project."id" = head."project_id"
JOIN "QingLong3TaskDefinitions" AS task_head
ON task_head."project_id" = revision."project_id"
AND task_head."task_id" = revision."task_id"
AND task_head."current_revision" = revision."task_revision"
JOIN "QingLong3TaskDefinitionRevisions" AS task_revision
ON task_revision."project_id" = task_head."project_id"
AND task_revision."task_id" = task_head."task_id"
AND task_revision."revision" = task_head."current_revision"
AND task_revision."content_digest" = revision."task_content_digest"
WHERE project."status" = 'active'
AND revision."enabled" = 1
AND task_revision."enabled" = 1
AND json_extract(revision."spec_json", '$.schema') = 'qinglong/cron@v1'
AND (schedule."next_fire_at_ms" IS NULL
OR schedule."next_fire_at_ms" <= ?)
ORDER BY schedule."next_fire_at_ms" IS NOT NULL,
schedule."next_fire_at_ms",
head."project_id", head."trigger_id"
LIMIT ?`,
)
.all(options.observedAtMs, options.limit + 1) as Row[];
const truncated = rows.length > options.limit;
return Object.freeze({
candidates: Object.freeze(rows.slice(0, options.limit).map(candidate)),
truncated,
});
});
}
commitLocalScheduleDecision(
command: CommitLocalScheduleDecisionCommand,
): Promise<CommitLocalScheduleDecisionResult> {
assertCommitShape(command);
const decision = command.decision;
if (
!decision ||
typeof decision !== 'object' ||
Array.isArray(decision) ||
!['initialize', 'skip', 'admit'].includes(decision.disposition) ||
!Number.isSafeInteger(decision.observedAtMs) ||
decision.observedAtMs < 0 ||
!Number.isSafeInteger(decision.nextFireAtMs) ||
decision.nextFireAtMs <= decision.observedAtMs
) {
throw new TypeError('Local schedule decision is invalid');
}
const expected = normalizeLocalScheduleCandidate(decision.candidate);
const admitted = decision.disposition === 'admit';
const ids = [
command.runId,
command.attemptId,
command.createdEventId,
command.queuedEventId,
];
if (
(admitted &&
(!Number.isSafeInteger(decision.scheduledForMs) ||
decision.scheduledForMs! > decision.observedAtMs ||
ids.some(
(value) => typeof value !== 'string' || !UUID_PATTERN.test(value),
))) ||
(!admitted &&
(decision.scheduledForMs !== undefined ||
ids.some((value) => value !== undefined)))
) {
throw new TypeError('Local schedule admission identity is invalid');
}
return this.enqueue(async () => {
this.client.exec('BEGIN IMMEDIATE');
try {
const row = this.client
.prepare(
`SELECT ${CANDIDATE_SELECT}
FROM "QingLong3LocalTriggerSchedules" AS schedule
JOIN "QingLong3Triggers" AS head
ON head."project_id" = schedule."project_id"
AND head."trigger_id" = schedule."trigger_id"
AND head."current_revision" = schedule."trigger_revision"
JOIN "QingLong3TriggerRevisions" AS revision
ON revision."project_id" = head."project_id"
AND revision."trigger_id" = head."trigger_id"
AND revision."revision" = head."current_revision"
JOIN "QingLong3Projects" AS project
ON project."id" = head."project_id"
JOIN "QingLong3TaskDefinitions" AS task_head
ON task_head."project_id" = revision."project_id"
AND task_head."task_id" = revision."task_id"
AND task_head."current_revision" = revision."task_revision"
JOIN "QingLong3TaskDefinitionRevisions" AS task_revision
ON task_revision."project_id" = task_head."project_id"
AND task_revision."task_id" = task_head."task_id"
AND task_revision."revision" = task_head."current_revision"
AND task_revision."content_digest" = revision."task_content_digest"
WHERE head."project_id" = ? AND head."trigger_id" = ?
AND project."status" = 'active' AND revision."enabled" = 1
AND task_revision."enabled" = 1
AND json_extract(revision."spec_json", '$.schema') = 'qinglong/cron@v1'`,
)
.get(expected.projectId, expected.triggerId) as Row | undefined;
if (!row || !sameCandidate(candidate(row), expected)) {
this.client.exec('ROLLBACK');
return Object.freeze({ status: 'raced' as const });
}
if (admitted) {
const taskRevision = `qltd:v1:${expected.taskRevision}:${expected.taskContentDigest}`;
const execution = this.client
.prepare(
`SELECT 1 FROM "QingLong3LocalTaskExecutionRevisions"
WHERE "project_id" = ? AND "task_id" = ?
AND "task_revision" = ? AND "executor_type" = 'local_process'`,
)
.get(expected.projectId, expected.taskId, taskRevision);
if (!execution)
throw new LocalSqliteScheduleUnavailableError(
'Pinned local execution revision is unavailable',
);
const task = this.client
.prepare(
`SELECT "name" FROM "QingLong3TaskDefinitionRevisions"
WHERE "project_id" = ? AND "task_id" = ? AND "revision" = ?
AND "content_digest" = ?`,
)
.get(
expected.projectId,
expected.taskId,
expected.taskRevision,
expected.taskContentDigest,
) as { name?: unknown } | undefined;
if (!task || typeof task.name !== 'string') {
throw new LocalSqliteScheduleUnavailableError(
'Pinned TaskDefinition revision is unavailable',
);
}
const idempotencyKey = `ql3:cron:v1:${expected.triggerId}:${expected.triggerRevision}:${decision.scheduledForMs}`;
this.client
.prepare(
`INSERT INTO "Runs" (
"id", "project_id", "task_id", "task_revision", "task_name",
"task_snapshot_ref", "trigger_id", "trigger_type",
"execution_origin", "execution_owner", "triggered_by",
"scheduled_for_ms", "status", "version", "event_sequence",
"priority", "idempotency_key", "created_at_ms", "queued_at_ms"
) VALUES (?, ?, ?, ?, ?, ?, ?, 'cron', 'scheduled_system',
'runtime', ?, ?, 'queued', 2, 2, 0, ?, ?, ?)`,
)
.run(
command.runId!,
expected.projectId,
expected.taskId,
taskRevision,
task.name,
taskRevision,
expected.triggerId,
expected.triggerId,
decision.scheduledForMs!,
idempotencyKey,
decision.observedAtMs,
decision.observedAtMs,
);
this.client
.prepare(
`INSERT INTO "RunAttempts" (
"id", "run_id", "attempt", "status", "executor_type",
"callback_sequence", "created_at_ms"
) VALUES (?, ?, 1, 'claimed', 'local_process', 0, ?)`,
)
.run(command.attemptId!, command.runId!, decision.observedAtMs);
const payload = JSON.stringify({
status: 'created',
version: 1,
execution_owner: 'runtime',
trigger_revision: expected.triggerRevision,
trigger_content_digest: expected.triggerContentDigest,
scheduled_for_ms: decision.scheduledForMs,
});
this.client
.prepare(
`INSERT INTO "RunEvents" (
"id", "run_id", "sequence", "type", "dedupe_key",
"actor_type", "actor_id", "payload", "created_at_ms"
) VALUES (?, ?, 1, 'run.created', ?, 'scheduler', 'local', ?, ?)`,
)
.run(
command.createdEventId!,
command.runId!,
`local-schedule-created:${idempotencyKey}`,
payload,
decision.observedAtMs,
);
this.client
.prepare(
`INSERT INTO "RunEvents" (
"id", "run_id", "sequence", "type", "dedupe_key",
"actor_type", "actor_id", "payload", "created_at_ms"
) VALUES (?, ?, 2, 'run.queued', ?, 'scheduler', 'local', ?, ?)`,
)
.run(
command.queuedEventId!,
command.runId!,
`local-schedule-queued:${idempotencyKey}`,
JSON.stringify({
from_status: 'created',
to_status: 'queued',
version: 2,
}),
decision.observedAtMs,
);
}
const updated = this.client
.prepare(
`UPDATE "QingLong3LocalTriggerSchedules"
SET "next_fire_at_ms" = ?,
"last_scheduled_at_ms" = ?,
"state_version" = "state_version" + 1,
"updated_at_ms" = ?
WHERE "project_id" = ? AND "trigger_id" = ?
AND "trigger_revision" = ? AND "state_version" = ?
AND "next_fire_at_ms" IS ?`,
)
.run(
decision.nextFireAtMs,
admitted ? decision.scheduledForMs! : null,
decision.observedAtMs,
expected.projectId,
expected.triggerId,
expected.triggerRevision,
expected.stateVersion,
expected.nextFireAtMs,
);
if (updated.changes !== 1) {
this.client.exec('ROLLBACK');
return Object.freeze({ status: 'raced' as const });
}
this.client.exec('COMMIT');
return admitted
? Object.freeze({
status: 'admitted' as const,
disposition: 'admit' as const,
runId: command.runId!,
attemptId: command.attemptId!,
})
: Object.freeze({
status: 'advanced' as const,
disposition: decision.disposition,
});
} catch (error) {
if (this.client.isTransaction) this.client.exec('ROLLBACK');
if (error instanceof LocalSqliteScheduleUnavailableError) throw error;
throw new LocalSqliteScheduleUnavailableError(
error instanceof Error ? error.message : undefined,
);
}
});
}
}
@@ -0,0 +1,258 @@
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 { TriggerSource } from '@qinglong/runtime-core/trigger';
import {
TriggerAdministrationAuthorizationFenceConflictError,
TriggerAdministrationMutationConflictError,
normalizeAuthorizedTriggerRevisionMutation,
type AuthorizedTriggerRevisionMutation,
type TriggerAdministrationRepository,
} from '@qinglong/runtime-core/trigger-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 { LocalSqliteTriggerRepository } from './triggerRepository';
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 LocalSqliteTriggerAdministrationDatabase {
readonly profile: LocalSqliteProfile;
readonly readiness: LocalSqliteReadinessEvidence;
readonly apiCredentials: ApiCredentialRepository;
readonly ownerPepper: Pick<LocalOwnerPepperRepository, 'resolveKey'>;
readonly projectPolicy: ProjectPolicyRepository;
readonly triggers: TriggerSource;
readonly triggerAdministration: TriggerAdministrationRepository;
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 TriggerAdministrationAuthorizationFenceConflictError();
}
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 LocalSqliteTriggerAdministrationRepository
implements TriggerAdministrationRepository
{
constructor(
private readonly authority: LocalSqliteOperationAuthority,
private readonly triggers: LocalSqliteTriggerRepository,
private readonly beforeMutation: (actor: Readonly<SecuritySubject>) => void,
) {}
private confirmAuthorization(
mutation: Readonly<AuthorizedTriggerRevisionMutation>,
): 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 TriggerAdministrationAuthorizationFenceConflictError();
}
const binding = localRoleBindingFromRow(bindingRow);
if (
binding.version !== mutation.fence.bindingVersion ||
binding.state !== 'active'
) {
throw new TriggerAdministrationAuthorizationFenceConflictError();
}
}
appendAuthorizedTriggerRevision(input: AuthorizedTriggerRevisionMutation) {
const mutation = normalizeAuthorizedTriggerRevisionMutation(input);
return this.triggers.appendTriggerRevision(
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 TriggerAdministrationMutationConflictError();
}
return;
}
if (auditRow) {
throw new TriggerAdministrationMutationConflictError();
}
insertLocalSecurityAudit(this.authority.client, mutation.audit);
},
);
}
}
export async function openLocalSqliteTriggerAdministrationDatabase(
options: LocalSqliteDatabaseOptions,
): Promise<LocalSqliteTriggerAdministrationDatabase> {
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 triggerRepository = new LocalSqliteTriggerRepository(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 triggerAdministration =
new LocalSqliteTriggerAdministrationRepository(
authority,
triggerRepository,
(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,
triggers: Object.freeze({
findCurrentTrigger:
triggerRepository.findCurrentTrigger.bind(triggerRepository),
findTriggerRevision:
triggerRepository.findTriggerRevision.bind(triggerRepository),
listTriggers: triggerRepository.listTriggers.bind(triggerRepository),
}),
triggerAdministration,
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,540 @@
import {
InvalidTriggerError,
TriggerConflictError,
TriggerSpecSemanticRegistry,
TriggerUnavailableError,
assertTriggerIdentifier,
assertTriggerPageSize,
assertTriggerRevision,
createBuiltInTriggerSpecSemanticRegistry,
createTriggerRecord,
normalizeAppendTriggerRevisionCommand,
normalizeTriggerCursor,
normalizeTriggerRecord,
type AppendTriggerRevisionCommand,
type TriggerPage,
type TriggerRecord,
type TriggerRepository,
} from '@qinglong/runtime-core/trigger';
import {
normalizeTaskDefinitionRecord,
type TaskDefinitionRecord,
} from '@qinglong/runtime-core/task-definition';
import type { DatabaseSync } from 'node:sqlite';
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
type Row = Record<string, unknown>;
export type LocalSqliteTriggerRevisionTransactionHook = (
context: Readonly<{
command: Readonly<AppendTriggerRevisionCommand>;
replay: TriggerRecord | null;
}>,
) => void;
const SELECT_FIELDS = `
head."project_id" AS "projectId",
head."trigger_id" AS "triggerId",
revision."revision" AS "revision",
revision."mutation_id" AS "mutationId",
revision."task_id" AS "taskId",
revision."task_revision" AS "taskRevision",
revision."task_content_digest" AS "taskContentDigest",
revision."spec_json" AS "specJson",
revision."enabled" AS "enabled",
revision."content_digest" AS "contentDigest",
head."created_at_ms" AS "createdAtMs",
revision."created_at_ms" AS "updatedAtMs"`;
const TASK_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: Row, key: string): string {
const value = row[key];
if (typeof value !== 'string') throw new TriggerUnavailableError();
return value;
}
function integer(row: Row, key: string): number {
const value = row[key];
if (!Number.isSafeInteger(value)) throw new TriggerUnavailableError();
return value as number;
}
function parseJson(row: Row, key: string): unknown {
try {
return JSON.parse(text(row, key));
} catch {
throw new TriggerUnavailableError();
}
}
function triggerRecord(row: Row): TriggerRecord {
try {
return normalizeTriggerRecord({
projectId: text(row, 'projectId'),
triggerId: text(row, 'triggerId'),
revision: integer(row, 'revision'),
mutationId: text(row, 'mutationId'),
taskId: text(row, 'taskId'),
taskRevision: integer(row, 'taskRevision'),
taskContentDigest: text(row, 'taskContentDigest'),
spec: parseJson(row, 'specJson') as TriggerRecord['spec'],
enabled: integer(row, 'enabled') === 1,
contentDigest: text(row, 'contentDigest'),
createdAtMs: integer(row, 'createdAtMs'),
updatedAtMs: integer(row, 'updatedAtMs'),
});
} catch (error) {
if (error instanceof TriggerUnavailableError) throw error;
throw new TriggerUnavailableError();
}
}
function taskRecord(row: Row): 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: text(row, 'description') }),
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 TriggerUnavailableError) throw error;
throw new TriggerUnavailableError();
}
}
function sameRecord(left: TriggerRecord, right: TriggerRecord): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function isConstraintError(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 &&
typeof error.errcode === 'number' &&
(error.errcode & 0xff) === 19
);
}
function mapStorageError(error: unknown): Error {
if (
error instanceof TriggerConflictError ||
error instanceof TriggerUnavailableError
) {
return error;
}
return isConstraintError(error)
? new TriggerConflictError()
: new TriggerUnavailableError();
}
export class LocalSqliteTriggerRepository implements TriggerRepository {
private readonly authority: LocalSqliteOperationAuthority;
private readonly semanticRegistry: TriggerSpecSemanticRegistry;
constructor(
authority: LocalSqliteOperationAuthority | DatabaseSync,
semanticRegistry = createBuiltInTriggerSpecSemanticRegistry(),
) {
this.authority =
authority instanceof LocalSqliteOperationAuthority
? authority
: new LocalSqliteOperationAuthority(authority);
this.semanticRegistry = semanticRegistry;
}
private enqueue<T>(work: () => T | Promise<T>): Promise<T> {
return this.authority.enqueue(
async () => {
try {
return await work();
} catch (error) {
throw mapStorageError(error);
}
},
() => new TriggerUnavailableError(),
);
}
private findCurrent(
projectId: string,
triggerId: string,
): TriggerRecord | null {
const row = this.authority.client
.prepare(
`SELECT ${SELECT_FIELDS}
FROM "QingLong3Triggers" AS head
JOIN "QingLong3TriggerRevisions" AS revision
ON revision."project_id" = head."project_id"
AND revision."trigger_id" = head."trigger_id"
AND revision."revision" = head."current_revision"
WHERE head."project_id" = ? AND head."trigger_id" = ?`,
)
.get(projectId, triggerId) as Row | undefined;
return row ? triggerRecord(row) : null;
}
private findRevision(
projectId: string,
triggerId: string,
revision: number,
): TriggerRecord | null {
const row = this.authority.client
.prepare(
`SELECT ${SELECT_FIELDS}
FROM "QingLong3Triggers" AS head
JOIN "QingLong3TriggerRevisions" AS revision
ON revision."project_id" = head."project_id"
AND revision."trigger_id" = head."trigger_id"
WHERE head."project_id" = ? AND head."trigger_id" = ?
AND revision."revision" = ?`,
)
.get(projectId, triggerId, revision) as Row | undefined;
return row ? triggerRecord(row) : null;
}
private findByMutation(mutationId: string): TriggerRecord | null {
const row = this.authority.client
.prepare(
`SELECT ${SELECT_FIELDS}
FROM "QingLong3TriggerRevisions" AS revision
JOIN "QingLong3Triggers" AS head
ON head."project_id" = revision."project_id"
AND head."trigger_id" = revision."trigger_id"
WHERE revision."mutation_id" = ?`,
)
.get(mutationId) as Row | undefined;
return row ? triggerRecord(row) : null;
}
private pinnedTask(
record: TriggerRecord,
requireCurrent: boolean,
): TaskDefinitionRecord {
const row = this.authority.client
.prepare(
`SELECT ${TASK_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 revision."project_id" = ? AND revision."task_id" = ?
AND revision."revision" = ?`,
)
.get(record.projectId, record.taskId, record.taskRevision) as
| Row
| undefined;
if (!row) throw new TriggerConflictError();
const task = taskRecord(row);
if (
task.contentDigest !== record.taskContentDigest ||
(record.enabled && !task.enabled)
) {
throw new TriggerConflictError();
}
if (requireCurrent) {
const current = this.authority.client
.prepare(
`SELECT "current_revision" AS "currentRevision"
FROM "QingLong3TaskDefinitions"
WHERE "project_id" = ? AND "task_id" = ?`,
)
.get(record.projectId, record.taskId) as Row | undefined;
if (
!current ||
integer(current, 'currentRevision') !== record.taskRevision
) {
throw new TriggerConflictError();
}
}
return task;
}
findCurrentTrigger(
projectId: string,
triggerId: string,
): Promise<TriggerRecord | null> {
assertTriggerIdentifier(projectId, 'projectId');
assertTriggerIdentifier(triggerId, 'triggerId');
return this.enqueue(() => this.findCurrent(projectId, triggerId));
}
findTriggerRevision(
projectId: string,
triggerId: string,
revision: number,
): Promise<TriggerRecord | null> {
assertTriggerIdentifier(projectId, 'projectId');
assertTriggerIdentifier(triggerId, 'triggerId');
assertTriggerRevision(revision);
return this.enqueue(() =>
this.findRevision(projectId, triggerId, revision),
);
}
listTriggers(options: {
readonly projectId: string;
readonly limit: number;
readonly after?: { readonly triggerId: string };
}): Promise<TriggerPage> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new InvalidTriggerError('list options are invalid');
}
const keys = Object.keys(options);
if (
!keys.includes('limit') ||
!keys.includes('projectId') ||
keys.some((key) => !['after', 'limit', 'projectId'].includes(key))
) {
throw new InvalidTriggerError('list options have an invalid shape');
}
assertTriggerIdentifier(options.projectId, 'projectId');
assertTriggerPageSize(options.limit);
const after = options.after
? normalizeTriggerCursor(options.after)
: undefined;
return this.enqueue(() => {
const rows = this.authority.client
.prepare(
`SELECT ${SELECT_FIELDS}
FROM "QingLong3Triggers" AS head
JOIN "QingLong3TriggerRevisions" AS revision
ON revision."project_id" = head."project_id"
AND revision."trigger_id" = head."trigger_id"
AND revision."revision" = head."current_revision"
WHERE head."project_id" = ? AND head."trigger_id" > ?
ORDER BY head."trigger_id"
LIMIT ?`,
)
.all(options.projectId, after?.triggerId ?? '', options.limit + 1) as
| Row[]
| undefined;
if (!Array.isArray(rows)) throw new TriggerUnavailableError();
const truncated = rows.length > options.limit;
const triggers = Object.freeze(
rows.slice(0, options.limit).map(triggerRecord),
);
const last = triggers.at(-1);
return Object.freeze({
triggers,
truncated,
...(truncated && last
? { next: Object.freeze({ triggerId: last.triggerId }) }
: {}),
});
});
}
appendTriggerRevision(
input: AppendTriggerRevisionCommand,
transactionHook?: LocalSqliteTriggerRevisionTransactionHook,
): Promise<
Readonly<{
status: 'created' | 'updated' | 'existing';
trigger: TriggerRecord;
}>
> {
const normalized = normalizeAppendTriggerRevisionCommand(input);
const command = Object.freeze({
...normalized,
spec: this.semanticRegistry.normalize({
projectId: normalized.projectId,
triggerId: normalized.triggerId,
taskId: normalized.taskId,
taskRevision: normalized.taskRevision,
spec: normalized.spec,
}),
});
if (
transactionHook !== undefined &&
typeof transactionHook !== 'function'
) {
throw new InvalidTriggerError('transaction hook is invalid');
}
return this.authority.enqueue(
async () => {
const client = this.authority.client;
let transactionHookError: unknown;
try {
client.exec('BEGIN IMMEDIATE');
const replay = this.findByMutation(command.mutationId);
if (transactionHook) {
try {
transactionHook(Object.freeze({ command, replay }));
} catch (error) {
transactionHookError = error;
throw error;
}
}
if (replay) {
const expected = createTriggerRecord(command, replay.createdAtMs);
if (!sameRecord(replay, expected)) throw new TriggerConflictError();
this.pinnedTask(replay, false);
client.exec('COMMIT');
return Object.freeze({
status: 'existing' as const,
trigger: 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 TriggerConflictError();
const head = client
.prepare(
`SELECT "task_id" AS "taskId",
"current_revision" AS "currentRevision",
"created_at_ms" AS "createdAtMs",
"updated_at_ms" AS "updatedAtMs"
FROM "QingLong3Triggers"
WHERE "project_id" = ? AND "trigger_id" = ?`,
)
.get(command.projectId, command.triggerId) as Row | 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 ||
(head && text(head, 'taskId') !== command.taskId) ||
command.occurredAtMs < previousUpdatedAtMs
) {
throw new TriggerConflictError();
}
const trigger = createTriggerRecord(command, createdAtMs);
this.pinnedTask(trigger, trigger.enabled);
if (!head) {
client
.prepare(
`INSERT INTO "QingLong3Triggers" (
"project_id", "trigger_id", "task_id", "current_revision",
"created_at_ms", "updated_at_ms"
) VALUES (?, ?, ?, 1, ?, ?)`,
)
.run(
trigger.projectId,
trigger.triggerId,
trigger.taskId,
trigger.createdAtMs,
trigger.updatedAtMs,
);
}
client
.prepare(
`INSERT INTO "QingLong3TriggerRevisions" (
"project_id", "trigger_id", "revision", "mutation_id",
"task_id", "task_revision", "task_content_digest",
"spec_json", "enabled", "content_digest", "created_at_ms"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
trigger.projectId,
trigger.triggerId,
trigger.revision,
trigger.mutationId,
trigger.taskId,
trigger.taskRevision,
trigger.taskContentDigest,
JSON.stringify(trigger.spec),
trigger.enabled ? 1 : 0,
trigger.contentDigest,
trigger.updatedAtMs,
);
if (head) {
const update = client
.prepare(
`UPDATE "QingLong3Triggers"
SET "current_revision" = ?, "updated_at_ms" = ?
WHERE "project_id" = ? AND "trigger_id" = ?
AND "current_revision" = ? AND "task_id" = ?`,
)
.run(
trigger.revision,
trigger.updatedAtMs,
trigger.projectId,
trigger.triggerId,
command.expectedRevision,
trigger.taskId,
);
if (update.changes !== 1) throw new TriggerConflictError();
}
client
.prepare(
`INSERT INTO "QingLong3LocalTriggerSchedules" (
"project_id", "trigger_id", "trigger_revision",
"next_fire_at_ms", "last_scheduled_at_ms", "state_version",
"updated_at_ms"
) VALUES (?, ?, ?, ?, NULL, 0, ?)
ON CONFLICT ("project_id", "trigger_id") DO UPDATE SET
"trigger_revision" = excluded."trigger_revision",
"next_fire_at_ms" = excluded."next_fire_at_ms",
"last_scheduled_at_ms" = NULL,
"state_version" = "QingLong3LocalTriggerSchedules"."state_version" + 1,
"updated_at_ms" = excluded."updated_at_ms"`,
)
.run(
trigger.projectId,
trigger.triggerId,
trigger.revision,
null,
trigger.updatedAtMs,
);
client.exec('COMMIT');
return Object.freeze({
status: head ? ('updated' as const) : ('created' as const),
trigger,
});
} catch (error) {
if (client.isTransaction) client.exec('ROLLBACK');
if (error === transactionHookError && error instanceof Error) {
throw error;
}
throw mapStorageError(error);
}
},
() => new TriggerUnavailableError(),
);
}
}