mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import type { ApiCredentialRepository } from '@qinglong/runtime-core/api-credential';
|
||||
import type { ApprovalRequestRepository } from '@qinglong/runtime-core/approved-action';
|
||||
import type { ApprovalRequestDetailSource } from '@qinglong/runtime-core/approval-discovery';
|
||||
import type { LocalOwnerPepperRepository } from '@qinglong/runtime-core/local-owner-pepper';
|
||||
import type { ProjectPolicyRepository } from '@qinglong/runtime-core/project-policy';
|
||||
import type { SecurityAuditSink } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
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 { LocalSqliteApiCredentialRepository } from '../security/apiCredentialRepository';
|
||||
import { LocalSqliteProjectPolicyRepository } from '../security/projectPolicyRepository';
|
||||
import { LocalSqliteSecurityAuthorityStore } from '../security/securityAuthorityStore';
|
||||
import {
|
||||
assertLocalSqliteOptions,
|
||||
assertLocalSqlitePathBoundary,
|
||||
openLocalSqliteClient,
|
||||
type LocalSqliteDatabaseOptions,
|
||||
type LocalSqliteProfile,
|
||||
} from '../storage/config';
|
||||
import { LocalSqliteApprovalRequestRepository } from './approvalRequestRepository';
|
||||
import { LocalSqliteApprovalRequestSource } from './approvalRequestSource';
|
||||
|
||||
export interface LocalSqliteApprovalDecisionDatabase {
|
||||
readonly profile: LocalSqliteProfile;
|
||||
readonly readiness: LocalSqliteReadinessEvidence;
|
||||
readonly apiCredentials: ApiCredentialRepository;
|
||||
readonly ownerPepper: Pick<LocalOwnerPepperRepository, 'resolveKey'>;
|
||||
readonly projectPolicy: ProjectPolicyRepository;
|
||||
readonly approvals: Pick<ApprovalRequestRepository, 'findById' | 'decide'>;
|
||||
readonly approvalDetails: ApprovalRequestDetailSource;
|
||||
readonly securityAudit: SecurityAuditSink;
|
||||
activateUserCredentialFence(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
): void;
|
||||
confirmUserCredentialFence(): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/** Short-lived Owner authority; it never migrates schema or starts workers. */
|
||||
export async function openLocalSqliteApprovalDecisionDatabase(
|
||||
options: LocalSqliteDatabaseOptions,
|
||||
): Promise<LocalSqliteApprovalDecisionDatabase> {
|
||||
assertLocalSqliteOptions(options);
|
||||
assertLocalSqlitePathBoundary(options.databasePath, false);
|
||||
const client = openLocalSqliteClient(options, false);
|
||||
try {
|
||||
const readiness = await auditLocalSqliteReadiness(client);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
let activeFence:
|
||||
| Readonly<LocalSqliteAuthenticatedUserCredentialFence>
|
||||
| undefined;
|
||||
const confirmActiveFence = () => {
|
||||
if (!activeFence) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, activeFence);
|
||||
};
|
||||
const securityAuthority = new LocalSqliteSecurityAuthorityStore(authority);
|
||||
const approvals = new LocalSqliteApprovalRequestRepository(
|
||||
authority,
|
||||
confirmActiveFence,
|
||||
);
|
||||
let closePromise: Promise<void> | undefined;
|
||||
return Object.freeze({
|
||||
profile: options.profile,
|
||||
readiness,
|
||||
apiCredentials: new LocalSqliteApiCredentialRepository(authority),
|
||||
ownerPepper: new LocalSqliteOwnerPepperRepository(authority),
|
||||
projectPolicy: new LocalSqliteProjectPolicyRepository(authority),
|
||||
approvals,
|
||||
approvalDetails: new LocalSqliteApprovalRequestSource(authority),
|
||||
securityAudit: securityAuthority,
|
||||
activateUserCredentialFence(
|
||||
fence: Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
||||
) {
|
||||
confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence);
|
||||
if (activeFence && !sameCredentialFence(activeFence, fence)) {
|
||||
throw new LocalSqliteAuthenticatedManagementFenceError();
|
||||
}
|
||||
activeFence = Object.freeze({ ...fence });
|
||||
},
|
||||
confirmUserCredentialFence: confirmActiveFence,
|
||||
close() {
|
||||
if (closePromise) return closePromise;
|
||||
closePromise = authority.close();
|
||||
return closePromise;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (client.isOpen) client.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
ApprovalMutationConflictError,
|
||||
ApprovalPolicyFenceConflictError,
|
||||
ApprovalRequestNotFoundError,
|
||||
ApprovalRequestStateConflictError,
|
||||
ApprovalUnavailableError,
|
||||
approvalRequestDigest,
|
||||
approvedActionDispatchDigest,
|
||||
consumeApprovalRequest,
|
||||
decideApprovalRequest,
|
||||
normalizeApprovalRequestRecord,
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
normalizeApprovedActionFence,
|
||||
type ApprovalRequestRecord,
|
||||
type ApprovalRequestRepository,
|
||||
type ApprovedActionDispatchRecord,
|
||||
type ConsumeDurableApprovalRequestCommand,
|
||||
type ConsumeDurableApprovalRequestResult,
|
||||
type CreateApprovalRequestCommand,
|
||||
type CreateApprovalRequestResult,
|
||||
type DecideApprovalRequestResult,
|
||||
type DecideDurableApprovalRequestCommand,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '@qinglong/runtime-core/security-audit';
|
||||
import type {
|
||||
SecurityPolicyFence,
|
||||
SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
import {
|
||||
findLocalApprovedActionExecution,
|
||||
insertLocalApprovedActionExecutionBaseline,
|
||||
} from './approvedActionExecutionRepository';
|
||||
import { createApprovedActionExecution } from '@qinglong/runtime-core/approved-action-execution';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw new ApprovalUnavailableError();
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(row: Row, key: string): string | null {
|
||||
const value = row[key];
|
||||
if (value !== null && typeof value !== 'string') {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value)) throw new ApprovalUnavailableError();
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function nullableInteger(row: Row, key: string): number | null {
|
||||
const value = row[key];
|
||||
if (value !== null && !Number.isSafeInteger(value)) {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
return value as number | null;
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function parseRequest(row: Row): Readonly<ApprovalRequestRecord> {
|
||||
try {
|
||||
const request = normalizeApprovalRequestRecord(
|
||||
JSON.parse(text(row, 'requestJson')) as ApprovalRequestRecord,
|
||||
);
|
||||
if (approvalRequestDigest(request) !== text(row, 'requestDigest')) {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
return request;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function parseDispatch(row: Row): Readonly<ApprovedActionDispatchRecord> {
|
||||
try {
|
||||
const dispatch = normalizeApprovedActionDispatchRecord(
|
||||
JSON.parse(text(row, 'dispatchJson')) as ApprovedActionDispatchRecord,
|
||||
);
|
||||
if (
|
||||
approvedActionDispatchDigest(dispatch) !== text(row, 'dispatchDigest')
|
||||
) {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
return dispatch;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function parseAudit(row: Row): Readonly<SecurityAuditRecord> {
|
||||
try {
|
||||
const subjectType = nullableText(row, 'subjectType');
|
||||
const subjectId = nullableText(row, 'subjectId');
|
||||
const fenceProjectVersion = nullableInteger(row, 'fenceProjectVersion');
|
||||
return normalizeSecurityAuditRecord({
|
||||
eventId: text(row, 'eventId'),
|
||||
requestId: text(row, 'requestId'),
|
||||
operationId: text(row, 'operationId'),
|
||||
projectId: nullableText(row, 'projectId'),
|
||||
subject:
|
||||
subjectType === null || subjectId === null
|
||||
? null
|
||||
: {
|
||||
type: subjectType as SecuritySubject['type'],
|
||||
id: subjectId,
|
||||
},
|
||||
authenticationId: nullableText(row, 'authenticationId'),
|
||||
outcome: text(row, 'outcome') as SecurityAuditRecord['outcome'],
|
||||
reasons: JSON.parse(text(row, 'reasonsJson')) as readonly string[],
|
||||
fence:
|
||||
fenceProjectVersion === null
|
||||
? null
|
||||
: {
|
||||
projectVersion: fenceProjectVersion,
|
||||
bindingVersion: nullableInteger(row, 'fenceBindingVersion'),
|
||||
},
|
||||
occurredAtMs: integer(row, 'occurredAtMs'),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function storageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof ApprovalMutationConflictError ||
|
||||
error instanceof ApprovalPolicyFenceConflictError ||
|
||||
error instanceof ApprovalRequestNotFoundError ||
|
||||
error instanceof ApprovalRequestStateConflictError ||
|
||||
error instanceof ApprovalUnavailableError ||
|
||||
(error instanceof Error &&
|
||||
error.name.startsWith('Approval') &&
|
||||
'code' in error)
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return new ApprovalMutationConflictError();
|
||||
}
|
||||
return new ApprovalUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function auditMatches(
|
||||
audit: Readonly<SecurityAuditRecord>,
|
||||
expected: Readonly<{
|
||||
operationId: string;
|
||||
projectId: string;
|
||||
subject: Readonly<SecuritySubject>;
|
||||
authenticationId?: string;
|
||||
outcome: SecurityAuditRecord['outcome'];
|
||||
fence: Readonly<SecurityPolicyFence>;
|
||||
}>,
|
||||
): boolean {
|
||||
return (
|
||||
audit.operationId === expected.operationId &&
|
||||
audit.projectId === expected.projectId &&
|
||||
audit.subject?.type === expected.subject.type &&
|
||||
audit.subject.id === expected.subject.id &&
|
||||
(expected.authenticationId === undefined ||
|
||||
audit.authenticationId === expected.authenticationId) &&
|
||||
audit.outcome === expected.outcome &&
|
||||
audit.fence?.projectVersion === expected.fence.projectVersion &&
|
||||
audit.fence.bindingVersion === expected.fence.bindingVersion
|
||||
);
|
||||
}
|
||||
|
||||
export class LocalSqliteApprovalRequestRepository
|
||||
implements ApprovalRequestRepository
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
readonly #client: DatabaseSync;
|
||||
readonly #confirmMutation: () => void;
|
||||
|
||||
constructor(
|
||||
authority: LocalSqliteOperationAuthority | DatabaseSync,
|
||||
confirmMutation: () => void = () => undefined,
|
||||
) {
|
||||
if (typeof confirmMutation !== 'function') {
|
||||
throw new TypeError('Local Approval mutation guard is invalid');
|
||||
}
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
this.#client = this.#authority.client;
|
||||
this.#confirmMutation = confirmMutation;
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw storageError(error);
|
||||
}
|
||||
},
|
||||
() => new ApprovalUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
#request(id: string): Readonly<ApprovalRequestRecord> | null {
|
||||
const row = this.#client
|
||||
.prepare(
|
||||
`SELECT "request_json" AS "requestJson",
|
||||
"request_digest" AS "requestDigest"
|
||||
FROM "QingLong3ApprovalRequests"
|
||||
WHERE "request_id" = ?`,
|
||||
)
|
||||
.get(id) as Row | undefined;
|
||||
return row ? parseRequest(row) : null;
|
||||
}
|
||||
|
||||
#dispatch(id: string): Readonly<ApprovedActionDispatchRecord> | null {
|
||||
const row = this.#client
|
||||
.prepare(
|
||||
`SELECT "dispatch_json" AS "dispatchJson",
|
||||
"dispatch_digest" AS "dispatchDigest"
|
||||
FROM "QingLong3ApprovedActionDispatches"
|
||||
WHERE "dispatch_id" = ?`,
|
||||
)
|
||||
.get(id) as Row | undefined;
|
||||
return row ? parseDispatch(row) : null;
|
||||
}
|
||||
|
||||
#audit(id: string): Readonly<SecurityAuditRecord> | null {
|
||||
const row = this.#client
|
||||
.prepare(
|
||||
`SELECT "event_id" AS "eventId", "request_id" AS "requestId",
|
||||
"operation_id" AS "operationId", "project_id" AS "projectId",
|
||||
"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"
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "event_id" = ?`,
|
||||
)
|
||||
.get(id) as Row | undefined;
|
||||
return row ? parseAudit(row) : null;
|
||||
}
|
||||
|
||||
#assertFence(
|
||||
projectId: string,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
expectedValue: Readonly<SecurityPolicyFence>,
|
||||
): void {
|
||||
const expected = normalizeApprovedActionFence(expectedValue);
|
||||
const row = this.#client
|
||||
.prepare(
|
||||
`SELECT project."status" AS "status",
|
||||
project."version" AS "projectVersion",
|
||||
(
|
||||
SELECT max(binding."version")
|
||||
FROM "QingLong3ProjectRoleBindings" AS binding
|
||||
WHERE binding."project_id" = project."id"
|
||||
AND binding."subject_type" = ?
|
||||
AND binding."subject_id" = ?
|
||||
) AS "bindingVersion"
|
||||
FROM "QingLong3Projects" AS project
|
||||
WHERE project."id" = ?`,
|
||||
)
|
||||
.get(subject.type, subject.id, projectId) as Row | undefined;
|
||||
if (
|
||||
!row ||
|
||||
row.status !== 'active' ||
|
||||
integer(row, 'projectVersion') !== expected.projectVersion ||
|
||||
nullableInteger(row, 'bindingVersion') !== expected.bindingVersion
|
||||
) {
|
||||
throw new ApprovalPolicyFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
#insertAudit(value: SecurityAuditRecord): void {
|
||||
const audit = normalizeSecurityAuditRecord(value);
|
||||
this.#client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3SecurityAuditEvents" (
|
||||
"event_id", "request_id", "operation_id", "project_id",
|
||||
"subject_type", "subject_id", "authentication_id", "outcome",
|
||||
"reasons_json", "fence_project_version",
|
||||
"fence_binding_version", "occurred_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
audit.eventId,
|
||||
audit.requestId,
|
||||
audit.operationId,
|
||||
audit.projectId,
|
||||
audit.subject?.type ?? null,
|
||||
audit.subject?.id ?? null,
|
||||
audit.authenticationId,
|
||||
audit.outcome,
|
||||
JSON.stringify(audit.reasons),
|
||||
audit.fence?.projectVersion ?? null,
|
||||
audit.fence?.bindingVersion ?? null,
|
||||
audit.occurredAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
#insertRequest(request: Readonly<ApprovalRequestRecord>): void {
|
||||
this.#client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApprovalRequests" (
|
||||
"request_id", "project_id", "version", "state", "action_type",
|
||||
"action_ref", "action_digest", "preview_digest",
|
||||
"requested_by_type", "requested_by_id", "decision_id",
|
||||
"consumption_id", "dispatch_id", "expires_at_ms", "request_json",
|
||||
"request_digest", "updated_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
request.id,
|
||||
request.projectId,
|
||||
request.version,
|
||||
request.state,
|
||||
request.action.actionType,
|
||||
request.action.actionRef,
|
||||
request.action.actionDigest,
|
||||
request.action.previewDigest,
|
||||
request.requestedBy.type,
|
||||
request.requestedBy.id,
|
||||
request.decisionId,
|
||||
request.consumptionId,
|
||||
request.dispatchId,
|
||||
request.expiresAtMs,
|
||||
JSON.stringify(request),
|
||||
approvalRequestDigest(request),
|
||||
request.consumedAtMs ?? request.decidedAtMs ?? request.requestedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
#updateRequest(
|
||||
request: Readonly<ApprovalRequestRecord>,
|
||||
expectedVersion: number,
|
||||
): void {
|
||||
const update = this.#client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3ApprovalRequests"
|
||||
SET "version" = ?, "state" = ?, "decision_id" = ?,
|
||||
"consumption_id" = ?, "dispatch_id" = ?, "request_json" = ?,
|
||||
"request_digest" = ?, "updated_at_ms" = ?
|
||||
WHERE "request_id" = ? AND "version" = ?`,
|
||||
)
|
||||
.run(
|
||||
request.version,
|
||||
request.state,
|
||||
request.decisionId,
|
||||
request.consumptionId,
|
||||
request.dispatchId,
|
||||
JSON.stringify(request),
|
||||
approvalRequestDigest(request),
|
||||
request.consumedAtMs ?? request.decidedAtMs ?? request.requestedAtMs,
|
||||
request.id,
|
||||
expectedVersion,
|
||||
);
|
||||
if (update.changes !== 1) {
|
||||
throw new ApprovalRequestStateConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
#insertDispatch(dispatch: Readonly<ApprovedActionDispatchRecord>): void {
|
||||
this.#client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApprovedActionDispatches" (
|
||||
"dispatch_id", "approval_request_id", "project_id", "action_type",
|
||||
"action_ref", "action_digest", "preview_digest", "dispatch_json",
|
||||
"dispatch_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
dispatch.id,
|
||||
dispatch.approvalRequestId,
|
||||
dispatch.projectId,
|
||||
dispatch.action.actionType,
|
||||
dispatch.action.actionRef,
|
||||
dispatch.action.actionDigest,
|
||||
dispatch.action.previewDigest,
|
||||
JSON.stringify(dispatch),
|
||||
approvedActionDispatchDigest(dispatch),
|
||||
dispatch.createdAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<Readonly<ApprovalRequestRecord> | null> {
|
||||
if (typeof id !== 'string' || !IDENTIFIER_PATTERN.test(id)) {
|
||||
throw new TypeError('Approval request lookup identity is invalid');
|
||||
}
|
||||
return this.#enqueue(() => this.#request(id));
|
||||
}
|
||||
|
||||
findDispatchById(
|
||||
id: string,
|
||||
): Promise<Readonly<ApprovedActionDispatchRecord> | null> {
|
||||
if (typeof id !== 'string' || !IDENTIFIER_PATTERN.test(id)) {
|
||||
throw new TypeError('Approved action dispatch identity is invalid');
|
||||
}
|
||||
return this.#enqueue(() => this.#dispatch(id));
|
||||
}
|
||||
|
||||
create(
|
||||
command: CreateApprovalRequestCommand,
|
||||
): Promise<CreateApprovalRequestResult> {
|
||||
const request = normalizeApprovalRequestRecord(command.request);
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
if (
|
||||
request.state !== 'pending' ||
|
||||
request.version !== 1 ||
|
||||
!auditMatches(audit, {
|
||||
operationId: 'approval.request',
|
||||
projectId: request.projectId,
|
||||
subject: request.requestedBy,
|
||||
outcome: 'approval_required',
|
||||
fence: request.requestFence,
|
||||
})
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
return this.#enqueue(() => {
|
||||
this.#client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
this.#confirmMutation();
|
||||
const existing = this.#request(request.id);
|
||||
if (existing) {
|
||||
const storedAudit = this.#audit(audit.eventId);
|
||||
if (
|
||||
!same(existing, request) ||
|
||||
!storedAudit ||
|
||||
!same(storedAudit, audit)
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
this.#client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'existing' as const, request });
|
||||
}
|
||||
this.#assertFence(
|
||||
request.projectId,
|
||||
request.requestedBy,
|
||||
request.requestFence,
|
||||
);
|
||||
this.#insertRequest(request);
|
||||
this.#insertAudit(audit);
|
||||
this.#client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'created' as const, request });
|
||||
} catch (error) {
|
||||
if (this.#client.isTransaction) this.#client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
decide(
|
||||
command: DecideDurableApprovalRequestCommand,
|
||||
): Promise<DecideApprovalRequestResult> {
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
return this.#enqueue(() => {
|
||||
this.#client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
this.#confirmMutation();
|
||||
const current = this.#request(command.requestId);
|
||||
if (!current) throw new ApprovalRequestNotFoundError();
|
||||
const request = decideApprovalRequest(current, {
|
||||
expectedVersion: command.expectedVersion,
|
||||
decisionId: command.decisionId,
|
||||
decision: command.decision,
|
||||
reasonCode: command.reasonCode,
|
||||
principal: command.principal,
|
||||
decidedAtMs: command.decidedAtMs,
|
||||
authorizationFence: command.authorizationFence,
|
||||
});
|
||||
if (
|
||||
!auditMatches(audit, {
|
||||
operationId: 'approval.decide',
|
||||
projectId: request.projectId,
|
||||
subject: command.principal.subject,
|
||||
authenticationId: command.principal.authenticationId,
|
||||
outcome: 'allowed',
|
||||
fence: command.authorizationFence,
|
||||
})
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
if (current.version === 2 && current.decisionId === command.decisionId) {
|
||||
const storedAudit = this.#audit(audit.eventId);
|
||||
if (!storedAudit || !same(storedAudit, audit)) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
this.#client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
request,
|
||||
});
|
||||
}
|
||||
this.#assertFence(
|
||||
request.projectId,
|
||||
command.principal.subject,
|
||||
command.authorizationFence,
|
||||
);
|
||||
this.#updateRequest(request, command.expectedVersion);
|
||||
this.#insertAudit(audit);
|
||||
this.#client.exec('COMMIT');
|
||||
return Object.freeze({ status: 'decided' as const, request });
|
||||
} catch (error) {
|
||||
if (this.#client.isTransaction) this.#client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
consume(
|
||||
command: ConsumeDurableApprovalRequestCommand,
|
||||
): Promise<ConsumeDurableApprovalRequestResult> {
|
||||
const audit = normalizeSecurityAuditRecord(command.audit);
|
||||
return this.#enqueue(() => {
|
||||
this.#client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
this.#confirmMutation();
|
||||
const current = this.#request(command.requestId);
|
||||
if (!current) throw new ApprovalRequestNotFoundError();
|
||||
const result = consumeApprovalRequest(current, {
|
||||
expectedVersion: command.expectedVersion,
|
||||
consumptionId: command.consumptionId,
|
||||
dispatchId: command.dispatchId,
|
||||
action: command.action,
|
||||
requestedBy: command.requestedBy,
|
||||
consumedBy: command.consumedBy,
|
||||
consumedAtMs: command.consumedAtMs,
|
||||
authorizationFence: command.authorizationFence,
|
||||
});
|
||||
if (
|
||||
!auditMatches(audit, {
|
||||
operationId: 'approval.consume',
|
||||
projectId: result.request.projectId,
|
||||
subject: command.consumedBy,
|
||||
outcome: 'allowed',
|
||||
fence: command.authorizationFence,
|
||||
})
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
if (
|
||||
current.version === 3 &&
|
||||
current.consumptionId === command.consumptionId
|
||||
) {
|
||||
const storedAudit = this.#audit(audit.eventId);
|
||||
const dispatch = this.#dispatch(result.dispatch.id);
|
||||
const execution = findLocalApprovedActionExecution(
|
||||
this.#client,
|
||||
result.dispatch.id,
|
||||
);
|
||||
if (
|
||||
!storedAudit ||
|
||||
!same(storedAudit, audit) ||
|
||||
!dispatch ||
|
||||
!same(dispatch, result.dispatch) ||
|
||||
!execution ||
|
||||
!same(execution, createApprovedActionExecution(result.dispatch))
|
||||
) {
|
||||
throw new ApprovalMutationConflictError();
|
||||
}
|
||||
this.#client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'existing' as const,
|
||||
request: result.request,
|
||||
dispatch: result.dispatch,
|
||||
});
|
||||
}
|
||||
this.#assertFence(
|
||||
result.request.projectId,
|
||||
command.requestedBy,
|
||||
command.authorizationFence,
|
||||
);
|
||||
this.#insertDispatch(result.dispatch);
|
||||
insertLocalApprovedActionExecutionBaseline(
|
||||
this.#client,
|
||||
result.dispatch,
|
||||
);
|
||||
this.#updateRequest(result.request, command.expectedVersion);
|
||||
this.#insertAudit(audit);
|
||||
this.#client.exec('COMMIT');
|
||||
return Object.freeze({
|
||||
status: 'consumed' as const,
|
||||
request: result.request,
|
||||
dispatch: result.dispatch,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.#client.isTransaction) this.#client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
ApprovalUnavailableError,
|
||||
approvalRequestDigest,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovalRequestRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
approvalRequestUpdatedAtMs,
|
||||
assertApprovalDiscoveryProjectId,
|
||||
assertApprovalDiscoveryRequestId,
|
||||
assertApprovalRequestPageSize,
|
||||
normalizeApprovalRequestCursor,
|
||||
type ApprovalRequestDetail,
|
||||
type ApprovalRequestDetailSource,
|
||||
type ApprovalRequestPage,
|
||||
type ApprovalRequestSource,
|
||||
} from '@qinglong/runtime-core/approval-discovery';
|
||||
import {
|
||||
normalizeToolInvocationPreviewArtifact,
|
||||
type ToolInvocationPreviewArtifact,
|
||||
} from '@qinglong/runtime-core/tool-invocation-artifact';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') throw new ApprovalUnavailableError();
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value) || Number(value) < 0) {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function previewArtifact(
|
||||
row: Row,
|
||||
): Readonly<ToolInvocationPreviewArtifact> | null {
|
||||
if (row.previewArtifactId === null) return null;
|
||||
try {
|
||||
const value = normalizeToolInvocationPreviewArtifact(
|
||||
JSON.parse(text(row, 'previewArtifactJson')) as ToolInvocationPreviewArtifact,
|
||||
);
|
||||
if (
|
||||
value.artifactId !== text(row, 'previewArtifactId') ||
|
||||
value.projectId !== text(row, 'previewProjectId') ||
|
||||
value.actionRef !== text(row, 'previewActionRef') ||
|
||||
value.actionDigest !== text(row, 'previewActionDigest') ||
|
||||
value.previewDigest !== text(row, 'storedPreviewDigest') ||
|
||||
value.redactionContractDigest !== text(row, 'redactionContractDigest') ||
|
||||
value.artifactDigest !== text(row, 'previewArtifactDigest') ||
|
||||
value.byteLength !== integer(row, 'previewByteLength') ||
|
||||
value.sealedAtMs !== integer(row, 'previewSealedAtMs')
|
||||
) {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function request(row: Row): Readonly<ApprovalRequestRecord> {
|
||||
try {
|
||||
const value = normalizeApprovalRequestRecord(
|
||||
JSON.parse(text(row, 'requestJson')) as ApprovalRequestRecord,
|
||||
);
|
||||
if (
|
||||
approvalRequestDigest(value) !== text(row, 'requestDigest') ||
|
||||
approvalRequestUpdatedAtMs(value) !== integer(row, 'updatedAtMs')
|
||||
) {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only, Project-scoped Approval history over the shared SQLite queue. */
|
||||
export class LocalSqliteApprovalRequestSource
|
||||
implements ApprovalRequestSource, ApprovalRequestDetailSource
|
||||
{
|
||||
constructor(private readonly authority: LocalSqliteOperationAuthority) {
|
||||
if (!(authority instanceof LocalSqliteOperationAuthority)) {
|
||||
throw new TypeError('Local Approval discovery authority is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
listApprovalRequests(options: {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: { readonly updatedAtMs: number; readonly requestId: string };
|
||||
}): Promise<Readonly<ApprovalRequestPage>> {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError('Approval discovery options are invalid');
|
||||
}
|
||||
const keys = Object.keys(options);
|
||||
if (
|
||||
!keys.includes('projectId') ||
|
||||
!keys.includes('limit') ||
|
||||
keys.some((key) => !['projectId', 'limit', 'after'].includes(key))
|
||||
) {
|
||||
throw new TypeError('Approval discovery options shape is invalid');
|
||||
}
|
||||
assertApprovalDiscoveryProjectId(options.projectId);
|
||||
assertApprovalRequestPageSize(options.limit);
|
||||
const after = options.after
|
||||
? normalizeApprovalRequestCursor(options.after)
|
||||
: undefined;
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const rows = this.authority.client
|
||||
.prepare(
|
||||
`SELECT "request_json" AS "requestJson",
|
||||
"request_digest" AS "requestDigest",
|
||||
"updated_at_ms" AS "updatedAtMs"
|
||||
FROM "QingLong3ApprovalRequests"
|
||||
WHERE "project_id" = ?
|
||||
AND (
|
||||
? IS NULL OR "updated_at_ms" < ? OR
|
||||
("updated_at_ms" = ? AND "request_id" < ?)
|
||||
)
|
||||
ORDER BY "updated_at_ms" DESC, "request_id" DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
options.projectId,
|
||||
after?.updatedAtMs ?? null,
|
||||
after?.updatedAtMs ?? null,
|
||||
after?.updatedAtMs ?? null,
|
||||
after?.requestId ?? '',
|
||||
options.limit + 1,
|
||||
) as Row[] | undefined;
|
||||
if (!Array.isArray(rows)) throw new ApprovalUnavailableError();
|
||||
const truncated = rows.length > options.limit;
|
||||
const requests = Object.freeze(
|
||||
rows.slice(0, options.limit).map(request),
|
||||
);
|
||||
const last = requests.at(-1);
|
||||
return Object.freeze({
|
||||
requests,
|
||||
truncated,
|
||||
...(truncated && last
|
||||
? {
|
||||
next: Object.freeze({
|
||||
updatedAtMs: approvalRequestUpdatedAtMs(last),
|
||||
requestId: last.id,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new ApprovalUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
getApprovalRequestDetail(options: {
|
||||
readonly projectId: string;
|
||||
readonly requestId: string;
|
||||
}): Promise<Readonly<ApprovalRequestDetail> | null> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).length !== 2 ||
|
||||
!Object.hasOwn(options, 'projectId') ||
|
||||
!Object.hasOwn(options, 'requestId')
|
||||
) {
|
||||
throw new TypeError('Approval detail options are invalid');
|
||||
}
|
||||
assertApprovalDiscoveryProjectId(options.projectId);
|
||||
assertApprovalDiscoveryRequestId(options.requestId);
|
||||
return this.authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
const rows = this.authority.client
|
||||
.prepare(
|
||||
`SELECT a."request_json" AS "requestJson",
|
||||
a."request_digest" AS "requestDigest",
|
||||
a."updated_at_ms" AS "updatedAtMs",
|
||||
p."artifact_id" AS "previewArtifactId",
|
||||
p."project_id" AS "previewProjectId",
|
||||
p."action_ref" AS "previewActionRef",
|
||||
p."action_digest" AS "previewActionDigest",
|
||||
p."preview_digest" AS "storedPreviewDigest",
|
||||
p."redaction_contract_digest" AS "redactionContractDigest",
|
||||
p."artifact_digest" AS "previewArtifactDigest",
|
||||
p."byte_length" AS "previewByteLength",
|
||||
p."sealed_at_ms" AS "previewSealedAtMs",
|
||||
p."artifact_json" AS "previewArtifactJson"
|
||||
FROM "QingLong3ApprovalRequests" a
|
||||
LEFT JOIN "ToolInvocationPreviewArtifacts" p
|
||||
ON a."action_type" = 'tool.invoke'
|
||||
AND p."project_id" = a."project_id"
|
||||
AND p."action_ref" = a."action_ref"
|
||||
AND p."action_digest" = a."action_digest"
|
||||
AND p."preview_digest" = a."preview_digest"
|
||||
WHERE a."project_id" = ? AND a."request_id" = ?
|
||||
LIMIT 2`,
|
||||
)
|
||||
.all(options.projectId, options.requestId) as Row[] | undefined;
|
||||
if (!Array.isArray(rows) || rows.length > 1) {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
if (!rows[0]) return null;
|
||||
const approval = request(rows[0]);
|
||||
const preview = previewArtifact(rows[0]);
|
||||
if (
|
||||
preview &&
|
||||
(approval.action.actionType !== 'tool.invoke' ||
|
||||
approval.projectId !== preview.projectId ||
|
||||
approval.action.actionRef !== preview.actionRef ||
|
||||
approval.action.actionDigest !== preview.actionDigest ||
|
||||
approval.action.previewDigest !== preview.previewDigest)
|
||||
) {
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
return Object.freeze({
|
||||
request: approval,
|
||||
preview: preview?.preview ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovalUnavailableError) throw error;
|
||||
throw new ApprovalUnavailableError();
|
||||
}
|
||||
},
|
||||
() => new ApprovalUnavailableError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import {
|
||||
ApprovedActionExecutionFenceConflictError,
|
||||
ApprovedActionExecutionStateConflictError,
|
||||
ApprovedActionExecutionUnavailableError,
|
||||
approvedActionExecutionEffectiveStatus,
|
||||
claimApprovedActionExecution,
|
||||
completeApprovedActionExecution,
|
||||
createApprovedActionExecution,
|
||||
normalizeApprovedActionExecutionCursor,
|
||||
normalizeApprovedActionExecutionRecord,
|
||||
normalizeApprovedActionExecutionSnapshot,
|
||||
releaseApprovedActionExecutionBeforeStart,
|
||||
renewApprovedActionExecution,
|
||||
startApprovedActionExecution,
|
||||
type ApprovedActionExecutionRecord,
|
||||
type ApprovedActionExecutionRepository,
|
||||
type ApprovedActionExecutionSnapshot,
|
||||
type ClaimApprovedActionExecutionCommand,
|
||||
type ClaimApprovedActionExecutionResult,
|
||||
type CompleteApprovedActionExecutionCommand,
|
||||
type ListDueApprovedActionExecutionsQuery,
|
||||
type ListDueApprovedActionExecutionsResult,
|
||||
type ReleaseApprovedActionExecutionBeforeStartCommand,
|
||||
type RenewApprovedActionExecutionCommand,
|
||||
type StartApprovedActionExecutionCommand,
|
||||
} from '@qinglong/runtime-core/approved-action-execution';
|
||||
import {
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
|
||||
import { LocalSqliteOperationAuthority } from '../authority/operationAuthority';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function text(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new ApprovedActionExecutionUnavailableError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseExecution(row: Row): Readonly<ApprovedActionExecutionRecord> {
|
||||
try {
|
||||
const execution = normalizeApprovedActionExecutionRecord(
|
||||
JSON.parse(text(row, 'executionJson')) as ApprovedActionExecutionRecord,
|
||||
);
|
||||
if (execution.executionDigest !== text(row, 'executionDigest')) {
|
||||
throw new ApprovedActionExecutionUnavailableError();
|
||||
}
|
||||
return execution;
|
||||
} catch (error) {
|
||||
if (error instanceof ApprovedActionExecutionUnavailableError) throw error;
|
||||
throw new ApprovedActionExecutionUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function parseDispatch(row: Row): Readonly<ApprovedActionDispatchRecord> {
|
||||
try {
|
||||
return normalizeApprovedActionDispatchRecord(
|
||||
JSON.parse(text(row, 'dispatchJson')) as ApprovedActionDispatchRecord,
|
||||
);
|
||||
} catch {
|
||||
throw new ApprovedActionExecutionUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function storageError(error: unknown): Error {
|
||||
if (
|
||||
error instanceof ApprovedActionExecutionFenceConflictError ||
|
||||
error instanceof ApprovedActionExecutionStateConflictError ||
|
||||
error instanceof ApprovedActionExecutionUnavailableError ||
|
||||
(error instanceof Error &&
|
||||
error.name.startsWith('ApprovedActionExecution'))
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
return new ApprovedActionExecutionUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const EXECUTION_COLUMNS = `
|
||||
"dispatch_id", "dispatch_digest", "project_id", "status", "version",
|
||||
"attempt_count", "max_attempts", "eligible_at_ms", "next_attempt_at_ms",
|
||||
"lease_owner", "lease_token", "lease_expires_at_ms", "started_at_ms",
|
||||
"result_mutation_id", "result_code", "result_digest", "completed_at_ms",
|
||||
"created_at_ms", "updated_at_ms", "execution_json", "execution_digest"
|
||||
`;
|
||||
|
||||
function executionValues(
|
||||
execution: Readonly<ApprovedActionExecutionRecord>,
|
||||
): readonly (string | number | null)[] {
|
||||
return [
|
||||
execution.dispatchId,
|
||||
execution.dispatchDigest,
|
||||
execution.projectId,
|
||||
execution.status,
|
||||
execution.version,
|
||||
execution.attemptCount,
|
||||
execution.maxAttempts,
|
||||
execution.eligibleAtMs,
|
||||
execution.nextAttemptAtMs,
|
||||
execution.leaseOwner,
|
||||
execution.leaseToken,
|
||||
execution.leaseExpiresAtMs,
|
||||
execution.startedAtMs,
|
||||
execution.resultMutationId,
|
||||
execution.resultCode,
|
||||
execution.resultDigest,
|
||||
execution.completedAtMs,
|
||||
execution.createdAtMs,
|
||||
execution.updatedAtMs,
|
||||
JSON.stringify(execution),
|
||||
execution.executionDigest,
|
||||
];
|
||||
}
|
||||
|
||||
export function insertLocalApprovedActionExecutionBaseline(
|
||||
client: DatabaseSync,
|
||||
dispatchValue: ApprovedActionDispatchRecord,
|
||||
): Readonly<ApprovedActionExecutionRecord> {
|
||||
const execution = createApprovedActionExecution(dispatchValue);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ApprovedActionExecutions" (${EXECUTION_COLUMNS})
|
||||
VALUES (${new Array(21).fill('?').join(', ')})`,
|
||||
)
|
||||
.run(...executionValues(execution));
|
||||
return execution;
|
||||
}
|
||||
|
||||
export function findLocalApprovedActionExecution(
|
||||
client: DatabaseSync,
|
||||
dispatchId: string,
|
||||
): Readonly<ApprovedActionExecutionRecord> | null {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT "execution_json" AS "executionJson",
|
||||
"execution_digest" AS "executionDigest"
|
||||
FROM "QingLong3ApprovedActionExecutions"
|
||||
WHERE "dispatch_id" = ?`,
|
||||
)
|
||||
.get(dispatchId) as Row | undefined;
|
||||
return row ? parseExecution(row) : null;
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
client: DatabaseSync,
|
||||
dispatchId: string,
|
||||
): Readonly<ApprovedActionExecutionSnapshot> | null {
|
||||
const row = client
|
||||
.prepare(
|
||||
`SELECT execution."execution_json" AS "executionJson",
|
||||
execution."execution_digest" AS "executionDigest",
|
||||
dispatch."dispatch_json" AS "dispatchJson"
|
||||
FROM "QingLong3ApprovedActionExecutions" AS execution
|
||||
JOIN "QingLong3ApprovedActionDispatches" AS dispatch
|
||||
ON dispatch."dispatch_id" = execution."dispatch_id"
|
||||
WHERE execution."dispatch_id" = ?`,
|
||||
)
|
||||
.get(dispatchId) as Row | undefined;
|
||||
if (!row) return null;
|
||||
return normalizeApprovedActionExecutionSnapshot({
|
||||
dispatch: parseDispatch(row),
|
||||
execution: parseExecution(row),
|
||||
});
|
||||
}
|
||||
|
||||
function replaceExecution(
|
||||
client: DatabaseSync,
|
||||
previous: Readonly<ApprovedActionExecutionRecord>,
|
||||
next: Readonly<ApprovedActionExecutionRecord>,
|
||||
): void {
|
||||
const result = client
|
||||
.prepare(
|
||||
`UPDATE "QingLong3ApprovedActionExecutions"
|
||||
SET "dispatch_digest" = ?, "project_id" = ?, "status" = ?,
|
||||
"version" = ?, "attempt_count" = ?, "max_attempts" = ?,
|
||||
"eligible_at_ms" = ?, "next_attempt_at_ms" = ?,
|
||||
"lease_owner" = ?, "lease_token" = ?, "lease_expires_at_ms" = ?,
|
||||
"started_at_ms" = ?, "result_mutation_id" = ?,
|
||||
"result_code" = ?, "result_digest" = ?, "completed_at_ms" = ?,
|
||||
"created_at_ms" = ?, "updated_at_ms" = ?, "execution_json" = ?,
|
||||
"execution_digest" = ?
|
||||
WHERE "dispatch_id" = ? AND "version" = ?
|
||||
AND "execution_digest" = ?`,
|
||||
)
|
||||
.run(
|
||||
...executionValues(next).slice(1),
|
||||
previous.dispatchId,
|
||||
previous.version,
|
||||
previous.executionDigest,
|
||||
);
|
||||
if (result.changes !== 1) {
|
||||
throw new ApprovedActionExecutionFenceConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function assertPageSize(value: number): void {
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > 64) {
|
||||
throw new ApprovedActionExecutionStateConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function actionTypes(values: readonly string[]): readonly string[] {
|
||||
if (
|
||||
!Array.isArray(values) ||
|
||||
values.length > 64 ||
|
||||
values.some(
|
||||
(value) =>
|
||||
typeof value !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value),
|
||||
) ||
|
||||
new Set(values).size !== values.length
|
||||
) {
|
||||
throw new ApprovedActionExecutionStateConflictError();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export class LocalSqliteApprovedActionExecutionRepository
|
||||
implements ApprovedActionExecutionRepository
|
||||
{
|
||||
readonly #authority: LocalSqliteOperationAuthority;
|
||||
readonly #client: DatabaseSync;
|
||||
|
||||
constructor(authority: LocalSqliteOperationAuthority | DatabaseSync) {
|
||||
this.#authority =
|
||||
authority instanceof LocalSqliteOperationAuthority
|
||||
? authority
|
||||
: new LocalSqliteOperationAuthority(authority);
|
||||
this.#client = this.#authority.client;
|
||||
}
|
||||
|
||||
#enqueue<T>(work: () => T | Promise<T>): Promise<T> {
|
||||
return this.#authority.enqueue(
|
||||
async () => {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
throw storageError(error);
|
||||
}
|
||||
},
|
||||
() => new ApprovedActionExecutionUnavailableError(),
|
||||
);
|
||||
}
|
||||
|
||||
#transaction<T>(work: () => T): T {
|
||||
this.#client.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const result = work();
|
||||
this.#client.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.#client.isTransaction) this.#client.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
findExecutionByDispatchId(
|
||||
dispatchId: string,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot> | null> {
|
||||
return this.#enqueue(() => snapshot(this.#client, dispatchId));
|
||||
}
|
||||
|
||||
listDueExecutions(
|
||||
query: ListDueApprovedActionExecutionsQuery,
|
||||
): Promise<ListDueApprovedActionExecutionsResult> {
|
||||
return this.#enqueue(() => {
|
||||
assertPageSize(query.limit);
|
||||
const handledActionTypes = actionTypes(query.actionTypes);
|
||||
const cursor = query.cursor
|
||||
? normalizeApprovedActionExecutionCursor(query.cursor)
|
||||
: undefined;
|
||||
if (!Number.isSafeInteger(query.nowMs) || query.nowMs < 0) {
|
||||
throw new ApprovedActionExecutionStateConflictError();
|
||||
}
|
||||
if (handledActionTypes.length === 0) {
|
||||
return Object.freeze({
|
||||
executions: Object.freeze([]),
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
const actionTypePlaceholders = handledActionTypes.map(() => '?').join(',');
|
||||
const rows = this.#client
|
||||
.prepare(
|
||||
`SELECT execution."execution_json" AS "executionJson",
|
||||
execution."execution_digest" AS "executionDigest",
|
||||
dispatch."dispatch_json" AS "dispatchJson"
|
||||
FROM "QingLong3ApprovedActionExecutions" AS execution
|
||||
JOIN "QingLong3ApprovedActionDispatches" AS dispatch
|
||||
ON dispatch."dispatch_id" = execution."dispatch_id"
|
||||
WHERE execution."status" IN ('pending','leased','retry_wait')
|
||||
AND execution."eligible_at_ms" <= ?
|
||||
AND dispatch."action_type" IN (${actionTypePlaceholders})
|
||||
AND (
|
||||
? IS NULL OR execution."eligible_at_ms" > ? OR
|
||||
(execution."eligible_at_ms" = ? AND execution."dispatch_id" > ?)
|
||||
)
|
||||
ORDER BY execution."eligible_at_ms", execution."dispatch_id"
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(
|
||||
query.nowMs,
|
||||
...handledActionTypes,
|
||||
cursor?.dispatchId ?? null,
|
||||
cursor?.eligibleAtMs ?? 0,
|
||||
cursor?.eligibleAtMs ?? 0,
|
||||
cursor?.dispatchId ?? '',
|
||||
query.limit + 1,
|
||||
) as Row[];
|
||||
const truncated = rows.length > query.limit;
|
||||
const pageRows = truncated ? rows.slice(0, query.limit) : rows;
|
||||
const executions = pageRows.map((row) =>
|
||||
normalizeApprovedActionExecutionSnapshot({
|
||||
dispatch: parseDispatch(row),
|
||||
execution: parseExecution(row),
|
||||
}),
|
||||
);
|
||||
const last = executions.at(-1)?.execution;
|
||||
return Object.freeze({
|
||||
executions: Object.freeze(executions),
|
||||
truncated,
|
||||
...(truncated && last?.eligibleAtMs !== null
|
||||
? {
|
||||
nextCursor: Object.freeze({
|
||||
eligibleAtMs: last!.eligibleAtMs!,
|
||||
dispatchId: last!.dispatchId,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
claimExecution(
|
||||
command: ClaimApprovedActionExecutionCommand,
|
||||
): Promise<ClaimApprovedActionExecutionResult> {
|
||||
return this.#enqueue(() =>
|
||||
this.#transaction(() => {
|
||||
const current = snapshot(this.#client, command.dispatchId);
|
||||
if (!current) return Object.freeze({ status: 'not_found' as const });
|
||||
const effective = approvedActionExecutionEffectiveStatus(
|
||||
current.execution,
|
||||
command.nowMs,
|
||||
);
|
||||
if (
|
||||
current.execution.status === 'leased' &&
|
||||
current.execution.leaseExpiresAtMs !== null &&
|
||||
current.execution.leaseExpiresAtMs <= command.nowMs &&
|
||||
current.execution.attemptCount >= current.execution.maxAttempts
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'recovery_required' as const,
|
||||
snapshot: current,
|
||||
});
|
||||
}
|
||||
const due =
|
||||
current.execution.eligibleAtMs !== null &&
|
||||
current.execution.eligibleAtMs <= command.nowMs;
|
||||
if (
|
||||
effective !== 'pending' &&
|
||||
effective !== 'retry_wait' &&
|
||||
!(effective === 'leased' && due)
|
||||
) {
|
||||
return Object.freeze({ status: effective, snapshot: current });
|
||||
}
|
||||
if (!due) {
|
||||
return Object.freeze({
|
||||
status: 'not_due' as const,
|
||||
snapshot: current,
|
||||
});
|
||||
}
|
||||
const next = claimApprovedActionExecution(current.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
nowMs: command.nowMs,
|
||||
leaseDurationMs: command.leaseDurationMs,
|
||||
});
|
||||
replaceExecution(this.#client, current.execution, next);
|
||||
return Object.freeze({
|
||||
status: 'claimed' as const,
|
||||
snapshot: Object.freeze({
|
||||
dispatch: current.dispatch,
|
||||
execution: next,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
startExecution(
|
||||
command: StartApprovedActionExecutionCommand,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#mutate(command.dispatchId, (current) =>
|
||||
startApprovedActionExecution(current, command),
|
||||
);
|
||||
}
|
||||
|
||||
renewExecution(
|
||||
command: RenewApprovedActionExecutionCommand,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#mutate(command.dispatchId, (current) =>
|
||||
renewApprovedActionExecution(current.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedVersion: command.expectedVersion,
|
||||
nowMs: command.nowMs,
|
||||
leaseDurationMs: command.leaseDurationMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
releaseExecutionBeforeStart(
|
||||
command: ReleaseApprovedActionExecutionBeforeStartCommand,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#mutate(command.dispatchId, (current) =>
|
||||
releaseApprovedActionExecutionBeforeStart(current.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedVersion: command.expectedVersion,
|
||||
resultMutationId: command.resultMutationId,
|
||||
resultCode: command.resultCode,
|
||||
atMs: command.atMs,
|
||||
...(command.retryAtMs === undefined
|
||||
? {}
|
||||
: { retryAtMs: command.retryAtMs }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
completeExecution(
|
||||
command: CompleteApprovedActionExecutionCommand,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#mutate(command.dispatchId, (current) =>
|
||||
completeApprovedActionExecution(current.execution, {
|
||||
owner: command.owner,
|
||||
leaseToken: command.leaseToken,
|
||||
expectedVersion: command.expectedVersion,
|
||||
resultMutationId: command.resultMutationId,
|
||||
outcome: command.outcome,
|
||||
resultCode: command.resultCode,
|
||||
...(command.resultDigest === undefined
|
||||
? {}
|
||||
: { resultDigest: command.resultDigest }),
|
||||
completedAtMs: command.completedAtMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#mutate(
|
||||
dispatchId: string,
|
||||
transition: (
|
||||
current: Readonly<ApprovedActionExecutionSnapshot>,
|
||||
) => Readonly<ApprovedActionExecutionRecord>,
|
||||
): Promise<Readonly<ApprovedActionExecutionSnapshot>> {
|
||||
return this.#enqueue(() =>
|
||||
this.#transaction(() => {
|
||||
const current = snapshot(this.#client, dispatchId);
|
||||
if (!current) {
|
||||
throw new ApprovedActionExecutionStateConflictError();
|
||||
}
|
||||
const next = transition(current);
|
||||
replaceExecution(this.#client, current.execution, next);
|
||||
return Object.freeze({
|
||||
dispatch: current.dispatch,
|
||||
execution: next,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user