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; findTriggerRevision( projectId: string, triggerId: string, revision: number, ): Promise; listTriggers(options: { readonly projectId: string; readonly limit: number; readonly after?: TriggerCursor; }): Promise; } 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>, context: Readonly<{ projectId: string; triggerId: string; taskId: string; taskRevision: number; }>, ): Readonly>; } 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 { 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; for (const key of keys) { boundedText(key, 'spec key', 128); normalized[key] = normalizeJson( (value as Record)[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>, }); 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>, ): Readonly> { 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; 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(); 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>; 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[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 { 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'), }); }