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,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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user