@rangka/core 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/.claude/skills/extend-core/SKILL.md +0 -133
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -18
- package/CLAUDE.md +0 -180
- package/src/__tests__/coerce.test.ts +0 -154
- package/src/__tests__/context.test.ts +0 -111
- package/src/__tests__/helpers.ts +0 -21
- package/src/__tests__/index.test.ts +0 -7
- package/src/__tests__/widgets.test.ts +0 -197
- package/src/api/__tests__/handlers.test.ts +0 -389
- package/src/api/__tests__/include-resolver.test.ts +0 -393
- package/src/api/__tests__/middleware.test.ts +0 -100
- package/src/api/__tests__/openapi-schema.test.ts +0 -210
- package/src/api/__tests__/query-parser.test.ts +0 -291
- package/src/api/__tests__/route-generator.test.ts +0 -137
- package/src/api/__tests__/server.test.ts +0 -73
- package/src/api/__tests__/swagger.test.ts +0 -166
- package/src/api/handlers.ts +0 -274
- package/src/api/include-resolver.ts +0 -27
- package/src/api/index.ts +0 -4
- package/src/api/meta-handler.ts +0 -254
- package/src/api/openapi-schema.ts +0 -99
- package/src/api/query-parser.ts +0 -315
- package/src/api/route-generator.ts +0 -448
- package/src/api/server.ts +0 -147
- package/src/api/types.ts +0 -16
- package/src/audit/__tests__/audit.test.ts +0 -144
- package/src/audit/index.ts +0 -3
- package/src/audit/record.ts +0 -69
- package/src/audit/tables.ts +0 -48
- package/src/audit/types.ts +0 -26
- package/src/auth/__tests__/core-module.test.ts +0 -54
- package/src/auth/__tests__/debug.test.ts +0 -47
- package/src/auth/__tests__/field-permissions.test.ts +0 -245
- package/src/auth/__tests__/integration.test.ts +0 -208
- package/src/auth/__tests__/meta-boot.test.ts +0 -538
- package/src/auth/__tests__/model-permissions.test.ts +0 -205
- package/src/auth/__tests__/password.test.ts +0 -29
- package/src/auth/__tests__/permission-registry.test.ts +0 -313
- package/src/auth/__tests__/scope-hook.test.ts +0 -509
- package/src/auth/__tests__/scope-registry.test.ts +0 -297
- package/src/auth/__tests__/scopes.test.ts +0 -66
- package/src/auth/__tests__/session.test.ts +0 -214
- package/src/auth/core-models.ts +0 -52
- package/src/auth/core-module.ts +0 -59
- package/src/auth/debug.ts +0 -157
- package/src/auth/field-permissions.ts +0 -116
- package/src/auth/index.ts +0 -37
- package/src/auth/model-permissions.ts +0 -59
- package/src/auth/password.ts +0 -22
- package/src/auth/permission-registry.ts +0 -171
- package/src/auth/scope-filters.ts +0 -11
- package/src/auth/scope-registry.ts +0 -121
- package/src/auth/scopes.ts +0 -146
- package/src/auth/seed.ts +0 -44
- package/src/auth/session.ts +0 -178
- package/src/auth/types.ts +0 -50
- package/src/boot/__tests__/page-scanning.test.ts +0 -170
- package/src/boot/__tests__/page-utils.test.ts +0 -225
- package/src/boot/__tests__/project-scanner.test.ts +0 -88
- package/src/boot/dependency-sort.ts +0 -82
- package/src/boot/discovery.ts +0 -85
- package/src/boot/index.ts +0 -457
- package/src/boot/page-utils.ts +0 -110
- package/src/boot/project-scanner.ts +0 -397
- package/src/boot/schema-loader.ts +0 -26
- package/src/boot/schema-merger.ts +0 -125
- package/src/boot/traits.ts +0 -25
- package/src/boot/types.ts +0 -73
- package/src/context.ts +0 -105
- package/src/db/__tests__/cascade-delete.test.ts +0 -182
- package/src/db/__tests__/desired-state.test.ts +0 -136
- package/src/db/__tests__/diff-engine.test.ts +0 -635
- package/src/db/__tests__/field-mapper.test.ts +0 -355
- package/src/db/__tests__/introspect.test.ts +0 -70
- package/src/db/__tests__/search-filter.test.ts +0 -45
- package/src/db/__tests__/sequence.test.ts +0 -221
- package/src/db/auto-sync.ts +0 -133
- package/src/db/client.ts +0 -147
- package/src/db/desired-state.ts +0 -98
- package/src/db/diff-engine.ts +0 -305
- package/src/db/field-mapper.ts +0 -504
- package/src/db/filter-applier.ts +0 -89
- package/src/db/include-resolver.ts +0 -40
- package/src/db/index.ts +0 -23
- package/src/db/introspect.ts +0 -265
- package/src/db/model-include-resolver.ts +0 -327
- package/src/db/model-ops.ts +0 -281
- package/src/db/scope-enforcer.ts +0 -37
- package/src/db/types.ts +0 -98
- package/src/errors.ts +0 -41
- package/src/events/__tests__/bus.test.ts +0 -105
- package/src/events/bus.ts +0 -89
- package/src/events/index.ts +0 -2
- package/src/events/types.ts +0 -9
- package/src/external-model/__tests__/computed-fields.test.ts +0 -106
- package/src/external-model/__tests__/field-mapper.test.ts +0 -160
- package/src/external-model/__tests__/in-memory-ops.test.ts +0 -247
- package/src/external-model/__tests__/mutation-executor.test.ts +0 -160
- package/src/external-model/__tests__/query-executor.test.ts +0 -284
- package/src/external-model/__tests__/schema-converter.test.ts +0 -174
- package/src/external-model/computed-fields.ts +0 -15
- package/src/external-model/define.ts +0 -5
- package/src/external-model/external-model-ops.ts +0 -108
- package/src/external-model/field-mapper.ts +0 -66
- package/src/external-model/in-memory-ops.ts +0 -107
- package/src/external-model/index.ts +0 -7
- package/src/external-model/mutation-executor.ts +0 -71
- package/src/external-model/query-executor.ts +0 -100
- package/src/external-model/schema-converter.ts +0 -53
- package/src/external-model/types.ts +0 -32
- package/src/fixtures/__tests__/fixtures.test.ts +0 -203
- package/src/fixtures/index.ts +0 -10
- package/src/fixtures/loader.ts +0 -196
- package/src/fixtures/registry.ts +0 -125
- package/src/fixtures/types.ts +0 -33
- package/src/helpers/assert-ownership.ts +0 -19
- package/src/helpers/coerce.ts +0 -28
- package/src/helpers/stamping.ts +0 -28
- package/src/helpers/validation.ts +0 -14
- package/src/hooks/__tests__/context.test.ts +0 -73
- package/src/hooks/__tests__/executor.test.ts +0 -433
- package/src/hooks/__tests__/middleware.test.ts +0 -224
- package/src/hooks/__tests__/registry.test.ts +0 -50
- package/src/hooks/context.ts +0 -89
- package/src/hooks/errors.ts +0 -11
- package/src/hooks/executor.ts +0 -115
- package/src/hooks/index.ts +0 -10
- package/src/hooks/middleware.ts +0 -220
- package/src/hooks/registry.ts +0 -20
- package/src/hooks/types.ts +0 -32
- package/src/index.ts +0 -172
- package/src/jobs/__tests__/enqueue.test.ts +0 -77
- package/src/jobs/__tests__/integration.test.ts +0 -71
- package/src/jobs/__tests__/registry.test.ts +0 -103
- package/src/jobs/__tests__/scheduler.test.ts +0 -92
- package/src/jobs/__tests__/worker-execution.test.ts +0 -202
- package/src/jobs/__tests__/worker.test.ts +0 -119
- package/src/jobs/enqueue.ts +0 -93
- package/src/jobs/index.ts +0 -14
- package/src/jobs/registry.ts +0 -92
- package/src/jobs/scheduler.ts +0 -205
- package/src/jobs/tables.ts +0 -132
- package/src/jobs/types.ts +0 -62
- package/src/jobs/worker.ts +0 -272
- package/src/model-api/__tests__/cross-boundary-includes.test.ts +0 -366
- package/src/model-api/__tests__/extended-api.test.ts +0 -244
- package/src/model-api/__tests__/filter-applier.test.ts +0 -177
- package/src/model-api/__tests__/filter-translator.test.ts +0 -186
- package/src/model-api/__tests__/include-resolver.test.ts +0 -226
- package/src/model-api/__tests__/model-access.test.ts +0 -284
- package/src/model-api/__tests__/query-builder.test.ts +0 -224
- package/src/model-api/__tests__/scope-enforcer.test.ts +0 -268
- package/src/model-api/field-access.ts +0 -28
- package/src/model-api/filter-applier.ts +0 -1
- package/src/model-api/filter-translator.ts +0 -67
- package/src/model-api/include-resolver.ts +0 -2
- package/src/model-api/index.ts +0 -86
- package/src/model-api/query-builder.ts +0 -155
- package/src/model-api/scope-enforcer.ts +0 -3
- package/src/model-api/types.ts +0 -139
- package/src/plugins/__tests__/adapter-registry.test.ts +0 -92
- package/src/plugins/__tests__/lifecycle.test.ts +0 -96
- package/src/plugins/__tests__/loader.test.ts +0 -273
- package/src/plugins/__tests__/validator.test.ts +0 -275
- package/src/plugins/adapter-registry.ts +0 -42
- package/src/plugins/define.ts +0 -5
- package/src/plugins/index.ts +0 -28
- package/src/plugins/lifecycle.ts +0 -27
- package/src/plugins/loader.ts +0 -126
- package/src/plugins/types.ts +0 -76
- package/src/plugins/validator.ts +0 -141
- package/src/schema/__tests__/registry-models-by-module.test.ts +0 -58
- package/src/schema/registry.ts +0 -93
- package/src/schema/relationships.ts +0 -93
- package/src/schema/types.ts +0 -43
- package/src/services/__tests__/integration.test.ts +0 -63
- package/src/services/__tests__/registry.test.ts +0 -175
- package/src/services/index.ts +0 -13
- package/src/services/registry.ts +0 -156
- package/src/services/types.ts +0 -27
- package/src/validation/__tests__/field-validator.test.ts +0 -195
- package/src/validation/field-validator.ts +0 -113
- package/src/validation/index.ts +0 -1
- package/src/widgets/index.ts +0 -3
- package/src/widgets/slot-validator.ts +0 -87
- package/src/widgets/widget-registry.ts +0 -32
- package/tests/boot.test.ts +0 -323
- package/tests/dependency-sort.test.ts +0 -99
- package/tests/discovery.test.ts +0 -126
- package/tests/registry.test.ts +0 -216
- package/tests/schema-loader.test.ts +0 -52
- package/tests/schema-merger.test.ts +0 -180
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
- package/vitest.config.ts +0 -14
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { enqueue } from '../enqueue.js';
|
|
3
|
-
|
|
4
|
-
describe('enqueue', () => {
|
|
5
|
-
it('inserts a job with default options', async () => {
|
|
6
|
-
const rows = [{ id: 'job-1' }];
|
|
7
|
-
|
|
8
|
-
const mockExecute = vi.fn().mockResolvedValue({ rows });
|
|
9
|
-
vi.mock('kysely', async (importOriginal) => {
|
|
10
|
-
const actual = (await importOriginal()) as any;
|
|
11
|
-
return {
|
|
12
|
-
...actual,
|
|
13
|
-
sql: new Proxy(actual.sql, {
|
|
14
|
-
apply: (target: any, thisArg: any, args: any[]) => {
|
|
15
|
-
const result = target.apply(thisArg, args);
|
|
16
|
-
result.execute = mockExecute;
|
|
17
|
-
return result;
|
|
18
|
-
},
|
|
19
|
-
get: (target: any, prop: string) => {
|
|
20
|
-
if (prop === '__esModule') return true;
|
|
21
|
-
return target[prop];
|
|
22
|
-
},
|
|
23
|
-
}),
|
|
24
|
-
};
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
// Since mocking kysely's sql tag is complex, we test the function signature and logic
|
|
28
|
-
// by verifying it accepts the correct parameters
|
|
29
|
-
expect(typeof enqueue).toBe('function');
|
|
30
|
-
expect(enqueue.length).toBeGreaterThanOrEqual(2);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('computes start_after from delay option', () => {
|
|
34
|
-
const now = Date.now();
|
|
35
|
-
const delay = 5000;
|
|
36
|
-
const expected = new Date(now + delay);
|
|
37
|
-
|
|
38
|
-
// Verify the delay math is correct
|
|
39
|
-
expect(expected.getTime() - now).toBe(delay);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('uses job name as uniqueKey when unique=true but no key specified', () => {
|
|
43
|
-
const options: { unique: boolean; uniqueKey?: string } = { unique: true };
|
|
44
|
-
const name = 'my_job';
|
|
45
|
-
const uniqueKey = options.unique ? (options.uniqueKey ?? name) : null;
|
|
46
|
-
expect(uniqueKey).toBe('my_job');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('uses custom uniqueKey when provided', () => {
|
|
50
|
-
const options: { unique: boolean; uniqueKey?: string } = {
|
|
51
|
-
unique: true,
|
|
52
|
-
uniqueKey: 'custom:key',
|
|
53
|
-
};
|
|
54
|
-
const name = 'my_job';
|
|
55
|
-
const uniqueKey = options.unique ? (options.uniqueKey ?? name) : null;
|
|
56
|
-
expect(uniqueKey).toBe('custom:key');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('sets null uniqueKey when unique is false', () => {
|
|
60
|
-
const options: { unique: boolean; uniqueKey?: string } = {
|
|
61
|
-
unique: false,
|
|
62
|
-
uniqueKey: 'ignored',
|
|
63
|
-
};
|
|
64
|
-
const uniqueKey = options.unique ? (options.uniqueKey ?? 'name') : null;
|
|
65
|
-
expect(uniqueKey).toBeNull();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('defaults backoff to exponential', () => {
|
|
69
|
-
const backoff: string | undefined = undefined;
|
|
70
|
-
expect(backoff ?? 'exponential').toBe('exponential');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('defaults retries to 0', () => {
|
|
74
|
-
const retries: number | undefined = undefined;
|
|
75
|
-
expect(retries ?? 0).toBe(0);
|
|
76
|
-
});
|
|
77
|
-
});
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { createHookContext } from '../../hooks/context.js';
|
|
3
|
-
import { EventBus } from '../../events/bus.js';
|
|
4
|
-
import type { SchemaRegistry } from '../../schema/registry.js';
|
|
5
|
-
import type { RequestContext } from '../../auth/types.js';
|
|
6
|
-
|
|
7
|
-
function mockSchema(): SchemaRegistry {
|
|
8
|
-
return { getAllModels: () => [] } as unknown as SchemaRegistry;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function mockAuth(): RequestContext {
|
|
12
|
-
return {
|
|
13
|
-
user: { id: '1', email: 'test@test.com' },
|
|
14
|
-
roles: ['Admin'],
|
|
15
|
-
scopeFilters: [],
|
|
16
|
-
} as unknown as RequestContext;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
describe('Hook → EventBus integration', () => {
|
|
20
|
-
it('ctx.events.emit delegates to EventBus in sync mode', async () => {
|
|
21
|
-
const bus = new EventBus();
|
|
22
|
-
const handler = vi.fn().mockResolvedValue(undefined);
|
|
23
|
-
bus.on('invoice.created', handler);
|
|
24
|
-
|
|
25
|
-
const ctx = createHookContext({
|
|
26
|
-
trx: {},
|
|
27
|
-
schema: mockSchema(),
|
|
28
|
-
auth: mockAuth(),
|
|
29
|
-
eventBus: bus,
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// emitWithTrx with sync falls through to sync handler
|
|
33
|
-
const mockEmitWithTrx = vi.fn().mockResolvedValue(undefined);
|
|
34
|
-
(bus as any).emitWithTrx = mockEmitWithTrx;
|
|
35
|
-
|
|
36
|
-
await ctx.events.emit('invoice.created', { id: '123' });
|
|
37
|
-
|
|
38
|
-
expect(mockEmitWithTrx).toHaveBeenCalledWith(
|
|
39
|
-
'invoice.created',
|
|
40
|
-
{ id: '123' },
|
|
41
|
-
expect.anything(),
|
|
42
|
-
);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('EventBus sync emit runs listener inline', async () => {
|
|
46
|
-
const bus = new EventBus();
|
|
47
|
-
const results: string[] = [];
|
|
48
|
-
|
|
49
|
-
bus.on('job.done', async (payload: unknown) => {
|
|
50
|
-
const p = payload as { name: string };
|
|
51
|
-
results.push(p.name);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
await bus.emit('job.done', { name: 'test' }, { sync: true });
|
|
55
|
-
expect(results).toEqual(['test']);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('EventBus registers event handler that JobWorker can call', async () => {
|
|
59
|
-
const bus = new EventBus();
|
|
60
|
-
const handler = vi.fn().mockResolvedValue(undefined);
|
|
61
|
-
|
|
62
|
-
bus.on('order.shipped', handler);
|
|
63
|
-
|
|
64
|
-
const listeners = bus.getListeners('order.shipped');
|
|
65
|
-
expect(listeners).toHaveLength(1);
|
|
66
|
-
|
|
67
|
-
// Simulate what the worker does: call the handler directly
|
|
68
|
-
await listeners[0].handler({ orderId: '456' });
|
|
69
|
-
expect(handler).toHaveBeenCalledWith({ orderId: '456' });
|
|
70
|
-
});
|
|
71
|
-
});
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { JobRegistry } from '../registry.js';
|
|
3
|
-
|
|
4
|
-
describe('JobRegistry', () => {
|
|
5
|
-
const validHandler = async () => {};
|
|
6
|
-
|
|
7
|
-
it('registers a valid job', () => {
|
|
8
|
-
const registry = new JobRegistry();
|
|
9
|
-
registry.register('send_email', {
|
|
10
|
-
handler: validHandler,
|
|
11
|
-
concurrency: 5,
|
|
12
|
-
retries: 3,
|
|
13
|
-
backoff: 'exponential',
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
expect(registry.has('send_email')).toBe(true);
|
|
17
|
-
const job = registry.get('send_email');
|
|
18
|
-
expect(job?.name).toBe('send_email');
|
|
19
|
-
expect(job?.config.concurrency).toBe(5);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('throws on duplicate name', () => {
|
|
23
|
-
const registry = new JobRegistry();
|
|
24
|
-
registry.register('job1', { handler: validHandler });
|
|
25
|
-
|
|
26
|
-
expect(() => registry.register('job1', { handler: validHandler })).toThrow(
|
|
27
|
-
'Job "job1" is already registered',
|
|
28
|
-
);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('throws on invalid concurrency', () => {
|
|
32
|
-
const registry = new JobRegistry();
|
|
33
|
-
|
|
34
|
-
expect(() => registry.register('bad', { handler: validHandler, concurrency: 0 })).toThrow(
|
|
35
|
-
'Concurrency must be a positive integer',
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
expect(() => registry.register('bad2', { handler: validHandler, concurrency: -1 })).toThrow(
|
|
39
|
-
'Concurrency must be a positive integer',
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
expect(() => registry.register('bad3', { handler: validHandler, concurrency: 1.5 })).toThrow(
|
|
43
|
-
'Concurrency must be a positive integer',
|
|
44
|
-
);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('throws on invalid retries', () => {
|
|
48
|
-
const registry = new JobRegistry();
|
|
49
|
-
|
|
50
|
-
expect(() => registry.register('bad', { handler: validHandler, retries: -1 })).toThrow(
|
|
51
|
-
'Retries must be a non-negative integer',
|
|
52
|
-
);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('throws on invalid backoff', () => {
|
|
56
|
-
const registry = new JobRegistry();
|
|
57
|
-
|
|
58
|
-
expect(() =>
|
|
59
|
-
registry.register('bad', { handler: validHandler, backoff: 'unknown' as any }),
|
|
60
|
-
).toThrow('Backoff must be one of');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('throws on invalid cron schedule', () => {
|
|
64
|
-
const registry = new JobRegistry();
|
|
65
|
-
|
|
66
|
-
expect(() => registry.register('bad', { handler: validHandler, schedule: '* * *' })).toThrow(
|
|
67
|
-
'Schedule must be a valid 5-field cron expression',
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('accepts valid cron schedule', () => {
|
|
72
|
-
const registry = new JobRegistry();
|
|
73
|
-
registry.register('daily', { handler: validHandler, schedule: '0 2 * * *' });
|
|
74
|
-
|
|
75
|
-
expect(registry.has('daily')).toBe(true);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('throws on missing handler', () => {
|
|
79
|
-
const registry = new JobRegistry();
|
|
80
|
-
|
|
81
|
-
expect(() => registry.register('bad', { handler: null as any })).toThrow(
|
|
82
|
-
'Job handler must be a function',
|
|
83
|
-
);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('getAll returns all registered jobs', () => {
|
|
87
|
-
const registry = new JobRegistry();
|
|
88
|
-
registry.register('a', { handler: validHandler });
|
|
89
|
-
registry.register('b', { handler: validHandler });
|
|
90
|
-
|
|
91
|
-
expect(registry.getAll()).toHaveLength(2);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('getScheduled returns only jobs with a schedule', () => {
|
|
95
|
-
const registry = new JobRegistry();
|
|
96
|
-
registry.register('a', { handler: validHandler });
|
|
97
|
-
registry.register('b', { handler: validHandler, schedule: '0 * * * *' });
|
|
98
|
-
|
|
99
|
-
const scheduled = registry.getScheduled();
|
|
100
|
-
expect(scheduled).toHaveLength(1);
|
|
101
|
-
expect(scheduled[0].name).toBe('b');
|
|
102
|
-
});
|
|
103
|
-
});
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { ScheduleManager } from '../scheduler.js';
|
|
3
|
-
import { JobRegistry } from '../registry.js';
|
|
4
|
-
|
|
5
|
-
describe('ScheduleManager', () => {
|
|
6
|
-
const registry = new JobRegistry();
|
|
7
|
-
const mockDb = {} as any;
|
|
8
|
-
const manager = new ScheduleManager(mockDb, registry, 5000);
|
|
9
|
-
|
|
10
|
-
describe('computeNextRun', () => {
|
|
11
|
-
it('parses simple cron: every hour at minute 0', () => {
|
|
12
|
-
const from = new Date('2026-01-15T10:00:00Z');
|
|
13
|
-
const next = manager.computeNextRun('0 * * * *', from);
|
|
14
|
-
expect(next.getMinutes()).toBe(0);
|
|
15
|
-
expect(next.getTime()).toBeGreaterThan(from.getTime());
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('parses cron: daily at 2 AM', () => {
|
|
19
|
-
const from = new Date('2026-01-15T03:00:00Z');
|
|
20
|
-
const next = manager.computeNextRun('0 2 * * *', from);
|
|
21
|
-
expect(next.getHours()).toBe(2);
|
|
22
|
-
expect(next.getMinutes()).toBe(0);
|
|
23
|
-
expect(next.getDate()).toBe(16); // next day since 2AM already passed
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('parses cron: every 5 minutes', () => {
|
|
27
|
-
const from = new Date('2026-01-15T10:02:00Z');
|
|
28
|
-
const next = manager.computeNextRun('*/5 * * * *', from);
|
|
29
|
-
expect(next.getMinutes() % 5).toBe(0);
|
|
30
|
-
expect(next.getTime()).toBeGreaterThan(from.getTime());
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('parses cron: specific day of week (Monday = 1)', () => {
|
|
34
|
-
const from = new Date('2026-01-15T00:00:00Z'); // Wednesday
|
|
35
|
-
const next = manager.computeNextRun('0 9 * * 1', from);
|
|
36
|
-
expect(next.getDay()).toBe(1); // Monday
|
|
37
|
-
expect(next.getHours()).toBe(9);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('parses cron: specific day of month', () => {
|
|
41
|
-
const from = new Date('2026-01-15T00:00:00Z');
|
|
42
|
-
const next = manager.computeNextRun('30 8 20 * *', from);
|
|
43
|
-
expect(next.getDate()).toBe(20);
|
|
44
|
-
expect(next.getHours()).toBe(8);
|
|
45
|
-
expect(next.getMinutes()).toBe(30);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('parses cron with ranges', () => {
|
|
49
|
-
const from = new Date('2026-01-15T10:00:00Z');
|
|
50
|
-
const next = manager.computeNextRun('0 9-17 * * *', from);
|
|
51
|
-
expect(next.getHours()).toBeGreaterThanOrEqual(9);
|
|
52
|
-
expect(next.getHours()).toBeLessThanOrEqual(17);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('parses cron with comma-separated values', () => {
|
|
56
|
-
const from = new Date('2026-01-15T10:00:00Z');
|
|
57
|
-
const next = manager.computeNextRun('0,30 * * * *', from);
|
|
58
|
-
expect([0, 30]).toContain(next.getMinutes());
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('throws on invalid cron expression', () => {
|
|
62
|
-
expect(() => manager.computeNextRun('* * *')).toThrow('Invalid cron expression');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('next run is always in the future', () => {
|
|
66
|
-
const from = new Date('2026-01-15T10:30:00Z');
|
|
67
|
-
const next = manager.computeNextRun('30 10 * * *', from);
|
|
68
|
-
expect(next.getTime()).toBeGreaterThan(from.getTime());
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('lifecycle', () => {
|
|
73
|
-
it('starts and stops', async () => {
|
|
74
|
-
const mgr = new ScheduleManager(mockDb, registry, 60000);
|
|
75
|
-
expect(mgr.isRunning()).toBe(false);
|
|
76
|
-
|
|
77
|
-
mgr.start();
|
|
78
|
-
expect(mgr.isRunning()).toBe(true);
|
|
79
|
-
|
|
80
|
-
await mgr.stop();
|
|
81
|
-
expect(mgr.isRunning()).toBe(false);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('does not double-start', async () => {
|
|
85
|
-
const mgr = new ScheduleManager(mockDb, registry, 60000);
|
|
86
|
-
mgr.start();
|
|
87
|
-
mgr.start();
|
|
88
|
-
expect(mgr.isRunning()).toBe(true);
|
|
89
|
-
await mgr.stop();
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
});
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { JobWorker } from '../worker.js';
|
|
3
|
-
import { JobRegistry } from '../registry.js';
|
|
4
|
-
|
|
5
|
-
describe('JobWorker execution', () => {
|
|
6
|
-
const mockCtx = {
|
|
7
|
-
db: {},
|
|
8
|
-
schema: {},
|
|
9
|
-
auth: { user: null, roles: [] },
|
|
10
|
-
scope: null,
|
|
11
|
-
config: {},
|
|
12
|
-
models: {},
|
|
13
|
-
service: () => ({}),
|
|
14
|
-
enqueue: async () => {},
|
|
15
|
-
events: { emit: async () => {}, on: () => {} },
|
|
16
|
-
notify: () => {},
|
|
17
|
-
email: { send: async () => {} },
|
|
18
|
-
} as any;
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
vi.useFakeTimers();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
vi.useRealTimers();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('handler invocation', () => {
|
|
29
|
-
it('calls handler with job data and ctx', async () => {
|
|
30
|
-
vi.useRealTimers();
|
|
31
|
-
const registry = new JobRegistry();
|
|
32
|
-
const handler = vi.fn().mockRejectedValue(new Error('stop'));
|
|
33
|
-
registry.register('test.job', { handler });
|
|
34
|
-
|
|
35
|
-
const mockDb = {} as any;
|
|
36
|
-
const worker = new JobWorker(mockDb, registry, mockCtx, { pollInterval: 100 });
|
|
37
|
-
|
|
38
|
-
const executeJob = (worker as any).executeJob.bind(worker);
|
|
39
|
-
// Will throw in handleFailure since no real DB, but handler should have been called
|
|
40
|
-
try {
|
|
41
|
-
await executeJob({
|
|
42
|
-
id: 'job-1',
|
|
43
|
-
name: 'test.job',
|
|
44
|
-
data: { key: 'value' },
|
|
45
|
-
state: 'active',
|
|
46
|
-
retry_count: 0,
|
|
47
|
-
max_retries: 0,
|
|
48
|
-
backoff: 'exponential',
|
|
49
|
-
});
|
|
50
|
-
} catch {
|
|
51
|
-
/* expected */
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
expect(handler).toHaveBeenCalledWith({ key: 'value' }, mockCtx);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('does not call handler for unregistered job', async () => {
|
|
58
|
-
vi.useRealTimers();
|
|
59
|
-
const registry = new JobRegistry();
|
|
60
|
-
const mockDb = {} as any;
|
|
61
|
-
const worker = new JobWorker(mockDb, registry, mockCtx, { pollInterval: 100 });
|
|
62
|
-
|
|
63
|
-
const executeJob = (worker as any).executeJob.bind(worker);
|
|
64
|
-
await executeJob({
|
|
65
|
-
id: 'job-1',
|
|
66
|
-
name: 'unknown.job',
|
|
67
|
-
data: {},
|
|
68
|
-
state: 'active',
|
|
69
|
-
retry_count: 0,
|
|
70
|
-
max_retries: 0,
|
|
71
|
-
backoff: 'exponential',
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
expect(worker.getInFlightCount()).toBe(0);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
describe('backoff computation', () => {
|
|
79
|
-
it('exponential: doubles with each retry, capped at 600s', () => {
|
|
80
|
-
const registry = new JobRegistry();
|
|
81
|
-
const mockDb = {} as any;
|
|
82
|
-
const worker = new JobWorker(mockDb, registry, mockCtx);
|
|
83
|
-
|
|
84
|
-
const compute = (worker as any).computeBackoffDelay.bind(worker);
|
|
85
|
-
expect(compute('exponential', 1)).toBe(2000);
|
|
86
|
-
expect(compute('exponential', 2)).toBe(4000);
|
|
87
|
-
expect(compute('exponential', 3)).toBe(8000);
|
|
88
|
-
expect(compute('exponential', 10)).toBe(600000); // capped
|
|
89
|
-
expect(compute('exponential', 20)).toBe(600000); // still capped
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('linear: 10s * retryCount', () => {
|
|
93
|
-
const registry = new JobRegistry();
|
|
94
|
-
const mockDb = {} as any;
|
|
95
|
-
const worker = new JobWorker(mockDb, registry, mockCtx);
|
|
96
|
-
|
|
97
|
-
const compute = (worker as any).computeBackoffDelay.bind(worker);
|
|
98
|
-
expect(compute('linear', 1)).toBe(10000);
|
|
99
|
-
expect(compute('linear', 2)).toBe(20000);
|
|
100
|
-
expect(compute('linear', 5)).toBe(50000);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('fixed: always 5s', () => {
|
|
104
|
-
const registry = new JobRegistry();
|
|
105
|
-
const mockDb = {} as any;
|
|
106
|
-
const worker = new JobWorker(mockDb, registry, mockCtx);
|
|
107
|
-
|
|
108
|
-
const compute = (worker as any).computeBackoffDelay.bind(worker);
|
|
109
|
-
expect(compute('fixed', 1)).toBe(5000);
|
|
110
|
-
expect(compute('fixed', 5)).toBe(5000);
|
|
111
|
-
expect(compute('fixed', 100)).toBe(5000);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
describe('concurrency limit check', () => {
|
|
116
|
-
it('returns false when no limit set (Infinity)', async () => {
|
|
117
|
-
const registry = new JobRegistry();
|
|
118
|
-
const mockDb = {} as any;
|
|
119
|
-
const worker = new JobWorker(mockDb, registry, mockCtx);
|
|
120
|
-
|
|
121
|
-
const exceeds = (worker as any).exceedsConcurrencyLimit.bind(worker);
|
|
122
|
-
const result = await exceeds('test.job', undefined);
|
|
123
|
-
expect(result).toBe(false);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('returns false when limit is Infinity', async () => {
|
|
127
|
-
const registry = new JobRegistry();
|
|
128
|
-
const mockDb = {} as any;
|
|
129
|
-
const worker = new JobWorker(mockDb, registry, mockCtx);
|
|
130
|
-
|
|
131
|
-
const exceeds = (worker as any).exceedsConcurrencyLimit.bind(worker);
|
|
132
|
-
const result = await exceeds('test.job', Infinity);
|
|
133
|
-
expect(result).toBe(false);
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe('graceful shutdown', () => {
|
|
138
|
-
it('stop resolves immediately when no jobs in flight', async () => {
|
|
139
|
-
const registry = new JobRegistry();
|
|
140
|
-
const mockDb = {} as any;
|
|
141
|
-
const worker = new JobWorker(mockDb, registry, mockCtx, { pollInterval: 10000 });
|
|
142
|
-
|
|
143
|
-
worker.start();
|
|
144
|
-
expect(worker.isRunning()).toBe(true);
|
|
145
|
-
|
|
146
|
-
await worker.stop();
|
|
147
|
-
expect(worker.isRunning()).toBe(false);
|
|
148
|
-
expect(worker.getInFlightCount()).toBe(0);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('tracks in-flight jobs correctly during execution', async () => {
|
|
152
|
-
vi.useRealTimers();
|
|
153
|
-
|
|
154
|
-
const registry = new JobRegistry();
|
|
155
|
-
let resolveHandler: () => void;
|
|
156
|
-
const handlerPromise = new Promise<void>((r) => {
|
|
157
|
-
resolveHandler = r;
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
registry.register('test.slow', {
|
|
161
|
-
handler: async () => {
|
|
162
|
-
await handlerPromise;
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
const mockDb = {} as any;
|
|
167
|
-
const worker = new JobWorker(mockDb, registry, mockCtx);
|
|
168
|
-
|
|
169
|
-
// Simulate adding to in-flight
|
|
170
|
-
(worker as any).activeJobIds.add('job-1');
|
|
171
|
-
expect(worker.getInFlightCount()).toBe(1);
|
|
172
|
-
|
|
173
|
-
(worker as any).activeJobIds.delete('job-1');
|
|
174
|
-
expect(worker.getInFlightCount()).toBe(0);
|
|
175
|
-
|
|
176
|
-
resolveHandler!();
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
describe('failure handling decision', () => {
|
|
181
|
-
it('moves to dead letter when retry_count + 1 > max_retries', () => {
|
|
182
|
-
// retry_count = 2, max_retries = 2 → attemptNumber = 3 > 2 → dead letter
|
|
183
|
-
const attemptNumber = 2 + 1;
|
|
184
|
-
const maxRetries = 2;
|
|
185
|
-
expect(attemptNumber > maxRetries).toBe(true);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('schedules retry when retry_count + 1 <= max_retries', () => {
|
|
189
|
-
// retry_count = 0, max_retries = 2 → attemptNumber = 1 <= 2 → retry
|
|
190
|
-
const attemptNumber = 0 + 1;
|
|
191
|
-
const maxRetries = 2;
|
|
192
|
-
expect(attemptNumber > maxRetries).toBe(false);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('zero retries means immediate dead letter on first failure', () => {
|
|
196
|
-
// retry_count = 0, max_retries = 0 → attemptNumber = 1 > 0 → dead letter
|
|
197
|
-
const attemptNumber = 0 + 1;
|
|
198
|
-
const maxRetries = 0;
|
|
199
|
-
expect(attemptNumber > maxRetries).toBe(true);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
});
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { JobWorker } from '../worker.js';
|
|
3
|
-
import { JobRegistry } from '../registry.js';
|
|
4
|
-
|
|
5
|
-
describe('JobWorker', () => {
|
|
6
|
-
let registry: JobRegistry;
|
|
7
|
-
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
registry = new JobRegistry();
|
|
10
|
-
vi.useFakeTimers();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
vi.useRealTimers();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
const mockCtx = {} as any;
|
|
18
|
-
|
|
19
|
-
it('starts and stops cleanly', async () => {
|
|
20
|
-
const mockDb = {} as any;
|
|
21
|
-
const worker = new JobWorker(mockDb, registry, mockCtx, { pollInterval: 100 });
|
|
22
|
-
|
|
23
|
-
expect(worker.isRunning()).toBe(false);
|
|
24
|
-
worker.start();
|
|
25
|
-
expect(worker.isRunning()).toBe(true);
|
|
26
|
-
|
|
27
|
-
await worker.stop();
|
|
28
|
-
expect(worker.isRunning()).toBe(false);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('does not double-start', () => {
|
|
32
|
-
const mockDb = {} as any;
|
|
33
|
-
const worker = new JobWorker(mockDb, registry, mockCtx, { pollInterval: 100 });
|
|
34
|
-
|
|
35
|
-
worker.start();
|
|
36
|
-
worker.start();
|
|
37
|
-
expect(worker.isRunning()).toBe(true);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('stop is a no-op when not running', async () => {
|
|
41
|
-
const mockDb = {} as any;
|
|
42
|
-
const worker = new JobWorker(mockDb, registry, mockCtx, { pollInterval: 100 });
|
|
43
|
-
|
|
44
|
-
await worker.stop();
|
|
45
|
-
expect(worker.isRunning()).toBe(false);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('defaults pollInterval to 2000ms', () => {
|
|
49
|
-
const mockDb = {} as any;
|
|
50
|
-
const worker = new JobWorker(mockDb, registry, mockCtx);
|
|
51
|
-
// Access via starting — the timer would fire at 2000ms intervals
|
|
52
|
-
expect(worker.isRunning()).toBe(false);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('tracks in-flight count', () => {
|
|
56
|
-
const mockDb = {} as any;
|
|
57
|
-
const worker = new JobWorker(mockDb, registry, mockCtx, { pollInterval: 100 });
|
|
58
|
-
|
|
59
|
-
expect(worker.getInFlightCount()).toBe(0);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
describe('computeBackoffDelay logic', () => {
|
|
63
|
-
it('exponential backoff doubles with each retry', () => {
|
|
64
|
-
// Testing the math: 1000 * 2^retry, capped at 600000
|
|
65
|
-
expect(1000 * Math.pow(2, 1)).toBe(2000);
|
|
66
|
-
expect(1000 * Math.pow(2, 2)).toBe(4000);
|
|
67
|
-
expect(1000 * Math.pow(2, 3)).toBe(8000);
|
|
68
|
-
expect(Math.min(1000 * Math.pow(2, 20), 600000)).toBe(600000);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('linear backoff increases linearly', () => {
|
|
72
|
-
expect(10000 * 1).toBe(10000);
|
|
73
|
-
expect(10000 * 2).toBe(20000);
|
|
74
|
-
expect(10000 * 3).toBe(30000);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('fixed backoff is constant', () => {
|
|
78
|
-
expect(5000).toBe(5000);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe('job execution', () => {
|
|
83
|
-
it('calls handler with job data on success', async () => {
|
|
84
|
-
const handler = vi.fn().mockResolvedValue(undefined);
|
|
85
|
-
registry.register('test_job', { handler });
|
|
86
|
-
|
|
87
|
-
// Verify the handler is registered and callable
|
|
88
|
-
const job = registry.get('test_job');
|
|
89
|
-
expect(job).toBeDefined();
|
|
90
|
-
await job!.config.handler({ foo: 'bar' }, {} as any);
|
|
91
|
-
expect(handler).toHaveBeenCalledWith({ foo: 'bar' }, expect.anything());
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('handler failure is catchable', async () => {
|
|
95
|
-
const handler = vi.fn().mockRejectedValue(new Error('boom'));
|
|
96
|
-
registry.register('fail_job', { handler, retries: 2, backoff: 'fixed' });
|
|
97
|
-
|
|
98
|
-
const job = registry.get('fail_job');
|
|
99
|
-
await expect(job!.config.handler({}, {} as any)).rejects.toThrow('boom');
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
describe('dead letter logic', () => {
|
|
104
|
-
it('moves to dead letter after max retries exceeded', () => {
|
|
105
|
-
// retry_count + 1 > max_retries means dead letter
|
|
106
|
-
const retryCount = 3;
|
|
107
|
-
const maxRetries = 3;
|
|
108
|
-
const newRetryCount = retryCount + 1;
|
|
109
|
-
expect(newRetryCount > maxRetries).toBe(true);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('retries when under max', () => {
|
|
113
|
-
const retryCount = 1;
|
|
114
|
-
const maxRetries = 3;
|
|
115
|
-
const newRetryCount = retryCount + 1;
|
|
116
|
-
expect(newRetryCount > maxRetries).toBe(false);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
});
|