Files
qinglong/packages/ql3-local-sqlite/test/triggerRepository.test.cjs
T

341 lines
10 KiB
JavaScript

const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
TriggerConflictError,
TriggerUnavailableError,
UnsupportedTriggerSpecError,
createTriggerSpecSemanticRegistry,
} = require('@qinglong/runtime-core/trigger');
const {
migrateLocalSqlitePath,
openLocalSqliteRuntimeDatabase,
} = require('../dist');
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-trigger-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return path.join(directory, 'qinglong3.sqlite');
}
function taskCommand(index, overrides = {}) {
const suffix = String(index).padStart(12, '0');
return {
projectId: 'default',
taskId: `task-${String(index).padStart(5, '0')}`,
expectedRevision: null,
mutationId: `019f7310-0000-7000-8000-${suffix}`,
name: `Task ${index}`,
kind: 'command',
spec: {
schema: 'qinglong/command@v1',
config: {
command: { kind: 'argv', file: '/bin/echo', args: [String(index)] },
},
},
labels: { source: 'trigger-test' },
enabled: true,
occurredAtMs: 100 + index,
...overrides,
};
}
function triggerCommand(index, task, overrides = {}) {
const suffix = String(index).padStart(12, '0');
return {
projectId: task.projectId,
triggerId: `trigger-${String(index).padStart(5, '0')}`,
expectedRevision: null,
mutationId: `019f7320-0000-7000-8000-${suffix}`,
taskId: task.taskId,
taskRevision: task.revision,
taskContentDigest: task.contentDigest,
spec: {
schema: 'qinglong/cron@v1',
config: {
expression: '*/5 * * * *',
timezone: 'Etc/UTC',
misfirePolicy: 'skip',
},
},
enabled: true,
occurredAtMs: 200 + index,
...overrides,
};
}
async function createRuntime(t, options = {}) {
const databasePath = fixture(t);
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
const runtime = await openLocalSqliteRuntimeDatabase(
{ databasePath, profile: 'edge' },
options,
);
t.after(() => runtime.close());
return { databasePath, runtime };
}
test('creates, replays and versions a Trigger pinned to immutable task revisions', async (t) => {
const { runtime } = await createRuntime(t);
const firstTask = (
await runtime.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
).definition;
const firstInput = triggerCommand(1, firstTask);
const created = await runtime.triggers.appendTriggerRevision(firstInput);
assert.equal(created.status, 'created');
assert.equal(created.trigger.revision, 1);
assert.deepEqual({ ...created.trigger.spec.config }, {
expression: '*/5 * * * *',
misfirePolicy: 'skip',
timezone: 'UTC',
});
assert.equal(
(await runtime.triggers.appendTriggerRevision(firstInput)).status,
'existing',
);
const secondTask = (
await runtime.taskDefinitions.appendTaskDefinitionRevision(
taskCommand(1, {
expectedRevision: 1,
mutationId: '019f7310-0000-7000-8000-000000010001',
name: 'Task 1 revision 2',
occurredAtMs: 300,
}),
)
).definition;
const updated = await runtime.triggers.appendTriggerRevision(
triggerCommand(1, secondTask, {
expectedRevision: 1,
mutationId: '019f7320-0000-7000-8000-000000010001',
occurredAtMs: 400,
}),
);
assert.equal(updated.status, 'updated');
assert.equal(updated.trigger.taskRevision, 2);
assert.equal(updated.trigger.createdAtMs, created.trigger.createdAtMs);
assert.equal(
(
await runtime.triggers.findTriggerRevision(
'default',
firstInput.triggerId,
1,
)
).taskRevision,
1,
);
assert.equal(
(
await runtime.triggers.findCurrentTrigger(
'default',
firstInput.triggerId,
)
).taskRevision,
2,
);
});
test('rejects digest drift, disabled targets, stale CAS and task rebinding', async (t) => {
const { runtime } = await createRuntime(t);
const task1 = (
await runtime.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
).definition;
await assert.rejects(
runtime.triggers.appendTriggerRevision(
triggerCommand(1, task1, { taskContentDigest: 'b'.repeat(64) }),
),
TriggerConflictError,
);
const disabledTask = (
await runtime.taskDefinitions.appendTaskDefinitionRevision(
taskCommand(2, { enabled: false }),
)
).definition;
await assert.rejects(
runtime.triggers.appendTriggerRevision(triggerCommand(2, disabledTask)),
TriggerConflictError,
);
const disabledTrigger = await runtime.triggers.appendTriggerRevision(
triggerCommand(2, disabledTask, {
enabled: false,
mutationId: '019f7320-0000-7000-8000-000000020001',
}),
);
assert.equal(disabledTrigger.trigger.enabled, false);
const first = await runtime.triggers.appendTriggerRevision(
triggerCommand(1, task1),
);
await assert.rejects(
runtime.triggers.appendTriggerRevision(
triggerCommand(1, task1, {
expectedRevision: null,
mutationId: '019f7320-0000-7000-8000-000000010010',
}),
),
TriggerConflictError,
);
await assert.rejects(
runtime.triggers.appendTriggerRevision(
triggerCommand(1, disabledTask, {
enabled: false,
expectedRevision: first.trigger.revision,
mutationId: '019f7320-0000-7000-8000-000000010011',
occurredAtMs: 500,
}),
),
TriggerConflictError,
);
});
test('paginates current Trigger heads and serializes competing revisions', async (t) => {
const { runtime } = await createRuntime(t);
const task = (
await runtime.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
).definition;
for (const index of [1, 2, 3]) {
await runtime.triggers.appendTriggerRevision(triggerCommand(index, task));
}
const first = await runtime.triggers.listTriggers({
projectId: 'default',
limit: 2,
});
assert.equal(first.triggers.length, 2);
assert.equal(first.truncated, true);
const second = await runtime.triggers.listTriggers({
projectId: 'default',
limit: 2,
after: first.next,
});
assert.equal(second.triggers.length, 1);
assert.equal(second.truncated, false);
const base = first.triggers[0];
const results = await Promise.allSettled([
runtime.triggers.appendTriggerRevision(
triggerCommand(1, task, {
expectedRevision: base.revision,
mutationId: '019f7320-0000-7000-8000-000000010101',
occurredAtMs: 600,
}),
),
runtime.triggers.appendTriggerRevision(
triggerCommand(1, task, {
expectedRevision: base.revision,
mutationId: '019f7320-0000-7000-8000-000000010102',
occurredAtMs: 601,
}),
),
]);
assert.equal(results.filter(({ status }) => status === 'fulfilled').length, 1);
assert.equal(results.filter(({ status }) => status === 'rejected').length, 1);
});
test('fails closed when a durable Trigger or pinned task digest is corrupt', async (t) => {
const { databasePath, runtime } = await createRuntime(t);
const task = (
await runtime.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
).definition;
const input = triggerCommand(1, task);
const trigger = (
await runtime.triggers.appendTriggerRevision(input)
).trigger;
await runtime.close();
const client = new DatabaseSync(databasePath);
client
.prepare(
`UPDATE "QingLong3TriggerRevisions" SET "content_digest" = ?
WHERE "project_id" = ? AND "trigger_id" = ? AND "revision" = ?`,
)
.run('b'.repeat(64), trigger.projectId, trigger.triggerId, trigger.revision);
client.close();
const reader = await openLocalSqliteRuntimeDatabase({
databasePath,
profile: 'edge',
});
t.after(() => reader.close());
await assert.rejects(
reader.triggers.findCurrentTrigger(trigger.projectId, trigger.triggerId),
TriggerUnavailableError,
);
await reader.close();
const taskClient = new DatabaseSync(databasePath);
taskClient
.prepare(
`UPDATE "QingLong3TriggerRevisions" SET "content_digest" = ?
WHERE "project_id" = ? AND "trigger_id" = ? AND "revision" = ?`,
)
.run(
trigger.contentDigest,
trigger.projectId,
trigger.triggerId,
trigger.revision,
);
taskClient
.prepare(
`UPDATE "QingLong3TaskDefinitionRevisions" SET "content_digest" = ?
WHERE "project_id" = ? AND "task_id" = ? AND "revision" = ?`,
)
.run('b'.repeat(64), task.projectId, task.taskId, task.revision);
taskClient.close();
const replayReader = await openLocalSqliteRuntimeDatabase({
databasePath,
profile: 'edge',
});
t.after(() => replayReader.close());
await assert.rejects(
replayReader.triggers.appendTriggerRevision(input),
TriggerUnavailableError,
);
});
test('reads historical extension specs without loading their write semantics', async (t) => {
const databasePath = fixture(t);
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
const registry = createTriggerSpecSemanticRegistry([
{
schema: 'example/event@v1',
normalizeConfig(config) {
return Object.freeze({ topic: String(config.topic).toLowerCase() });
},
},
]);
const writer = await openLocalSqliteRuntimeDatabase(
{ databasePath, profile: 'edge' },
{ triggerSpecSemanticRegistry: registry },
);
const task = (
await writer.taskDefinitions.appendTaskDefinitionRevision(taskCommand(1))
).definition;
const input = triggerCommand(1, task, {
spec: { schema: 'example/event@v1', config: { topic: 'BUILD' } },
});
await writer.triggers.appendTriggerRevision(input);
await writer.close();
const reader = await openLocalSqliteRuntimeDatabase({
databasePath,
profile: 'edge',
});
t.after(() => reader.close());
assert.equal(
(
await reader.triggers.findCurrentTrigger(
input.projectId,
input.triggerId,
)
).spec.config.topic,
'build',
);
await assert.rejects(
async () => reader.triggers.appendTriggerRevision(input),
UnsupportedTriggerSpecError,
);
});