mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
normalizeLocalScheduleCandidate,
|
||||
resolveLocalScheduleDecision,
|
||||
type LocalCronNextOccurrence,
|
||||
type LocalScheduleCandidate,
|
||||
type LocalScheduleDecision,
|
||||
} from './localScheduler';
|
||||
|
||||
export const MAX_CLUSTER_SCHEDULE_CLAIM_LEASE_MS = 60_000;
|
||||
export const MIN_CLUSTER_SCHEDULE_CLAIM_LEASE_MS = 1_000;
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const OWNER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export interface ClusterScheduleClaim extends LocalScheduleCandidate {
|
||||
readonly claimOwner: string;
|
||||
readonly claimToken: string;
|
||||
readonly claimVersion: number;
|
||||
readonly claimAcquiredAtMs: number;
|
||||
readonly claimExpiresAtMs: number;
|
||||
}
|
||||
|
||||
export interface ClaimClusterScheduleCommand {
|
||||
readonly ownerId: string;
|
||||
readonly claimToken: string;
|
||||
readonly leaseMs: number;
|
||||
}
|
||||
|
||||
export interface CommitClusterScheduleDecisionCommand {
|
||||
readonly claim: ClusterScheduleClaim;
|
||||
readonly decision: LocalScheduleDecision;
|
||||
readonly runId?: string;
|
||||
readonly attemptId?: string;
|
||||
readonly createdEventId?: string;
|
||||
readonly queuedEventId?: string;
|
||||
}
|
||||
|
||||
export type CommitClusterScheduleDecisionResult = Readonly<
|
||||
| { status: 'advanced'; disposition: 'initialize' | 'skip' }
|
||||
| {
|
||||
status: 'admitted';
|
||||
disposition: 'admit';
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
}
|
||||
| { status: 'raced' }
|
||||
>;
|
||||
|
||||
export interface ClusterScheduleStore {
|
||||
claimNextClusterSchedule(
|
||||
command: ClaimClusterScheduleCommand,
|
||||
): Promise<ClusterScheduleClaim | null>;
|
||||
commitClusterScheduleDecision(
|
||||
command: CommitClusterScheduleDecisionCommand,
|
||||
): Promise<CommitClusterScheduleDecisionResult>;
|
||||
}
|
||||
|
||||
export class InvalidClusterScheduleError extends TypeError {
|
||||
readonly code = 'CLUSTER_SCHEDULE_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Cluster schedule is invalid: ${message}`);
|
||||
this.name = 'InvalidClusterScheduleError';
|
||||
}
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new InvalidClusterScheduleError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function normalizeClusterScheduleClaim(
|
||||
value: ClusterScheduleClaim,
|
||||
): ClusterScheduleClaim {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidClusterScheduleError('claim shape is invalid');
|
||||
}
|
||||
const candidate = normalizeLocalScheduleCandidate({
|
||||
projectId: value.projectId,
|
||||
triggerId: value.triggerId,
|
||||
triggerRevision: value.triggerRevision,
|
||||
triggerContentDigest: value.triggerContentDigest,
|
||||
triggerUpdatedAtMs: value.triggerUpdatedAtMs,
|
||||
taskId: value.taskId,
|
||||
taskRevision: value.taskRevision,
|
||||
taskContentDigest: value.taskContentDigest,
|
||||
expression: value.expression,
|
||||
timezone: value.timezone,
|
||||
misfirePolicy: value.misfirePolicy,
|
||||
stateVersion: value.stateVersion,
|
||||
nextFireAtMs: value.nextFireAtMs,
|
||||
});
|
||||
const expectedKeys = new Set([
|
||||
...Object.keys(candidate),
|
||||
'claimAcquiredAtMs',
|
||||
'claimExpiresAtMs',
|
||||
'claimOwner',
|
||||
'claimToken',
|
||||
'claimVersion',
|
||||
]);
|
||||
if (
|
||||
Object.keys(value).some((key) => !expectedKeys.has(key)) ||
|
||||
typeof value.claimOwner !== 'string' ||
|
||||
!OWNER_PATTERN.test(value.claimOwner) ||
|
||||
typeof value.claimToken !== 'string' ||
|
||||
!UUID_PATTERN.test(value.claimToken) ||
|
||||
!Number.isSafeInteger(value.claimVersion) ||
|
||||
value.claimVersion < 1 ||
|
||||
value.claimVersion > 2_147_483_647
|
||||
) {
|
||||
throw new InvalidClusterScheduleError('claim fence is invalid');
|
||||
}
|
||||
const claimExpiresAtMs = timestamp(
|
||||
value.claimExpiresAtMs,
|
||||
'claimExpiresAtMs',
|
||||
);
|
||||
const claimAcquiredAtMs = timestamp(
|
||||
value.claimAcquiredAtMs,
|
||||
'claimAcquiredAtMs',
|
||||
);
|
||||
const leaseMs = claimExpiresAtMs - claimAcquiredAtMs;
|
||||
if (
|
||||
claimAcquiredAtMs < value.triggerUpdatedAtMs ||
|
||||
!Number.isSafeInteger(leaseMs) ||
|
||||
leaseMs < MIN_CLUSTER_SCHEDULE_CLAIM_LEASE_MS ||
|
||||
leaseMs > MAX_CLUSTER_SCHEDULE_CLAIM_LEASE_MS
|
||||
) {
|
||||
throw new InvalidClusterScheduleError('claim expiry is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...candidate,
|
||||
claimOwner: value.claimOwner,
|
||||
claimToken: value.claimToken,
|
||||
claimVersion: value.claimVersion,
|
||||
claimAcquiredAtMs,
|
||||
claimExpiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeClaimClusterScheduleCommand(
|
||||
value: ClaimClusterScheduleCommand,
|
||||
): ClaimClusterScheduleCommand {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join(',') !== 'claimToken,leaseMs,ownerId' ||
|
||||
!OWNER_PATTERN.test(value.ownerId) ||
|
||||
!UUID_PATTERN.test(value.claimToken) ||
|
||||
!Number.isSafeInteger(value.leaseMs) ||
|
||||
value.leaseMs < MIN_CLUSTER_SCHEDULE_CLAIM_LEASE_MS ||
|
||||
value.leaseMs > MAX_CLUSTER_SCHEDULE_CLAIM_LEASE_MS
|
||||
) {
|
||||
throw new InvalidClusterScheduleError('claim command is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
ownerId: value.ownerId,
|
||||
claimToken: value.claimToken,
|
||||
leaseMs: value.leaseMs,
|
||||
});
|
||||
}
|
||||
|
||||
function uuid(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !UUID_PATTERN.test(value)) {
|
||||
throw new InvalidClusterScheduleError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeCommitClusterScheduleDecisionCommand(
|
||||
value: CommitClusterScheduleDecisionCommand,
|
||||
): CommitClusterScheduleDecisionCommand {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidClusterScheduleError('commit command shape is invalid');
|
||||
}
|
||||
const allowed = new Set([
|
||||
'attemptId',
|
||||
'claim',
|
||||
'createdEventId',
|
||||
'decision',
|
||||
'queuedEventId',
|
||||
'runId',
|
||||
]);
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
!keys.includes('claim') ||
|
||||
!keys.includes('decision') ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw new InvalidClusterScheduleError('commit command shape is invalid');
|
||||
}
|
||||
const claim = normalizeClusterScheduleClaim(value.claim);
|
||||
const decision = value.decision;
|
||||
if (!decision || typeof decision !== 'object' || Array.isArray(decision)) {
|
||||
throw new InvalidClusterScheduleError('decision shape is invalid');
|
||||
}
|
||||
const admitted = decision.disposition === 'admit';
|
||||
const decisionKeys = Object.keys(decision).sort().join(',');
|
||||
if (
|
||||
(admitted &&
|
||||
decisionKeys !==
|
||||
'candidate,disposition,nextFireAtMs,observedAtMs,scheduledForMs') ||
|
||||
(!admitted &&
|
||||
decisionKeys !== 'candidate,disposition,nextFireAtMs,observedAtMs') ||
|
||||
(!admitted &&
|
||||
decision.disposition !== 'initialize' &&
|
||||
decision.disposition !== 'skip')
|
||||
) {
|
||||
throw new InvalidClusterScheduleError('decision shape is invalid');
|
||||
}
|
||||
const candidate = normalizeLocalScheduleCandidate(decision.candidate);
|
||||
const observedAtMs = timestamp(
|
||||
decision.observedAtMs,
|
||||
'decision.observedAtMs',
|
||||
);
|
||||
const nextFireAtMs = timestamp(
|
||||
decision.nextFireAtMs,
|
||||
'decision.nextFireAtMs',
|
||||
);
|
||||
if (
|
||||
JSON.stringify(candidate) !==
|
||||
JSON.stringify({
|
||||
projectId: claim.projectId,
|
||||
triggerId: claim.triggerId,
|
||||
triggerRevision: claim.triggerRevision,
|
||||
triggerContentDigest: claim.triggerContentDigest,
|
||||
triggerUpdatedAtMs: claim.triggerUpdatedAtMs,
|
||||
taskId: claim.taskId,
|
||||
taskRevision: claim.taskRevision,
|
||||
taskContentDigest: claim.taskContentDigest,
|
||||
expression: claim.expression,
|
||||
timezone: claim.timezone,
|
||||
misfirePolicy: claim.misfirePolicy,
|
||||
stateVersion: claim.stateVersion,
|
||||
nextFireAtMs: claim.nextFireAtMs,
|
||||
}) ||
|
||||
observedAtMs !== claim.claimAcquiredAtMs ||
|
||||
nextFireAtMs <= observedAtMs ||
|
||||
(decision.disposition === 'initialize' && candidate.nextFireAtMs !== null)
|
||||
) {
|
||||
throw new InvalidClusterScheduleError('decision fence is invalid');
|
||||
}
|
||||
if (admitted) {
|
||||
const scheduledForMs = timestamp(
|
||||
decision.scheduledForMs,
|
||||
'decision.scheduledForMs',
|
||||
);
|
||||
if (scheduledForMs > observedAtMs) {
|
||||
throw new InvalidClusterScheduleError('scheduled occurrence is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
claim,
|
||||
decision: Object.freeze({
|
||||
candidate,
|
||||
observedAtMs,
|
||||
nextFireAtMs,
|
||||
scheduledForMs,
|
||||
disposition: 'admit' as const,
|
||||
}),
|
||||
runId: uuid(value.runId, 'runId'),
|
||||
attemptId: uuid(value.attemptId, 'attemptId'),
|
||||
createdEventId: uuid(value.createdEventId, 'createdEventId'),
|
||||
queuedEventId: uuid(value.queuedEventId, 'queuedEventId'),
|
||||
});
|
||||
}
|
||||
if (
|
||||
value.runId !== undefined ||
|
||||
value.attemptId !== undefined ||
|
||||
value.createdEventId !== undefined ||
|
||||
value.queuedEventId !== undefined
|
||||
) {
|
||||
throw new InvalidClusterScheduleError(
|
||||
'non-admission decision carries identities',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
claim,
|
||||
decision: Object.freeze({
|
||||
candidate,
|
||||
observedAtMs,
|
||||
nextFireAtMs,
|
||||
disposition: decision.disposition,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveClusterScheduleDecision(
|
||||
claim: ClusterScheduleClaim,
|
||||
misfireGraceMs: number,
|
||||
nextOccurrence: LocalCronNextOccurrence,
|
||||
): LocalScheduleDecision {
|
||||
const normalized = normalizeClusterScheduleClaim(claim);
|
||||
return resolveLocalScheduleDecision(
|
||||
{
|
||||
projectId: normalized.projectId,
|
||||
triggerId: normalized.triggerId,
|
||||
triggerRevision: normalized.triggerRevision,
|
||||
triggerContentDigest: normalized.triggerContentDigest,
|
||||
triggerUpdatedAtMs: normalized.triggerUpdatedAtMs,
|
||||
taskId: normalized.taskId,
|
||||
taskRevision: normalized.taskRevision,
|
||||
taskContentDigest: normalized.taskContentDigest,
|
||||
expression: normalized.expression,
|
||||
timezone: normalized.timezone,
|
||||
misfirePolicy: normalized.misfirePolicy,
|
||||
stateVersion: normalized.stateVersion,
|
||||
nextFireAtMs: normalized.nextFireAtMs,
|
||||
},
|
||||
normalized.claimAcquiredAtMs,
|
||||
misfireGraceMs,
|
||||
nextOccurrence,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
export const MAX_LOCAL_SCHEDULE_PAGE_SIZE = 256;
|
||||
export const MAX_LOCAL_SCHEDULE_MISFIRE_GRACE_MS = 5 * 60_000;
|
||||
|
||||
export type LocalCronMisfirePolicy = 'skip' | 'fire_once';
|
||||
|
||||
export interface LocalCronSchedule {
|
||||
readonly expression: string;
|
||||
readonly timezone: string;
|
||||
}
|
||||
|
||||
export type LocalCronNextOccurrence = (
|
||||
schedule: LocalCronSchedule,
|
||||
afterMs: number,
|
||||
) => number;
|
||||
|
||||
export interface LocalScheduleCandidate {
|
||||
readonly projectId: string;
|
||||
readonly triggerId: string;
|
||||
readonly triggerRevision: number;
|
||||
readonly triggerContentDigest: string;
|
||||
readonly triggerUpdatedAtMs: number;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: number;
|
||||
readonly taskContentDigest: string;
|
||||
readonly expression: string;
|
||||
readonly timezone: string;
|
||||
readonly misfirePolicy: LocalCronMisfirePolicy;
|
||||
readonly stateVersion: number;
|
||||
readonly nextFireAtMs: number | null;
|
||||
}
|
||||
|
||||
export interface LocalScheduleDecision {
|
||||
readonly candidate: LocalScheduleCandidate;
|
||||
readonly observedAtMs: number;
|
||||
readonly nextFireAtMs: number;
|
||||
readonly scheduledForMs?: number;
|
||||
readonly disposition: 'initialize' | 'skip' | 'admit';
|
||||
}
|
||||
|
||||
export interface LocalScheduleCandidatePage {
|
||||
readonly candidates: readonly LocalScheduleCandidate[];
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
export interface CommitLocalScheduleDecisionCommand {
|
||||
readonly decision: LocalScheduleDecision;
|
||||
readonly runId?: string;
|
||||
readonly attemptId?: string;
|
||||
readonly createdEventId?: string;
|
||||
readonly queuedEventId?: string;
|
||||
}
|
||||
|
||||
export type CommitLocalScheduleDecisionResult = Readonly<
|
||||
| { status: 'advanced'; disposition: 'initialize' | 'skip' }
|
||||
| {
|
||||
status: 'admitted';
|
||||
disposition: 'admit';
|
||||
runId: string;
|
||||
attemptId: string;
|
||||
}
|
||||
| { status: 'raced' }
|
||||
>;
|
||||
|
||||
export interface LocalScheduleStore {
|
||||
listLocalScheduleCandidates(options: {
|
||||
readonly observedAtMs: number;
|
||||
readonly limit: number;
|
||||
}): Promise<LocalScheduleCandidatePage>;
|
||||
commitLocalScheduleDecision(
|
||||
command: CommitLocalScheduleDecisionCommand,
|
||||
): Promise<CommitLocalScheduleDecisionResult>;
|
||||
}
|
||||
|
||||
export class InvalidLocalScheduleError extends TypeError {
|
||||
readonly code = 'LOCAL_SCHEDULE_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Local schedule is invalid: ${message}`);
|
||||
this.name = 'InvalidLocalScheduleError';
|
||||
}
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new InvalidLocalScheduleError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function positiveRevision(value: unknown, label: string): number {
|
||||
const result = timestamp(value, label);
|
||||
if (result < 1 || result > 2_147_483_647) {
|
||||
throw new InvalidLocalScheduleError(`${label} is invalid`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string, maximumBytes = 128): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes
|
||||
) {
|
||||
throw new InvalidLocalScheduleError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function assertLocalSchedulePageSize(limit: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_LOCAL_SCHEDULE_PAGE_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Local schedule page size must be between 1 and ${MAX_LOCAL_SCHEDULE_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLocalScheduleCandidate(
|
||||
value: LocalScheduleCandidate,
|
||||
): LocalScheduleCandidate {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join(',') !==
|
||||
'expression,misfirePolicy,nextFireAtMs,projectId,stateVersion,taskContentDigest,taskId,taskRevision,timezone,triggerContentDigest,triggerId,triggerRevision,triggerUpdatedAtMs'
|
||||
) {
|
||||
throw new InvalidLocalScheduleError('candidate shape is invalid');
|
||||
}
|
||||
const triggerContentDigest = text(
|
||||
value.triggerContentDigest,
|
||||
'triggerContentDigest',
|
||||
64,
|
||||
);
|
||||
const taskContentDigest = text(
|
||||
value.taskContentDigest,
|
||||
'taskContentDigest',
|
||||
64,
|
||||
);
|
||||
if (
|
||||
!/^[0-9a-f]{64}$/.test(triggerContentDigest) ||
|
||||
!/^[0-9a-f]{64}$/.test(taskContentDigest) ||
|
||||
(value.misfirePolicy !== 'skip' && value.misfirePolicy !== 'fire_once') ||
|
||||
(value.nextFireAtMs !== null &&
|
||||
(!Number.isSafeInteger(value.nextFireAtMs) || value.nextFireAtMs < 0))
|
||||
) {
|
||||
throw new InvalidLocalScheduleError('candidate content is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: text(value.projectId, 'projectId'),
|
||||
triggerId: text(value.triggerId, 'triggerId'),
|
||||
triggerRevision: positiveRevision(value.triggerRevision, 'triggerRevision'),
|
||||
triggerContentDigest,
|
||||
triggerUpdatedAtMs: timestamp(
|
||||
value.triggerUpdatedAtMs,
|
||||
'triggerUpdatedAtMs',
|
||||
),
|
||||
taskId: text(value.taskId, 'taskId'),
|
||||
taskRevision: positiveRevision(value.taskRevision, 'taskRevision'),
|
||||
taskContentDigest,
|
||||
expression: text(value.expression, 'expression', 768),
|
||||
timezone: text(value.timezone, 'timezone'),
|
||||
misfirePolicy: value.misfirePolicy,
|
||||
stateVersion: timestamp(value.stateVersion, 'stateVersion'),
|
||||
nextFireAtMs: value.nextFireAtMs === null ? null : value.nextFireAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function cronNext(
|
||||
candidate: LocalScheduleCandidate,
|
||||
afterMs: number,
|
||||
nextOccurrence: LocalCronNextOccurrence,
|
||||
): number {
|
||||
try {
|
||||
if (
|
||||
candidate.expression.startsWith('@') ||
|
||||
typeof nextOccurrence !== 'function'
|
||||
) {
|
||||
throw new Error('cron provider is invalid');
|
||||
}
|
||||
const value = timestamp(
|
||||
nextOccurrence(
|
||||
Object.freeze({
|
||||
expression: candidate.expression,
|
||||
timezone: candidate.timezone,
|
||||
}),
|
||||
afterMs,
|
||||
),
|
||||
'next cron occurrence',
|
||||
);
|
||||
if (value <= afterMs) {
|
||||
throw new Error('cron provider did not advance time');
|
||||
}
|
||||
return value;
|
||||
} catch {
|
||||
throw new InvalidLocalScheduleError('cron calculation failed');
|
||||
}
|
||||
}
|
||||
|
||||
export function initialLocalCronNextFireAt(
|
||||
candidate: Pick<
|
||||
LocalScheduleCandidate,
|
||||
'expression' | 'timezone' | 'triggerUpdatedAtMs'
|
||||
>,
|
||||
nextOccurrence: LocalCronNextOccurrence,
|
||||
): number {
|
||||
const normalized = normalizeLocalScheduleCandidate({
|
||||
projectId: '_',
|
||||
triggerId: '_',
|
||||
triggerRevision: 1,
|
||||
triggerContentDigest: '0'.repeat(64),
|
||||
triggerUpdatedAtMs: candidate.triggerUpdatedAtMs,
|
||||
taskId: '_',
|
||||
taskRevision: 1,
|
||||
taskContentDigest: '0'.repeat(64),
|
||||
expression: candidate.expression,
|
||||
timezone: candidate.timezone,
|
||||
misfirePolicy: 'skip',
|
||||
stateVersion: 0,
|
||||
nextFireAtMs: null,
|
||||
});
|
||||
return cronNext(
|
||||
normalized,
|
||||
Math.max(0, normalized.triggerUpdatedAtMs - 1),
|
||||
nextOccurrence,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveLocalScheduleDecision(
|
||||
value: LocalScheduleCandidate,
|
||||
observedAtMs: number,
|
||||
misfireGraceMs: number,
|
||||
nextOccurrence: LocalCronNextOccurrence,
|
||||
): LocalScheduleDecision {
|
||||
const candidate = normalizeLocalScheduleCandidate(value);
|
||||
const observed = timestamp(observedAtMs, 'observedAtMs');
|
||||
if (
|
||||
!Number.isSafeInteger(misfireGraceMs) ||
|
||||
misfireGraceMs < 0 ||
|
||||
misfireGraceMs > MAX_LOCAL_SCHEDULE_MISFIRE_GRACE_MS
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Local schedule misfire grace must be between 0 and ${MAX_LOCAL_SCHEDULE_MISFIRE_GRACE_MS}`,
|
||||
);
|
||||
}
|
||||
const dueAtMs =
|
||||
candidate.nextFireAtMs ??
|
||||
initialLocalCronNextFireAt(candidate, nextOccurrence);
|
||||
if (dueAtMs > observed) {
|
||||
return Object.freeze({
|
||||
candidate,
|
||||
observedAtMs: observed,
|
||||
nextFireAtMs: dueAtMs,
|
||||
disposition: 'initialize' as const,
|
||||
});
|
||||
}
|
||||
const late = observed - dueAtMs > misfireGraceMs;
|
||||
if (late && candidate.misfirePolicy === 'skip') {
|
||||
return Object.freeze({
|
||||
candidate,
|
||||
observedAtMs: observed,
|
||||
nextFireAtMs: cronNext(candidate, observed, nextOccurrence),
|
||||
disposition: 'skip' as const,
|
||||
});
|
||||
}
|
||||
const scheduledForMs = dueAtMs;
|
||||
return Object.freeze({
|
||||
candidate,
|
||||
observedAtMs: observed,
|
||||
scheduledForMs,
|
||||
nextFireAtMs: cronNext(candidate, observed, nextOccurrence),
|
||||
disposition: 'admit' as const,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA = 'qinglong/cron@v1';
|
||||
export const MAX_TRIGGER_PAGE_SIZE = 256;
|
||||
export const MAX_TRIGGER_SPEC_BYTES = 16 * 1024;
|
||||
export const MAX_TRIGGER_SPEC_SEMANTIC_SCHEMAS = 32;
|
||||
|
||||
const TRIGGER_SPEC_SCHEMA_PATTERN =
|
||||
/^[a-z][a-z0-9.-]{0,63}\/[a-z][a-z0-9.-]{0,63}@v[1-9][0-9]{0,5}$/;
|
||||
const MUTATION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const CRON_FIELD_PATTERN = /^[0-9A-Za-z*?,/#LW-]+$/;
|
||||
|
||||
export type TriggerSpecJson =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| readonly TriggerSpecJson[]
|
||||
| Readonly<{ [key: string]: TriggerSpecJson }>;
|
||||
|
||||
export interface TriggerSpec {
|
||||
readonly schema: string;
|
||||
readonly config: Readonly<{ [key: string]: TriggerSpecJson }>;
|
||||
}
|
||||
|
||||
export interface TriggerRecord {
|
||||
readonly projectId: string;
|
||||
readonly triggerId: string;
|
||||
readonly revision: number;
|
||||
readonly mutationId: string;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: number;
|
||||
readonly taskContentDigest: string;
|
||||
readonly spec: TriggerSpec;
|
||||
readonly enabled: boolean;
|
||||
readonly contentDigest: string;
|
||||
readonly createdAtMs: number;
|
||||
readonly updatedAtMs: number;
|
||||
}
|
||||
|
||||
export interface AppendTriggerRevisionCommand {
|
||||
readonly projectId: string;
|
||||
readonly triggerId: string;
|
||||
readonly expectedRevision: number | null;
|
||||
readonly mutationId: string;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: number;
|
||||
readonly taskContentDigest: string;
|
||||
readonly spec: TriggerSpec;
|
||||
readonly enabled: boolean;
|
||||
readonly occurredAtMs: number;
|
||||
}
|
||||
|
||||
export interface TriggerCursor {
|
||||
readonly triggerId: string;
|
||||
}
|
||||
|
||||
export interface TriggerPage {
|
||||
readonly triggers: readonly TriggerRecord[];
|
||||
readonly truncated: boolean;
|
||||
readonly next?: TriggerCursor;
|
||||
}
|
||||
|
||||
export interface TriggerSource {
|
||||
findCurrentTrigger(
|
||||
projectId: string,
|
||||
triggerId: string,
|
||||
): Promise<TriggerRecord | null>;
|
||||
findTriggerRevision(
|
||||
projectId: string,
|
||||
triggerId: string,
|
||||
revision: number,
|
||||
): Promise<TriggerRecord | null>;
|
||||
listTriggers(options: {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: TriggerCursor;
|
||||
}): Promise<TriggerPage>;
|
||||
}
|
||||
|
||||
export interface TriggerRepository extends TriggerSource {
|
||||
appendTriggerRevision(command: AppendTriggerRevisionCommand): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
trigger: TriggerRecord;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface TriggerSpecSemanticDescriptor {
|
||||
readonly schema: string;
|
||||
normalizeConfig(
|
||||
config: Readonly<Record<string, TriggerSpecJson>>,
|
||||
context: Readonly<{
|
||||
projectId: string;
|
||||
triggerId: string;
|
||||
taskId: string;
|
||||
taskRevision: number;
|
||||
}>,
|
||||
): Readonly<Record<string, TriggerSpecJson>>;
|
||||
}
|
||||
|
||||
export interface TriggerSpecSemanticMetadata {
|
||||
readonly schema: string;
|
||||
}
|
||||
|
||||
export class InvalidTriggerError extends TypeError {
|
||||
readonly code = 'TRIGGER_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Trigger is invalid: ${message}`);
|
||||
this.name = 'InvalidTriggerError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedTriggerSpecError extends Error {
|
||||
readonly code = 'TRIGGER_SPEC_UNSUPPORTED';
|
||||
|
||||
constructor() {
|
||||
super('Trigger spec schema is unsupported');
|
||||
this.name = 'UnsupportedTriggerSpecError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTriggerSpecSemanticError extends TypeError {
|
||||
readonly code = 'TRIGGER_SPEC_SEMANTIC_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Trigger spec semantics are invalid: ${message}`);
|
||||
this.name = 'InvalidTriggerSpecSemanticError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TriggerConflictError extends Error {
|
||||
readonly code = 'TRIGGER_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Trigger mutation conflicts with durable state');
|
||||
this.name = 'TriggerConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TriggerUnavailableError extends Error {
|
||||
readonly code = 'TRIGGER_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super('Trigger storage is unavailable');
|
||||
this.name = 'TriggerUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
label: string,
|
||||
ErrorType: typeof InvalidTriggerError | typeof InvalidTriggerSpecSemanticError =
|
||||
InvalidTriggerError,
|
||||
): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ErrorType(`${label} must be an object`);
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (
|
||||
required.some((key) => !keys.includes(key)) ||
|
||||
keys.some((key) => !allowed.has(key))
|
||||
) {
|
||||
throw new ErrorType(`${label} has an invalid shape`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(
|
||||
value: unknown,
|
||||
label: string,
|
||||
maximumBytes: number,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
value.includes('\0') ||
|
||||
/[\u0001-\u001f\u007f]/.test(value) ||
|
||||
Buffer.byteLength(value, 'utf8') > maximumBytes
|
||||
) {
|
||||
throw new InvalidTriggerError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
return boundedText(value, label, 128);
|
||||
}
|
||||
|
||||
function revision(value: unknown, label: string): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > 2_147_483_647
|
||||
) {
|
||||
throw new InvalidTriggerError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new InvalidTriggerError(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function normalizeJson(
|
||||
value: unknown,
|
||||
budget: { nodes: number },
|
||||
depth: number,
|
||||
): TriggerSpecJson {
|
||||
budget.nodes += 1;
|
||||
if (budget.nodes > 512 || depth > 8) {
|
||||
throw new InvalidTriggerError('spec exceeds its structure budget');
|
||||
}
|
||||
if (value === null || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new InvalidTriggerError('spec contains an invalid number');
|
||||
}
|
||||
return Object.is(value, -0) ? 0 : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (value.includes('\0') || Buffer.byteLength(value, 'utf8') > 4096) {
|
||||
throw new InvalidTriggerError('spec contains invalid text');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 128) {
|
||||
throw new InvalidTriggerError('spec array is too large');
|
||||
}
|
||||
return Object.freeze(
|
||||
value.map((entry) => normalizeJson(entry, budget, depth + 1)),
|
||||
);
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new InvalidTriggerError('spec contains a non-JSON value');
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new InvalidTriggerError('spec object prototype is invalid');
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
if (keys.length > 128) {
|
||||
throw new InvalidTriggerError('spec object is too large');
|
||||
}
|
||||
const normalized = Object.create(null) as Record<string, TriggerSpecJson>;
|
||||
for (const key of keys) {
|
||||
boundedText(key, 'spec key', 128);
|
||||
normalized[key] = normalizeJson(
|
||||
(value as Record<string, unknown>)[key],
|
||||
budget,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
export function assertTriggerIdentifier(
|
||||
value: unknown,
|
||||
label = 'identifier',
|
||||
): asserts value is string {
|
||||
identifier(value, label);
|
||||
}
|
||||
|
||||
export function assertTriggerRevision(
|
||||
value: unknown,
|
||||
label = 'revision',
|
||||
): asserts value is number {
|
||||
revision(value, label);
|
||||
}
|
||||
|
||||
export function normalizeTriggerSpec(value: TriggerSpec): TriggerSpec {
|
||||
exactKeys(value, ['config', 'schema'], [], 'spec');
|
||||
if (
|
||||
typeof value.schema !== 'string' ||
|
||||
!TRIGGER_SPEC_SCHEMA_PATTERN.test(value.schema)
|
||||
) {
|
||||
throw new InvalidTriggerError('spec schema is invalid');
|
||||
}
|
||||
const config = normalizeJson(value.config, { nodes: 0 }, 0);
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
throw new InvalidTriggerError('spec config must be an object');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: value.schema,
|
||||
config: config as Readonly<Record<string, TriggerSpecJson>>,
|
||||
});
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
||||
MAX_TRIGGER_SPEC_BYTES
|
||||
) {
|
||||
throw new InvalidTriggerError('spec exceeds its byte budget');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeCronConfig(
|
||||
value: Readonly<Record<string, TriggerSpecJson>>,
|
||||
): Readonly<Record<string, TriggerSpecJson>> {
|
||||
exactKeys(
|
||||
value,
|
||||
['expression', 'misfirePolicy', 'timezone'],
|
||||
[],
|
||||
'cron config',
|
||||
InvalidTriggerSpecSemanticError,
|
||||
);
|
||||
if (typeof value.expression !== 'string') {
|
||||
throw new InvalidTriggerSpecSemanticError('cron expression is invalid');
|
||||
}
|
||||
const fields = value.expression.trim().split(/\s+/u);
|
||||
if (
|
||||
(fields.length !== 5 && fields.length !== 6) ||
|
||||
fields.some(
|
||||
(field) =>
|
||||
field.length < 1 ||
|
||||
Buffer.byteLength(field, 'utf8') > 128 ||
|
||||
!CRON_FIELD_PATTERN.test(field),
|
||||
)
|
||||
) {
|
||||
throw new InvalidTriggerSpecSemanticError(
|
||||
'cron expression must contain five or six bounded fields',
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.timezone !== 'string' ||
|
||||
value.timezone.length < 1 ||
|
||||
Buffer.byteLength(value.timezone, 'utf8') > 128
|
||||
) {
|
||||
throw new InvalidTriggerSpecSemanticError('timezone is invalid');
|
||||
}
|
||||
let timezone: string;
|
||||
try {
|
||||
timezone = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: value.timezone,
|
||||
}).resolvedOptions().timeZone;
|
||||
} catch {
|
||||
throw new InvalidTriggerSpecSemanticError('timezone is unsupported');
|
||||
}
|
||||
if (value.misfirePolicy !== 'skip' && value.misfirePolicy !== 'fire_once') {
|
||||
throw new InvalidTriggerSpecSemanticError('misfirePolicy is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
expression: fields.join(' '),
|
||||
timezone,
|
||||
misfirePolicy: value.misfirePolicy,
|
||||
});
|
||||
}
|
||||
|
||||
const BUILT_IN_DESCRIPTORS: readonly TriggerSpecSemanticDescriptor[] =
|
||||
Object.freeze([
|
||||
Object.freeze({
|
||||
schema: BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA,
|
||||
normalizeConfig: normalizeCronConfig,
|
||||
}),
|
||||
]);
|
||||
|
||||
export class TriggerSpecSemanticRegistry {
|
||||
readonly #descriptors: ReadonlyMap<string, TriggerSpecSemanticDescriptor>;
|
||||
readonly #metadata: readonly TriggerSpecSemanticMetadata[];
|
||||
|
||||
constructor(descriptors: readonly TriggerSpecSemanticDescriptor[]) {
|
||||
if (
|
||||
!Array.isArray(descriptors) ||
|
||||
descriptors.length < 1 ||
|
||||
descriptors.length > MAX_TRIGGER_SPEC_SEMANTIC_SCHEMAS
|
||||
) {
|
||||
throw new InvalidTriggerSpecSemanticError(
|
||||
'registry descriptor count is invalid',
|
||||
);
|
||||
}
|
||||
const bySchema = new Map<string, TriggerSpecSemanticDescriptor>();
|
||||
for (const descriptor of descriptors) {
|
||||
const descriptorSchema = descriptor?.schema;
|
||||
const normalizeConfig = descriptor?.normalizeConfig;
|
||||
exactKeys(
|
||||
descriptor,
|
||||
['normalizeConfig', 'schema'],
|
||||
[],
|
||||
'registry descriptor',
|
||||
InvalidTriggerSpecSemanticError,
|
||||
);
|
||||
let schema: string;
|
||||
try {
|
||||
schema = normalizeTriggerSpec({
|
||||
schema: descriptorSchema,
|
||||
config: {},
|
||||
}).schema;
|
||||
} catch {
|
||||
throw new InvalidTriggerSpecSemanticError(
|
||||
'registry descriptor schema is invalid',
|
||||
);
|
||||
}
|
||||
if (typeof normalizeConfig !== 'function' || bySchema.has(schema)) {
|
||||
throw new InvalidTriggerSpecSemanticError(
|
||||
'registry descriptor is invalid or duplicated',
|
||||
);
|
||||
}
|
||||
bySchema.set(
|
||||
schema,
|
||||
Object.freeze({
|
||||
schema,
|
||||
normalizeConfig,
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.#descriptors = bySchema;
|
||||
this.#metadata = Object.freeze(
|
||||
[...bySchema.keys()]
|
||||
.sort()
|
||||
.map((schema) => Object.freeze({ schema })),
|
||||
);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
list(): readonly TriggerSpecSemanticMetadata[] {
|
||||
return this.#metadata;
|
||||
}
|
||||
|
||||
supports(schema: string): boolean {
|
||||
return this.#descriptors.has(schema);
|
||||
}
|
||||
|
||||
normalize(context: {
|
||||
readonly projectId: string;
|
||||
readonly triggerId: string;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: number;
|
||||
readonly spec: TriggerSpec;
|
||||
}): TriggerSpec {
|
||||
exactKeys(
|
||||
context,
|
||||
['projectId', 'spec', 'taskId', 'taskRevision', 'triggerId'],
|
||||
[],
|
||||
'semantic context',
|
||||
InvalidTriggerSpecSemanticError,
|
||||
);
|
||||
const projectId = identifier(context.projectId, 'projectId');
|
||||
const triggerId = identifier(context.triggerId, 'triggerId');
|
||||
const taskId = identifier(context.taskId, 'taskId');
|
||||
const taskRevision = revision(context.taskRevision, 'taskRevision');
|
||||
const spec = normalizeTriggerSpec(context.spec);
|
||||
const descriptor = this.#descriptors.get(spec.schema);
|
||||
if (!descriptor) throw new UnsupportedTriggerSpecError();
|
||||
let config: Readonly<Record<string, TriggerSpecJson>>;
|
||||
try {
|
||||
config = descriptor.normalizeConfig(
|
||||
spec.config,
|
||||
Object.freeze({ projectId, triggerId, taskId, taskRevision }),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTriggerSpecSemanticError) throw error;
|
||||
throw new InvalidTriggerSpecSemanticError('validator rejected the config');
|
||||
}
|
||||
return normalizeTriggerSpec({ schema: spec.schema, config });
|
||||
}
|
||||
}
|
||||
|
||||
export function createBuiltInTriggerSpecSemanticRegistry(): TriggerSpecSemanticRegistry {
|
||||
return new TriggerSpecSemanticRegistry(BUILT_IN_DESCRIPTORS);
|
||||
}
|
||||
|
||||
export function createTriggerSpecSemanticRegistry(
|
||||
extensions: readonly TriggerSpecSemanticDescriptor[] = [],
|
||||
): TriggerSpecSemanticRegistry {
|
||||
if (
|
||||
!Array.isArray(extensions) ||
|
||||
extensions.some(
|
||||
(descriptor) =>
|
||||
!descriptor ||
|
||||
typeof descriptor !== 'object' ||
|
||||
typeof descriptor.schema !== 'string' ||
|
||||
descriptor.schema.startsWith('qinglong/'),
|
||||
)
|
||||
) {
|
||||
throw new InvalidTriggerSpecSemanticError(
|
||||
'extension descriptor uses the reserved qinglong namespace',
|
||||
);
|
||||
}
|
||||
return new TriggerSpecSemanticRegistry([
|
||||
...BUILT_IN_DESCRIPTORS,
|
||||
...extensions,
|
||||
]);
|
||||
}
|
||||
|
||||
function semanticTrigger(value: {
|
||||
readonly projectId: string;
|
||||
readonly triggerId: string;
|
||||
readonly revision: number;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: number;
|
||||
readonly taskContentDigest: string;
|
||||
readonly spec: TriggerSpec;
|
||||
readonly enabled: boolean;
|
||||
}): object {
|
||||
return {
|
||||
projectId: value.projectId,
|
||||
triggerId: value.triggerId,
|
||||
revision: value.revision,
|
||||
taskId: value.taskId,
|
||||
taskRevision: value.taskRevision,
|
||||
taskContentDigest: value.taskContentDigest,
|
||||
spec: value.spec,
|
||||
enabled: value.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function triggerContentDigest(
|
||||
value: Parameters<typeof semanticTrigger>[0],
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update('qinglong.trigger-definition.v1\0')
|
||||
.update(JSON.stringify(semanticTrigger(value)))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function normalizeTriggerFields(value: {
|
||||
readonly projectId: unknown;
|
||||
readonly triggerId: unknown;
|
||||
readonly revision: unknown;
|
||||
readonly taskId: unknown;
|
||||
readonly taskRevision: unknown;
|
||||
readonly taskContentDigest: unknown;
|
||||
readonly spec: TriggerSpec;
|
||||
readonly enabled: unknown;
|
||||
}): Omit<
|
||||
TriggerRecord,
|
||||
'mutationId' | 'contentDigest' | 'createdAtMs' | 'updatedAtMs'
|
||||
> {
|
||||
if (
|
||||
typeof value.taskContentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.taskContentDigest)
|
||||
) {
|
||||
throw new InvalidTriggerError('taskContentDigest is invalid');
|
||||
}
|
||||
if (typeof value.enabled !== 'boolean') {
|
||||
throw new InvalidTriggerError('enabled is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: identifier(value.projectId, 'projectId'),
|
||||
triggerId: identifier(value.triggerId, 'triggerId'),
|
||||
revision: revision(value.revision, 'revision'),
|
||||
taskId: identifier(value.taskId, 'taskId'),
|
||||
taskRevision: revision(value.taskRevision, 'taskRevision'),
|
||||
taskContentDigest: value.taskContentDigest,
|
||||
spec: normalizeTriggerSpec(value.spec),
|
||||
enabled: value.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAppendTriggerRevisionCommand(
|
||||
value: AppendTriggerRevisionCommand,
|
||||
): Readonly<AppendTriggerRevisionCommand> {
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'enabled',
|
||||
'expectedRevision',
|
||||
'mutationId',
|
||||
'occurredAtMs',
|
||||
'projectId',
|
||||
'spec',
|
||||
'taskContentDigest',
|
||||
'taskId',
|
||||
'taskRevision',
|
||||
'triggerId',
|
||||
],
|
||||
[],
|
||||
'command',
|
||||
);
|
||||
const expectedRevision =
|
||||
value.expectedRevision === null
|
||||
? null
|
||||
: revision(value.expectedRevision, 'expectedRevision');
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(value.mutationId)
|
||||
) {
|
||||
throw new InvalidTriggerError('mutationId is invalid');
|
||||
}
|
||||
const fields = normalizeTriggerFields({
|
||||
...value,
|
||||
revision: expectedRevision === null ? 1 : expectedRevision + 1,
|
||||
});
|
||||
return Object.freeze({
|
||||
projectId: fields.projectId,
|
||||
triggerId: fields.triggerId,
|
||||
expectedRevision,
|
||||
mutationId: value.mutationId,
|
||||
taskId: fields.taskId,
|
||||
taskRevision: fields.taskRevision,
|
||||
taskContentDigest: fields.taskContentDigest,
|
||||
spec: fields.spec,
|
||||
enabled: fields.enabled,
|
||||
occurredAtMs: timestamp(value.occurredAtMs, 'occurredAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeTriggerRecord(value: TriggerRecord): TriggerRecord {
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'contentDigest',
|
||||
'createdAtMs',
|
||||
'enabled',
|
||||
'mutationId',
|
||||
'projectId',
|
||||
'revision',
|
||||
'spec',
|
||||
'taskContentDigest',
|
||||
'taskId',
|
||||
'taskRevision',
|
||||
'triggerId',
|
||||
'updatedAtMs',
|
||||
],
|
||||
[],
|
||||
'record',
|
||||
);
|
||||
const fields = normalizeTriggerFields(value);
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
|
||||
typeof value.contentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.contentDigest)
|
||||
) {
|
||||
throw new InvalidTriggerError('record identity is invalid');
|
||||
}
|
||||
const createdAtMs = timestamp(value.createdAtMs, 'createdAtMs');
|
||||
const updatedAtMs = timestamp(value.updatedAtMs, 'updatedAtMs');
|
||||
if (updatedAtMs < createdAtMs) {
|
||||
throw new InvalidTriggerError('record time order is invalid');
|
||||
}
|
||||
if (value.contentDigest !== triggerContentDigest(fields)) {
|
||||
throw new InvalidTriggerError('content digest did not match');
|
||||
}
|
||||
return Object.freeze({
|
||||
...fields,
|
||||
mutationId: value.mutationId,
|
||||
contentDigest: value.contentDigest,
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function createTriggerRecord(
|
||||
command: AppendTriggerRevisionCommand,
|
||||
createdAtMs: number,
|
||||
): TriggerRecord {
|
||||
const normalized = normalizeAppendTriggerRevisionCommand(command);
|
||||
const fields = normalizeTriggerFields({
|
||||
...normalized,
|
||||
revision:
|
||||
normalized.expectedRevision === null
|
||||
? 1
|
||||
: normalized.expectedRevision + 1,
|
||||
});
|
||||
return normalizeTriggerRecord({
|
||||
...fields,
|
||||
mutationId: normalized.mutationId,
|
||||
contentDigest: triggerContentDigest(fields),
|
||||
createdAtMs: timestamp(createdAtMs, 'createdAtMs'),
|
||||
updatedAtMs: normalized.occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertTriggerPageSize(limit: number): void {
|
||||
if (
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > MAX_TRIGGER_PAGE_SIZE
|
||||
) {
|
||||
throw new RangeError(
|
||||
`Trigger page size must be between 1 and ${MAX_TRIGGER_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTriggerCursor(cursor: TriggerCursor): TriggerCursor {
|
||||
exactKeys(cursor, ['triggerId'], [], 'cursor');
|
||||
return Object.freeze({
|
||||
triggerId: identifier(cursor.triggerId, 'cursor.triggerId'),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
assertTriggerIdentifier,
|
||||
assertTriggerPageSize,
|
||||
normalizeAppendTriggerRevisionCommand,
|
||||
normalizeTriggerCursor,
|
||||
type AppendTriggerRevisionCommand,
|
||||
type TriggerCursor,
|
||||
type TriggerPage,
|
||||
type TriggerRecord,
|
||||
} from './trigger';
|
||||
import { normalizeProjectPolicySubject } from '../security/project-policy/projectPolicy';
|
||||
import type { SecurityPolicyFence, SecuritySubject } from '../security/security';
|
||||
import {
|
||||
normalizeSecurityAuditRecord,
|
||||
type SecurityAuditRecord,
|
||||
} from '../security/audit/securityAudit';
|
||||
|
||||
export interface AuthorizedTriggerRevisionMutation {
|
||||
readonly command: AppendTriggerRevisionCommand;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface TriggerAdministrationRepository {
|
||||
appendAuthorizedTriggerRevision(
|
||||
mutation: AuthorizedTriggerRevisionMutation,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
status: 'created' | 'updated' | 'existing';
|
||||
trigger: TriggerRecord;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface AuthorizedTriggerInspection {
|
||||
readonly projectId: string;
|
||||
readonly triggerId: string;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface AuthorizedTriggerList {
|
||||
readonly projectId: string;
|
||||
readonly limit: number;
|
||||
readonly after?: TriggerCursor;
|
||||
readonly actor: SecuritySubject;
|
||||
readonly fence: SecurityPolicyFence;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface TriggerAdministrationSource {
|
||||
findAuthorizedCurrentTrigger(
|
||||
inspection: AuthorizedTriggerInspection,
|
||||
): Promise<TriggerRecord | null>;
|
||||
listAuthorizedTriggers(query: AuthorizedTriggerList): Promise<TriggerPage>;
|
||||
}
|
||||
|
||||
export class InvalidTriggerAdministrationMutationError extends TypeError {
|
||||
readonly code = 'TRIGGER_ADMINISTRATION_MUTATION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Trigger administration mutation is invalid: ${message}`);
|
||||
this.name = 'InvalidTriggerAdministrationMutationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TriggerAdministrationAuthorizationFenceConflictError extends Error {
|
||||
readonly code = 'TRIGGER_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Trigger administration authorization fence changed');
|
||||
this.name = 'TriggerAdministrationAuthorizationFenceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TriggerAdministrationMutationConflictError extends Error {
|
||||
readonly code = 'TRIGGER_ADMINISTRATION_MUTATION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Trigger administration mutation conflicts with durable state');
|
||||
this.name = 'TriggerAdministrationMutationConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidTriggerAdministrationReadError extends TypeError {
|
||||
readonly code = 'TRIGGER_ADMINISTRATION_READ_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Trigger administration read is invalid: ${message}`);
|
||||
this.name = 'InvalidTriggerAdministrationReadError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TriggerAdministrationReadConflictError extends Error {
|
||||
readonly code = 'TRIGGER_ADMINISTRATION_READ_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super('Trigger administration read conflicts with durable state');
|
||||
this.name = 'TriggerAdministrationReadConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function normalizeFence(value: SecurityPolicyFence): SecurityPolicyFence {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['bindingVersion', 'projectVersion']) ||
|
||||
!Number.isSafeInteger(value.projectVersion) ||
|
||||
value.projectVersion < 1 ||
|
||||
!Number.isSafeInteger(value.bindingVersion) ||
|
||||
(value.bindingVersion as number) < 1
|
||||
) {
|
||||
throw new InvalidTriggerAdministrationMutationError(
|
||||
'authorization fence is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
projectVersion: value.projectVersion,
|
||||
bindingVersion: value.bindingVersion,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAuthorizedTriggerRevisionMutation(
|
||||
value: AuthorizedTriggerRevisionMutation,
|
||||
): Readonly<AuthorizedTriggerRevisionMutation> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['actor', 'audit', 'command', 'fence'])
|
||||
) {
|
||||
throw new InvalidTriggerAdministrationMutationError(
|
||||
'mutation shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const command = normalizeAppendTriggerRevisionCommand(value.command);
|
||||
const actor = normalizeProjectPolicySubject(value.actor);
|
||||
const fence = normalizeFence(value.fence);
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
const operationId =
|
||||
command.expectedRevision === null ? 'trigger.create' : 'trigger.update';
|
||||
if (
|
||||
audit.eventId !== command.mutationId ||
|
||||
audit.operationId !== operationId ||
|
||||
audit.projectId !== command.projectId ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!audit.subject ||
|
||||
!sameSubject(audit.subject, actor) ||
|
||||
audit.authenticationId === null ||
|
||||
!audit.fence ||
|
||||
audit.fence.projectVersion !== fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== fence.bindingVersion
|
||||
) {
|
||||
throw new InvalidTriggerAdministrationMutationError(
|
||||
'audit binding is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ command, actor, fence, audit });
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTriggerAdministrationMutationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidTriggerAdministrationMutationError(
|
||||
'mutation value is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTriggerReadAuthority(
|
||||
value: Readonly<{
|
||||
projectId: string;
|
||||
actor: SecuritySubject;
|
||||
fence: SecurityPolicyFence;
|
||||
audit: SecurityAuditRecord;
|
||||
}>,
|
||||
): Readonly<{
|
||||
projectId: string;
|
||||
actor: SecuritySubject;
|
||||
fence: SecurityPolicyFence;
|
||||
audit: SecurityAuditRecord;
|
||||
}> {
|
||||
const actor = normalizeProjectPolicySubject(value.actor);
|
||||
const fence = normalizeFence(value.fence);
|
||||
const audit = normalizeSecurityAuditRecord(value.audit);
|
||||
if (
|
||||
audit.operationId !== 'trigger.read' ||
|
||||
audit.projectId !== value.projectId ||
|
||||
audit.outcome !== 'allowed' ||
|
||||
!audit.subject ||
|
||||
!sameSubject(audit.subject, actor) ||
|
||||
audit.authenticationId === null ||
|
||||
!audit.fence ||
|
||||
audit.fence.projectVersion !== fence.projectVersion ||
|
||||
audit.fence.bindingVersion !== fence.bindingVersion
|
||||
) {
|
||||
throw new InvalidTriggerAdministrationReadError(
|
||||
'audit binding is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
projectId: value.projectId,
|
||||
actor,
|
||||
fence,
|
||||
audit,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeAuthorizedTriggerInspection(
|
||||
value: AuthorizedTriggerInspection,
|
||||
): Readonly<AuthorizedTriggerInspection> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!exactKeys(value, ['actor', 'audit', 'fence', 'projectId', 'triggerId'])
|
||||
) {
|
||||
throw new InvalidTriggerAdministrationReadError(
|
||||
'inspection shape is invalid',
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertTriggerIdentifier(value.projectId, 'projectId');
|
||||
assertTriggerIdentifier(value.triggerId, 'triggerId');
|
||||
return Object.freeze({
|
||||
...normalizeTriggerReadAuthority(value),
|
||||
triggerId: value.triggerId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTriggerAdministrationReadError) throw error;
|
||||
throw new InvalidTriggerAdministrationReadError(
|
||||
'inspection value is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAuthorizedTriggerList(
|
||||
value: AuthorizedTriggerList,
|
||||
): Readonly<AuthorizedTriggerList> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new InvalidTriggerAdministrationReadError('list shape is invalid');
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
!['actor', 'audit', 'fence', 'limit', 'projectId'].every((key) =>
|
||||
keys.includes(key),
|
||||
) ||
|
||||
keys.some(
|
||||
(key) =>
|
||||
!['actor', 'after', 'audit', 'fence', 'limit', 'projectId'].includes(
|
||||
key,
|
||||
),
|
||||
)
|
||||
) {
|
||||
throw new InvalidTriggerAdministrationReadError('list shape is invalid');
|
||||
}
|
||||
try {
|
||||
assertTriggerIdentifier(value.projectId, 'projectId');
|
||||
assertTriggerPageSize(value.limit);
|
||||
const authority = normalizeTriggerReadAuthority(value);
|
||||
const after = Object.hasOwn(value, 'after')
|
||||
? normalizeTriggerCursor(value.after as TriggerCursor)
|
||||
: undefined;
|
||||
return Object.freeze({
|
||||
...authority,
|
||||
limit: value.limit,
|
||||
...(after ? { after } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTriggerAdministrationReadError) throw error;
|
||||
throw new InvalidTriggerAdministrationReadError('list value is invalid');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user