mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
667 lines
20 KiB
TypeScript
667 lines
20 KiB
TypeScript
// Automation management owns Trigger authoring and inspection commands.
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
PrivateLocalCommandFileError,
|
|
readPrivateLocalCommandFile,
|
|
} from '@qinglong/local-command-file';
|
|
import {
|
|
AuthenticatedLocalCommandAuthenticationError,
|
|
establishAuthenticatedLocalCommand,
|
|
type AuthenticatedLocalCommand,
|
|
} from '@qinglong/local-owner-console/authenticated-command';
|
|
import {
|
|
LocalTriggerAdministrationAuthenticationError,
|
|
LocalTriggerAdministrationAuthorizationError,
|
|
LocalTriggerAdministrationConfigurationError,
|
|
LocalTriggerAdministrationUnavailableError,
|
|
createLocalTriggerAdministrationService,
|
|
} from '@qinglong/local-admin/trigger-administration';
|
|
import {
|
|
LocalSqliteAuthenticatedManagementFenceError,
|
|
type LocalSqliteAuthenticatedUserCredentialFence,
|
|
} from '@qinglong/local-sqlite/authenticated-management';
|
|
import {
|
|
openLocalSqliteTriggerAdministrationDatabase,
|
|
type LocalSqliteTriggerAdministrationDatabase,
|
|
} from '@qinglong/local-sqlite/trigger-administration';
|
|
import {
|
|
InvalidTriggerError,
|
|
InvalidTriggerSpecSemanticError,
|
|
TriggerConflictError,
|
|
TriggerUnavailableError,
|
|
UnsupportedTriggerSpecError,
|
|
assertTriggerIdentifier,
|
|
assertTriggerPageSize,
|
|
normalizeAppendTriggerRevisionCommand,
|
|
normalizeTriggerCursor,
|
|
type AppendTriggerRevisionCommand,
|
|
type TriggerRecord,
|
|
} from '@qinglong/runtime-core/trigger';
|
|
import {
|
|
TriggerAdministrationAuthorizationFenceConflictError,
|
|
TriggerAdministrationMutationConflictError,
|
|
} from '@qinglong/runtime-core/trigger-administration';
|
|
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
|
|
|
const MAX_PATH_BYTES = 4096;
|
|
const UUID_V4_PATTERN =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
|
|
export interface LocalTriggerCommandOptions {
|
|
readonly deploymentRoot: string;
|
|
readonly databasePath: string;
|
|
readonly profile: 'edge' | 'standalone';
|
|
readonly ownerPepperKeyringDirectory: string;
|
|
readonly credentialFilePath: string;
|
|
readonly busyTimeoutMs?: number;
|
|
}
|
|
|
|
interface BaseTriggerCommandRequest {
|
|
readonly projectId: string;
|
|
readonly requestId: string;
|
|
readonly failureAuditEventId: string;
|
|
}
|
|
|
|
export interface PutLocalTriggerCommand {
|
|
readonly schemaVersion: 1;
|
|
readonly operation: 'trigger.put';
|
|
readonly options: LocalTriggerCommandOptions;
|
|
readonly request: BaseTriggerCommandRequest &
|
|
Omit<AppendTriggerRevisionCommand, 'projectId'>;
|
|
}
|
|
|
|
export interface InspectLocalTriggerCommand {
|
|
readonly schemaVersion: 1;
|
|
readonly operation: 'trigger.inspect';
|
|
readonly options: LocalTriggerCommandOptions;
|
|
readonly request: BaseTriggerCommandRequest & {
|
|
readonly triggerId: string;
|
|
readonly auditEventId: string;
|
|
};
|
|
}
|
|
|
|
export interface ListLocalTriggersCommand {
|
|
readonly schemaVersion: 1;
|
|
readonly operation: 'trigger.list';
|
|
readonly options: LocalTriggerCommandOptions;
|
|
readonly request: BaseTriggerCommandRequest & {
|
|
readonly limit: number;
|
|
readonly after?: Readonly<{ readonly triggerId: string }>;
|
|
readonly auditEventId: string;
|
|
};
|
|
}
|
|
|
|
export type LocalTriggerCommand =
|
|
| PutLocalTriggerCommand
|
|
| InspectLocalTriggerCommand
|
|
| ListLocalTriggersCommand;
|
|
|
|
export type LocalTriggerSummary = Readonly<{
|
|
projectId: string;
|
|
triggerId: string;
|
|
revision: number;
|
|
taskId: string;
|
|
taskRevision: number;
|
|
taskContentDigest: string;
|
|
schema: string;
|
|
enabled: boolean;
|
|
contentDigest: string;
|
|
createdAtMs: number;
|
|
updatedAtMs: number;
|
|
}>;
|
|
|
|
export type LocalTriggerCommandResult =
|
|
| Readonly<{
|
|
schemaVersion: 1;
|
|
operation: 'trigger.put';
|
|
status: 'created' | 'updated' | 'existing';
|
|
trigger: LocalTriggerSummary;
|
|
}>
|
|
| Readonly<{
|
|
schemaVersion: 1;
|
|
operation: 'trigger.inspect';
|
|
found: false;
|
|
projectId: string;
|
|
triggerId: string;
|
|
}>
|
|
| Readonly<{
|
|
schemaVersion: 1;
|
|
operation: 'trigger.inspect';
|
|
found: true;
|
|
trigger: LocalTriggerSummary;
|
|
}>
|
|
| Readonly<{
|
|
schemaVersion: 1;
|
|
operation: 'trigger.list';
|
|
projectId: string;
|
|
triggers: readonly LocalTriggerSummary[];
|
|
nextCursor: Readonly<{ readonly triggerId: string }> | null;
|
|
}>;
|
|
|
|
export interface LocalTriggerCommandRunner {
|
|
run(commandFilePath: string): Promise<LocalTriggerCommandResult>;
|
|
}
|
|
|
|
export interface LocalTriggerCommandRunnerDependencies {
|
|
readonly openDatabase: typeof openLocalSqliteTriggerAdministrationDatabase;
|
|
readonly authenticate: typeof establishAuthenticatedLocalCommand;
|
|
readonly createService: typeof createLocalTriggerAdministrationService;
|
|
readonly now: () => number;
|
|
}
|
|
|
|
export class LocalTriggerCommandConfigurationError extends TypeError {
|
|
readonly code = 'LOCAL_TRIGGER_COMMAND_CONFIGURATION_INVALID';
|
|
|
|
constructor(message: string, readonly cause?: unknown) {
|
|
super(`Local Trigger command configuration is invalid: ${message}`);
|
|
this.name = 'LocalTriggerCommandConfigurationError';
|
|
}
|
|
}
|
|
|
|
function exactObject(
|
|
value: unknown,
|
|
required: readonly string[],
|
|
optional: readonly string[],
|
|
label: string,
|
|
): asserts value is Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
`${label} must be an object`,
|
|
);
|
|
}
|
|
const keys = Object.keys(value).sort();
|
|
const allowed = new Set([...required, ...optional]);
|
|
if (
|
|
required.some((key) => !keys.includes(key)) ||
|
|
keys.some((key) => !allowed.has(key))
|
|
) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
`${label} shape is invalid`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function boundedPath(value: unknown, label: string): string {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
!path.isAbsolute(value) ||
|
|
path.parse(value).root === value ||
|
|
path.normalize(value) !== value ||
|
|
value.includes('\0') ||
|
|
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
|
) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
`${label} must be a normalized bounded absolute non-root path`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function descendant(root: string, candidate: string, label: string): void {
|
|
const relative = path.relative(root, candidate);
|
|
if (
|
|
relative.length === 0 ||
|
|
relative === '..' ||
|
|
relative.startsWith(`..${path.sep}`) ||
|
|
path.isAbsolute(relative)
|
|
) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
`${label} must be a descendant of deploymentRoot`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeOptions(
|
|
value: unknown,
|
|
): Readonly<LocalTriggerCommandOptions> {
|
|
const hasBusyTimeout =
|
|
!!value &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
Object.hasOwn(value, 'busyTimeoutMs');
|
|
exactObject(
|
|
value,
|
|
[
|
|
'credentialFilePath',
|
|
'databasePath',
|
|
'deploymentRoot',
|
|
'ownerPepperKeyringDirectory',
|
|
'profile',
|
|
...(hasBusyTimeout ? ['busyTimeoutMs'] : []),
|
|
],
|
|
[],
|
|
'options',
|
|
);
|
|
const deploymentRoot = boundedPath(value.deploymentRoot, 'deploymentRoot');
|
|
for (const key of [
|
|
'databasePath',
|
|
'ownerPepperKeyringDirectory',
|
|
'credentialFilePath',
|
|
] as const) {
|
|
const candidate = boundedPath(value[key], key);
|
|
descendant(deploymentRoot, candidate, key);
|
|
}
|
|
if (value.profile !== 'edge' && value.profile !== 'standalone') {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
'profile must be edge or standalone',
|
|
);
|
|
}
|
|
if (
|
|
value.busyTimeoutMs !== undefined &&
|
|
(!Number.isSafeInteger(value.busyTimeoutMs) ||
|
|
(value.busyTimeoutMs as number) < 100 ||
|
|
(value.busyTimeoutMs as number) > 30_000)
|
|
) {
|
|
throw new LocalTriggerCommandConfigurationError('busyTimeoutMs is invalid');
|
|
}
|
|
return Object.freeze(value as unknown as LocalTriggerCommandOptions);
|
|
}
|
|
|
|
function requestIdentity(
|
|
value: Record<string, unknown>,
|
|
eventKeys: readonly string[],
|
|
): void {
|
|
if (
|
|
typeof value.requestId !== 'string' ||
|
|
!REQUEST_ID_PATTERN.test(value.requestId) ||
|
|
eventKeys.some(
|
|
(key) =>
|
|
typeof value[key] !== 'string' ||
|
|
!UUID_V4_PATTERN.test(value[key] as string),
|
|
) ||
|
|
new Set(eventKeys.map((key) => value[key])).size !== eventKeys.length
|
|
) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
'request identity is invalid',
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizePutRequest(
|
|
value: unknown,
|
|
): Readonly<PutLocalTriggerCommand['request']> {
|
|
exactObject(
|
|
value,
|
|
[
|
|
'enabled',
|
|
'expectedRevision',
|
|
'failureAuditEventId',
|
|
'mutationId',
|
|
'occurredAtMs',
|
|
'projectId',
|
|
'requestId',
|
|
'spec',
|
|
'taskContentDigest',
|
|
'taskId',
|
|
'taskRevision',
|
|
'triggerId',
|
|
],
|
|
[],
|
|
'request',
|
|
);
|
|
requestIdentity(value, ['failureAuditEventId', 'mutationId']);
|
|
try {
|
|
const {
|
|
failureAuditEventId: _failure,
|
|
requestId: _request,
|
|
...command
|
|
} = value;
|
|
normalizeAppendTriggerRevisionCommand(
|
|
command as unknown as AppendTriggerRevisionCommand,
|
|
);
|
|
} catch (error) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
'Trigger request is invalid',
|
|
error,
|
|
);
|
|
}
|
|
return Object.freeze(value as unknown as PutLocalTriggerCommand['request']);
|
|
}
|
|
|
|
function normalizeInspectRequest(
|
|
value: unknown,
|
|
): Readonly<InspectLocalTriggerCommand['request']> {
|
|
exactObject(
|
|
value,
|
|
[
|
|
'auditEventId',
|
|
'failureAuditEventId',
|
|
'projectId',
|
|
'requestId',
|
|
'triggerId',
|
|
],
|
|
[],
|
|
'request',
|
|
);
|
|
requestIdentity(value, ['auditEventId', 'failureAuditEventId']);
|
|
try {
|
|
assertTriggerIdentifier(value.projectId, 'projectId');
|
|
assertTriggerIdentifier(value.triggerId, 'triggerId');
|
|
} catch (error) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
'Trigger identity is invalid',
|
|
error,
|
|
);
|
|
}
|
|
return Object.freeze(
|
|
value as unknown as InspectLocalTriggerCommand['request'],
|
|
);
|
|
}
|
|
|
|
function normalizeListRequest(
|
|
value: unknown,
|
|
): Readonly<ListLocalTriggersCommand['request']> {
|
|
exactObject(
|
|
value,
|
|
['auditEventId', 'failureAuditEventId', 'limit', 'projectId', 'requestId'],
|
|
['after'],
|
|
'request',
|
|
);
|
|
requestIdentity(value, ['auditEventId', 'failureAuditEventId']);
|
|
try {
|
|
assertTriggerIdentifier(value.projectId, 'projectId');
|
|
assertTriggerPageSize(value.limit as number);
|
|
const after =
|
|
value.after === undefined
|
|
? undefined
|
|
: normalizeTriggerCursor(
|
|
value.after as Readonly<{ readonly triggerId: string }>,
|
|
);
|
|
return Object.freeze({
|
|
...(value as unknown as ListLocalTriggersCommand['request']),
|
|
...(after ? { after } : {}),
|
|
});
|
|
} catch (error) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
'Trigger list request is invalid',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeCommand(value: unknown): Readonly<LocalTriggerCommand> {
|
|
exactObject(
|
|
value,
|
|
['operation', 'options', 'request', 'schemaVersion'],
|
|
[],
|
|
'command',
|
|
);
|
|
if (
|
|
value.schemaVersion !== 1 ||
|
|
!['trigger.inspect', 'trigger.list', 'trigger.put'].includes(
|
|
value.operation as string,
|
|
)
|
|
) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
'command version or operation is invalid',
|
|
);
|
|
}
|
|
const options = normalizeOptions(value.options);
|
|
if (value.operation === 'trigger.put') {
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
operation: 'trigger.put',
|
|
options,
|
|
request: normalizePutRequest(value.request),
|
|
});
|
|
}
|
|
if (value.operation === 'trigger.inspect') {
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
operation: 'trigger.inspect',
|
|
options,
|
|
request: normalizeInspectRequest(value.request),
|
|
});
|
|
}
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
operation: 'trigger.list',
|
|
options,
|
|
request: normalizeListRequest(value.request),
|
|
});
|
|
}
|
|
|
|
function readCommandFile(candidatePath: string): Readonly<LocalTriggerCommand> {
|
|
try {
|
|
return normalizeCommand(readPrivateLocalCommandFile(candidatePath));
|
|
} catch (error) {
|
|
if (error instanceof LocalTriggerCommandConfigurationError) throw error;
|
|
if (error instanceof PrivateLocalCommandFileError) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
'command file cannot be read',
|
|
error,
|
|
);
|
|
}
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
'command file cannot be read',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
function summary(value: Readonly<TriggerRecord>): LocalTriggerSummary {
|
|
return Object.freeze({
|
|
projectId: value.projectId,
|
|
triggerId: value.triggerId,
|
|
revision: value.revision,
|
|
taskId: value.taskId,
|
|
taskRevision: value.taskRevision,
|
|
taskContentDigest: value.taskContentDigest,
|
|
schema: value.spec.schema,
|
|
enabled: value.enabled,
|
|
contentDigest: value.contentDigest,
|
|
createdAtMs: value.createdAtMs,
|
|
updatedAtMs: value.updatedAtMs,
|
|
});
|
|
}
|
|
|
|
function failureAudit(
|
|
command: Readonly<LocalTriggerCommand>,
|
|
authenticated: Readonly<AuthenticatedLocalCommand> | undefined,
|
|
error: unknown,
|
|
occurredAtMs: number,
|
|
): Readonly<SecurityAuditRecord> | null {
|
|
if (
|
|
error instanceof LocalTriggerAdministrationAuthenticationError ||
|
|
error instanceof LocalTriggerAdministrationAuthorizationError ||
|
|
error instanceof LocalTriggerAdministrationUnavailableError
|
|
) {
|
|
return null;
|
|
}
|
|
let outcome: SecurityAuditRecord['outcome'];
|
|
let reason: string;
|
|
if (
|
|
!authenticated ||
|
|
error instanceof AuthenticatedLocalCommandAuthenticationError
|
|
) {
|
|
outcome = 'authentication_rejected';
|
|
reason = 'credential_rejected';
|
|
} else if (
|
|
error instanceof LocalSqliteAuthenticatedManagementFenceError ||
|
|
error instanceof TriggerAdministrationAuthorizationFenceConflictError
|
|
) {
|
|
outcome = 'denied';
|
|
reason = 'credential_or_policy_fence_rejected';
|
|
} else if (
|
|
error instanceof TriggerConflictError ||
|
|
error instanceof TriggerAdministrationMutationConflictError
|
|
) {
|
|
outcome = 'denied';
|
|
reason = 'trigger_conflict';
|
|
} else if (
|
|
error instanceof InvalidTriggerError ||
|
|
error instanceof InvalidTriggerSpecSemanticError ||
|
|
error instanceof UnsupportedTriggerSpecError ||
|
|
error instanceof LocalTriggerAdministrationConfigurationError ||
|
|
error instanceof LocalTriggerCommandConfigurationError
|
|
) {
|
|
outcome = 'denied';
|
|
reason = 'trigger_rejected';
|
|
} else if (error instanceof TriggerUnavailableError) {
|
|
return null;
|
|
} else {
|
|
return null;
|
|
}
|
|
return Object.freeze({
|
|
eventId: command.request.failureAuditEventId,
|
|
requestId: command.request.requestId,
|
|
operationId:
|
|
command.operation === 'trigger.put'
|
|
? command.request.expectedRevision === null
|
|
? 'trigger.create'
|
|
: 'trigger.update'
|
|
: 'trigger.read',
|
|
projectId: command.request.projectId,
|
|
subject: authenticated?.principal.subject ?? null,
|
|
authenticationId: authenticated?.principal.authenticationId ?? null,
|
|
outcome,
|
|
reasons: Object.freeze([reason]),
|
|
fence: null,
|
|
occurredAtMs,
|
|
});
|
|
}
|
|
|
|
function dependencies(
|
|
value: LocalTriggerCommandRunnerDependencies,
|
|
): Readonly<LocalTriggerCommandRunnerDependencies> {
|
|
if (
|
|
!value ||
|
|
typeof value !== 'object' ||
|
|
Array.isArray(value) ||
|
|
Object.keys(value).sort().join('\0') !==
|
|
['authenticate', 'createService', 'now', 'openDatabase']
|
|
.sort()
|
|
.join('\0') ||
|
|
typeof value.openDatabase !== 'function' ||
|
|
typeof value.authenticate !== 'function' ||
|
|
typeof value.createService !== 'function' ||
|
|
typeof value.now !== 'function'
|
|
) {
|
|
throw new LocalTriggerCommandConfigurationError(
|
|
'runner dependencies are invalid',
|
|
);
|
|
}
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
async function activateFence(
|
|
database: LocalSqliteTriggerAdministrationDatabase,
|
|
authenticated: Readonly<AuthenticatedLocalCommand>,
|
|
): Promise<void> {
|
|
await authenticated.confirm();
|
|
database.activateUserCredentialFence(
|
|
authenticated.databaseFence as Readonly<LocalSqliteAuthenticatedUserCredentialFence>,
|
|
);
|
|
}
|
|
|
|
export function createLocalTriggerCommandRunner(
|
|
candidateDependencies: LocalTriggerCommandRunnerDependencies = {
|
|
openDatabase: openLocalSqliteTriggerAdministrationDatabase,
|
|
authenticate: establishAuthenticatedLocalCommand,
|
|
createService: createLocalTriggerAdministrationService,
|
|
now: Date.now,
|
|
},
|
|
): LocalTriggerCommandRunner {
|
|
const adapters = dependencies(candidateDependencies);
|
|
return Object.freeze({
|
|
async run(commandFilePath: string) {
|
|
const command = readCommandFile(commandFilePath);
|
|
const database = await adapters.openDatabase({
|
|
databasePath: command.options.databasePath,
|
|
profile: command.options.profile,
|
|
...(command.options.busyTimeoutMs === undefined
|
|
? {}
|
|
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
|
});
|
|
let authenticated: Readonly<AuthenticatedLocalCommand> | undefined;
|
|
try {
|
|
try {
|
|
authenticated = await adapters.authenticate(database, {
|
|
deploymentRoot: command.options.deploymentRoot,
|
|
databasePath: command.options.databasePath,
|
|
ownerPepperKeyringDirectory:
|
|
command.options.ownerPepperKeyringDirectory,
|
|
credentialFilePath: command.options.credentialFilePath,
|
|
authenticationNamespace: 'local_trigger',
|
|
});
|
|
await activateFence(database, authenticated);
|
|
const service = adapters.createService(
|
|
database.projectPolicy,
|
|
database.triggerAdministration,
|
|
database.triggers,
|
|
database.securityAudit,
|
|
{ now: adapters.now },
|
|
);
|
|
if (command.operation === 'trigger.put') {
|
|
const { failureAuditEventId: _failure, ...request } =
|
|
command.request;
|
|
const result = await service.put({
|
|
...request,
|
|
principal: authenticated.principal,
|
|
});
|
|
return Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
operation: command.operation,
|
|
status: result.status,
|
|
trigger: summary(result.trigger),
|
|
});
|
|
}
|
|
if (command.operation === 'trigger.inspect') {
|
|
const { failureAuditEventId: _failure, ...request } =
|
|
command.request;
|
|
const trigger = await service.inspect({
|
|
...request,
|
|
principal: authenticated.principal,
|
|
});
|
|
return trigger
|
|
? Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
operation: command.operation,
|
|
found: true as const,
|
|
trigger: summary(trigger),
|
|
})
|
|
: Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
operation: command.operation,
|
|
found: false as const,
|
|
projectId: command.request.projectId,
|
|
triggerId: command.request.triggerId,
|
|
});
|
|
}
|
|
const { failureAuditEventId: _failure, ...request } = command.request;
|
|
const page = await service.list({
|
|
...request,
|
|
principal: authenticated.principal,
|
|
});
|
|
return Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
operation: command.operation,
|
|
projectId: command.request.projectId,
|
|
triggers: Object.freeze(page.triggers.map(summary)),
|
|
nextCursor: page.next ?? null,
|
|
});
|
|
} catch (error) {
|
|
const audit = failureAudit(
|
|
command,
|
|
authenticated,
|
|
error,
|
|
adapters.now(),
|
|
);
|
|
if (audit) await database.securityAudit.record(audit);
|
|
throw error;
|
|
}
|
|
} finally {
|
|
await database.close();
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function runLocalTriggerCommandFile(
|
|
commandFilePath: string,
|
|
): Promise<LocalTriggerCommandResult> {
|
|
return createLocalTriggerCommandRunner().run(commandFilePath);
|
|
}
|