feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,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');
}
}