@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
package/src/jobs/enqueue.ts
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import type { Kysely } from 'kysely';
|
|
2
|
-
import { sql } from 'kysely';
|
|
3
|
-
import type { EnqueueOptions, BackoffStrategy } from './types.js';
|
|
4
|
-
|
|
5
|
-
export interface EnqueueParams {
|
|
6
|
-
name: string;
|
|
7
|
-
data?: unknown;
|
|
8
|
-
options?: EnqueueOptions;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Insert a job row into rangka_jobs and return its ID.
|
|
13
|
-
* If `unique` is set, deduplicates against active/created jobs with the same key.
|
|
14
|
-
*/
|
|
15
|
-
export async function enqueue(
|
|
16
|
-
db: Kysely<unknown>,
|
|
17
|
-
name: string,
|
|
18
|
-
data: unknown = null,
|
|
19
|
-
options: EnqueueOptions = {},
|
|
20
|
-
): Promise<string> {
|
|
21
|
-
const maxRetries = options.retries ?? 0;
|
|
22
|
-
const backoff: BackoffStrategy = options.backoff ?? 'exponential';
|
|
23
|
-
const startAfter = options.delay ? new Date(Date.now() + options.delay) : null;
|
|
24
|
-
const uniqueKey = options.unique ? (options.uniqueKey ?? name) : null;
|
|
25
|
-
|
|
26
|
-
if (uniqueKey) {
|
|
27
|
-
return insertUniqueJob(db, name, data, maxRetries, backoff, startAfter, uniqueKey);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return insertJob(db, name, data, maxRetries, backoff, startAfter);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Insert a job with a unique_key constraint — returns the existing job ID if a duplicate exists. */
|
|
34
|
-
async function insertUniqueJob(
|
|
35
|
-
db: Kysely<unknown>,
|
|
36
|
-
name: string,
|
|
37
|
-
data: unknown,
|
|
38
|
-
maxRetries: number,
|
|
39
|
-
backoff: BackoffStrategy,
|
|
40
|
-
startAfter: Date | null,
|
|
41
|
-
uniqueKey: string,
|
|
42
|
-
): Promise<string> {
|
|
43
|
-
const result = await sql<{ id: string }>`
|
|
44
|
-
INSERT INTO rangka_jobs (name, data, state, max_retries, backoff, start_after, unique_key)
|
|
45
|
-
VALUES (
|
|
46
|
-
${name},
|
|
47
|
-
${JSON.stringify(data)}::jsonb,
|
|
48
|
-
'created',
|
|
49
|
-
${maxRetries},
|
|
50
|
-
${backoff},
|
|
51
|
-
${startAfter},
|
|
52
|
-
${uniqueKey}
|
|
53
|
-
)
|
|
54
|
-
ON CONFLICT (unique_key) WHERE state IN ('created', 'active')
|
|
55
|
-
DO NOTHING
|
|
56
|
-
RETURNING id
|
|
57
|
-
`.execute(db);
|
|
58
|
-
|
|
59
|
-
// If insert was skipped due to conflict, look up the existing job's ID
|
|
60
|
-
if (result.rows.length === 0) {
|
|
61
|
-
const existing = await sql<{ id: string }>`
|
|
62
|
-
SELECT id FROM rangka_jobs WHERE unique_key = ${uniqueKey} AND state IN ('created', 'active') LIMIT 1
|
|
63
|
-
`.execute(db);
|
|
64
|
-
return existing.rows[0]?.id ?? '';
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return result.rows[0].id;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Insert a standard (non-unique) job row. */
|
|
71
|
-
async function insertJob(
|
|
72
|
-
db: Kysely<unknown>,
|
|
73
|
-
name: string,
|
|
74
|
-
data: unknown,
|
|
75
|
-
maxRetries: number,
|
|
76
|
-
backoff: BackoffStrategy,
|
|
77
|
-
startAfter: Date | null,
|
|
78
|
-
): Promise<string> {
|
|
79
|
-
const result = await sql<{ id: string }>`
|
|
80
|
-
INSERT INTO rangka_jobs (name, data, state, max_retries, backoff, start_after)
|
|
81
|
-
VALUES (
|
|
82
|
-
${name},
|
|
83
|
-
${JSON.stringify(data)}::jsonb,
|
|
84
|
-
'created',
|
|
85
|
-
${maxRetries},
|
|
86
|
-
${backoff},
|
|
87
|
-
${startAfter}
|
|
88
|
-
)
|
|
89
|
-
RETURNING id
|
|
90
|
-
`.execute(db);
|
|
91
|
-
|
|
92
|
-
return result.rows[0].id;
|
|
93
|
-
}
|
package/src/jobs/index.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export type {
|
|
2
|
-
JobRecord,
|
|
3
|
-
DeadLetterRecord,
|
|
4
|
-
ScheduledJobRecord,
|
|
5
|
-
JobState,
|
|
6
|
-
BackoffStrategy,
|
|
7
|
-
JobWorkerConfig,
|
|
8
|
-
RegisteredJob,
|
|
9
|
-
EnqueueOptions,
|
|
10
|
-
} from './types.js';
|
|
11
|
-
export { JobRegistry } from './registry.js';
|
|
12
|
-
export { enqueue } from './enqueue.js';
|
|
13
|
-
export { JobWorker } from './worker.js';
|
|
14
|
-
export { ScheduleManager } from './scheduler.js';
|
package/src/jobs/registry.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import type { JobConfig } from '@rangka/shared';
|
|
2
|
-
import type { RegisteredJob } from './types.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Stores and validates background job definitions.
|
|
6
|
-
* Jobs can be one-off or scheduled via cron expressions.
|
|
7
|
-
*/
|
|
8
|
-
export class JobRegistry {
|
|
9
|
-
private readonly jobs: Map<string, RegisteredJob> = new Map();
|
|
10
|
-
|
|
11
|
-
/** Register a new job after validating its configuration. */
|
|
12
|
-
register(name: string, config: JobConfig): void {
|
|
13
|
-
if (this.jobs.has(name)) {
|
|
14
|
-
throw new Error(`Job "${name}" is already registered`);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const validationErrors = this.validate(name, config);
|
|
18
|
-
if (validationErrors.length > 0) {
|
|
19
|
-
throw new Error(`Invalid job config for "${name}": ${validationErrors.join('; ')}`);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
this.jobs.set(name, { name, config });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
get(name: string): RegisteredJob | undefined {
|
|
26
|
-
return this.jobs.get(name);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
has(name: string): boolean {
|
|
30
|
-
return this.jobs.has(name);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
getAll(): RegisteredJob[] {
|
|
34
|
-
return Array.from(this.jobs.values());
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Return only jobs that have a cron schedule defined. */
|
|
38
|
-
getScheduled(): RegisteredJob[] {
|
|
39
|
-
return this.getAll().filter((job) => !!job.config.schedule);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Validate job name and config, returning a list of human-readable errors. */
|
|
43
|
-
private validate(name: string, config: JobConfig): string[] {
|
|
44
|
-
const errors: string[] = [];
|
|
45
|
-
|
|
46
|
-
if (!name || name.trim().length === 0) {
|
|
47
|
-
errors.push('Job name must not be empty');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (typeof config.handler !== 'function') {
|
|
51
|
-
errors.push('Job handler must be a function');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
this.validateConcurrency(config, errors);
|
|
55
|
-
this.validateRetries(config, errors);
|
|
56
|
-
this.validateBackoff(config, errors);
|
|
57
|
-
this.validateSchedule(config, errors);
|
|
58
|
-
|
|
59
|
-
return errors;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
private validateConcurrency(config: JobConfig, errors: string[]): void {
|
|
63
|
-
if (
|
|
64
|
-
config.concurrency !== undefined &&
|
|
65
|
-
(config.concurrency < 1 || !Number.isInteger(config.concurrency))
|
|
66
|
-
) {
|
|
67
|
-
errors.push('Concurrency must be a positive integer');
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
private validateRetries(config: JobConfig, errors: string[]): void {
|
|
72
|
-
if (config.retries !== undefined && (config.retries < 0 || !Number.isInteger(config.retries))) {
|
|
73
|
-
errors.push('Retries must be a non-negative integer');
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
private validateBackoff(config: JobConfig, errors: string[]): void {
|
|
78
|
-
const allowedStrategies = ['exponential', 'linear', 'fixed'];
|
|
79
|
-
if (config.backoff !== undefined && !allowedStrategies.includes(config.backoff)) {
|
|
80
|
-
errors.push('Backoff must be one of: exponential, linear, fixed');
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private validateSchedule(config: JobConfig, errors: string[]): void {
|
|
85
|
-
if (config.schedule !== undefined) {
|
|
86
|
-
const cronFields = config.schedule.trim().split(/\s+/);
|
|
87
|
-
if (cronFields.length !== 5) {
|
|
88
|
-
errors.push('Schedule must be a valid 5-field cron expression');
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
package/src/jobs/scheduler.ts
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import type { Kysely } from 'kysely';
|
|
2
|
-
import { sql } from 'kysely';
|
|
3
|
-
import type { JobRegistry } from './registry.js';
|
|
4
|
-
import { toCount } from '../helpers/coerce.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* ScheduleManager polls the database for cron-scheduled jobs that are due,
|
|
8
|
-
* enqueues them, and advances their next_run_at timestamps.
|
|
9
|
-
*/
|
|
10
|
-
export class ScheduleManager {
|
|
11
|
-
private running = false;
|
|
12
|
-
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
13
|
-
private readonly pollInterval: number;
|
|
14
|
-
private readonly db: Kysely<unknown>;
|
|
15
|
-
private readonly registry: JobRegistry;
|
|
16
|
-
|
|
17
|
-
constructor(db: Kysely<unknown>, registry: JobRegistry, pollInterval = 60000) {
|
|
18
|
-
this.db = db;
|
|
19
|
-
this.registry = registry;
|
|
20
|
-
this.pollInterval = pollInterval;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// ─── Public API ────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
/** Upserts all registered scheduled jobs into the database. */
|
|
26
|
-
async syncSchedules(): Promise<void> {
|
|
27
|
-
const scheduledJobs = this.registry.getScheduled();
|
|
28
|
-
|
|
29
|
-
for (const job of scheduledJobs) {
|
|
30
|
-
const cronExpr = job.config.schedule!;
|
|
31
|
-
const nextRun = this.computeNextRun(cronExpr);
|
|
32
|
-
|
|
33
|
-
await sql`
|
|
34
|
-
INSERT INTO rangka_scheduled_jobs (name, cron, data, next_run_at)
|
|
35
|
-
VALUES (${job.name}, ${cronExpr}, NULL, ${nextRun})
|
|
36
|
-
ON CONFLICT (name) DO UPDATE SET cron = ${cronExpr}, next_run_at = COALESCE(rangka_scheduled_jobs.next_run_at, ${nextRun})
|
|
37
|
-
`.execute(this.db);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Starts the polling loop. */
|
|
42
|
-
start(): void {
|
|
43
|
-
if (this.running) return;
|
|
44
|
-
this.running = true;
|
|
45
|
-
this.schedulePoll();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Stops the polling loop. */
|
|
49
|
-
async stop(): Promise<void> {
|
|
50
|
-
this.running = false;
|
|
51
|
-
if (this.pollTimer) {
|
|
52
|
-
clearTimeout(this.pollTimer);
|
|
53
|
-
this.pollTimer = null;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
isRunning(): boolean {
|
|
58
|
-
return this.running;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Parses a 5-field cron expression and returns the next matching minute
|
|
63
|
-
* after `from`. Scans up to ~1 year of minutes before giving up.
|
|
64
|
-
*/
|
|
65
|
-
computeNextRun(cronExpr: string, from: Date = new Date()): Date {
|
|
66
|
-
const fields = cronExpr.trim().split(/\s+/);
|
|
67
|
-
if (fields.length !== 5) {
|
|
68
|
-
throw new Error(`Invalid cron expression: "${cronExpr}"`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const [minuteExpr, hourExpr, dayOfMonthExpr, monthExpr, dayOfWeekExpr] = fields;
|
|
72
|
-
|
|
73
|
-
// Start scanning from the next whole minute after `from`
|
|
74
|
-
const candidate = new Date(from.getTime());
|
|
75
|
-
candidate.setSeconds(0, 0);
|
|
76
|
-
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
77
|
-
|
|
78
|
-
const ONE_YEAR_IN_MINUTES = 525_960;
|
|
79
|
-
|
|
80
|
-
for (let i = 0; i < ONE_YEAR_IN_MINUTES; i++) {
|
|
81
|
-
const matchesAllFields =
|
|
82
|
-
this.matchesCronField(minuteExpr, candidate.getMinutes(), 0, 59) &&
|
|
83
|
-
this.matchesCronField(hourExpr, candidate.getHours(), 0, 23) &&
|
|
84
|
-
this.matchesCronField(dayOfMonthExpr, candidate.getDate(), 1, 31) &&
|
|
85
|
-
this.matchesCronField(monthExpr, candidate.getMonth() + 1, 1, 12) &&
|
|
86
|
-
this.matchesCronField(dayOfWeekExpr, candidate.getDay(), 0, 6);
|
|
87
|
-
|
|
88
|
-
if (matchesAllFields) {
|
|
89
|
-
return candidate;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
throw new Error(`Could not compute next run for cron: "${cronExpr}"`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// ─── Private helpers ───────────────────────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
/** Arms the next poll timer. */
|
|
101
|
-
private schedulePoll(): void {
|
|
102
|
-
this.pollTimer = setTimeout(() => this.poll(), this.pollInterval);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Runs one poll cycle, then re-arms the timer if still running. */
|
|
106
|
-
private poll(): void {
|
|
107
|
-
if (!this.running) return;
|
|
108
|
-
|
|
109
|
-
this.checkSchedules().finally(() => {
|
|
110
|
-
if (this.running) {
|
|
111
|
-
this.schedulePoll();
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Finds all schedules that are due and processes each one:
|
|
118
|
-
* - Skips enqueuing if the job already has a pending/active instance.
|
|
119
|
-
* - Otherwise enqueues a new job instance.
|
|
120
|
-
* In both cases, advances the schedule's next_run_at.
|
|
121
|
-
*/
|
|
122
|
-
private async checkSchedules(): Promise<void> {
|
|
123
|
-
const dueSchedules = await sql<{ id: string; name: string; cron: string; data: unknown }>`
|
|
124
|
-
SELECT id, name, cron, data FROM rangka_scheduled_jobs
|
|
125
|
-
WHERE next_run_at <= NOW()
|
|
126
|
-
FOR UPDATE SKIP LOCKED
|
|
127
|
-
`.execute(this.db);
|
|
128
|
-
|
|
129
|
-
for (const schedule of dueSchedules.rows) {
|
|
130
|
-
const alreadyPending = await this.hasPendingJob(schedule.name);
|
|
131
|
-
|
|
132
|
-
if (!alreadyPending) {
|
|
133
|
-
await this.enqueueJob(schedule.name, schedule.data);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
await this.advanceSchedule(schedule.id, schedule.cron);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Inserts a new job instance into the jobs table. */
|
|
141
|
-
private async enqueueJob(name: string, data: unknown): Promise<void> {
|
|
142
|
-
await sql`
|
|
143
|
-
INSERT INTO rangka_jobs (name, data, state, max_retries, backoff)
|
|
144
|
-
VALUES (${name}, ${JSON.stringify(data)}::jsonb, 'created', 0, 'exponential')
|
|
145
|
-
`.execute(this.db);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Updates a schedule's next_run_at and records last_run_at. */
|
|
149
|
-
private async advanceSchedule(scheduleId: string, cronExpr: string): Promise<void> {
|
|
150
|
-
const nextRun = this.computeNextRun(cronExpr);
|
|
151
|
-
await sql`
|
|
152
|
-
UPDATE rangka_scheduled_jobs
|
|
153
|
-
SET next_run_at = ${nextRun}, last_run_at = NOW()
|
|
154
|
-
WHERE id = ${scheduleId}
|
|
155
|
-
`.execute(this.db);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Returns true if a job with the given name is already created or active. */
|
|
159
|
-
private async hasPendingJob(name: string): Promise<boolean> {
|
|
160
|
-
const result = await sql<{ count: string }>`
|
|
161
|
-
SELECT COUNT(*) as count FROM rangka_jobs
|
|
162
|
-
WHERE name = ${name} AND state IN ('created', 'active')
|
|
163
|
-
`.execute(this.db);
|
|
164
|
-
return toCount(result.rows[0]) > 0;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Checks whether a single cron field expression matches the given value.
|
|
169
|
-
* Supports: wildcard (*), lists (1,5,10), ranges (1-5), and steps (star/5, 1-10/2).
|
|
170
|
-
*/
|
|
171
|
-
private matchesCronField(expr: string, value: number, min: number, _max: number): boolean {
|
|
172
|
-
if (expr === '*') return true;
|
|
173
|
-
|
|
174
|
-
// A field can be a comma-separated list of segments
|
|
175
|
-
for (const segment of expr.split(',')) {
|
|
176
|
-
if (this.segmentMatches(segment, value, min)) {
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Evaluates a single segment of a cron field (step, range, or literal). */
|
|
185
|
-
private segmentMatches(segment: string, value: number, fieldMin: number): boolean {
|
|
186
|
-
if (segment.includes('/')) {
|
|
187
|
-
// Step expression, e.g. "*/5" or "10-30/5"
|
|
188
|
-
const [rangeExpr, stepStr] = segment.split('/');
|
|
189
|
-
const step = parseInt(stepStr, 10);
|
|
190
|
-
const start = rangeExpr === '*' ? fieldMin : parseInt(rangeExpr, 10);
|
|
191
|
-
return value >= start && (value - start) % step === 0;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (segment.includes('-')) {
|
|
195
|
-
// Range expression, e.g. "1-5"
|
|
196
|
-
const [startStr, endStr] = segment.split('-');
|
|
197
|
-
const rangeStart = parseInt(startStr, 10);
|
|
198
|
-
const rangeEnd = parseInt(endStr, 10);
|
|
199
|
-
return value >= rangeStart && value <= rangeEnd;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Exact value, e.g. "30"
|
|
203
|
-
return parseInt(segment, 10) === value;
|
|
204
|
-
}
|
|
205
|
-
}
|
package/src/jobs/tables.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import type { TableDefinition } from '../db/types.js';
|
|
2
|
-
|
|
3
|
-
export function getJobTables(): TableDefinition[] {
|
|
4
|
-
return [buildJobsTable(), buildDeadLettersTable(), buildScheduledJobsTable()];
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
function buildJobsTable(): TableDefinition {
|
|
8
|
-
return {
|
|
9
|
-
name: 'rangka_jobs',
|
|
10
|
-
columns: [
|
|
11
|
-
{
|
|
12
|
-
name: 'id',
|
|
13
|
-
type: 'UUID',
|
|
14
|
-
nullable: false,
|
|
15
|
-
primaryKey: true,
|
|
16
|
-
defaultValue: 'gen_random_uuid()',
|
|
17
|
-
},
|
|
18
|
-
{ name: 'name', type: 'TEXT', nullable: false },
|
|
19
|
-
{ name: 'data', type: 'JSONB', nullable: true },
|
|
20
|
-
{ name: 'state', type: 'TEXT', nullable: false, defaultValue: "'created'" },
|
|
21
|
-
{ name: 'retry_count', type: 'INTEGER', nullable: false, defaultValue: '0' },
|
|
22
|
-
{ name: 'max_retries', type: 'INTEGER', nullable: false, defaultValue: '0' },
|
|
23
|
-
{ name: 'backoff', type: 'TEXT', nullable: false, defaultValue: "'exponential'" },
|
|
24
|
-
{ name: 'start_after', type: 'TIMESTAMPTZ', nullable: true },
|
|
25
|
-
{ name: 'started_at', type: 'TIMESTAMPTZ', nullable: true },
|
|
26
|
-
{ name: 'completed_at', type: 'TIMESTAMPTZ', nullable: true },
|
|
27
|
-
{ name: 'failed_at', type: 'TIMESTAMPTZ', nullable: true },
|
|
28
|
-
{ name: 'error', type: 'TEXT', nullable: true },
|
|
29
|
-
{ name: 'unique_key', type: 'TEXT', nullable: true },
|
|
30
|
-
{ name: 'created_at', type: 'TIMESTAMPTZ', nullable: false, defaultValue: 'NOW()' },
|
|
31
|
-
{ name: 'expire_in', type: 'INTERVAL', nullable: false, defaultValue: "'15 minutes'" },
|
|
32
|
-
],
|
|
33
|
-
foreignKeys: [],
|
|
34
|
-
checkConstraints: [
|
|
35
|
-
{
|
|
36
|
-
name: 'chk_rangka_jobs_state',
|
|
37
|
-
column: 'state',
|
|
38
|
-
expression: "state IN ('created', 'active', 'completed', 'failed')",
|
|
39
|
-
},
|
|
40
|
-
],
|
|
41
|
-
indexes: [
|
|
42
|
-
{
|
|
43
|
-
name: 'idx_rangka_jobs_state_start_after',
|
|
44
|
-
table: 'rangka_jobs',
|
|
45
|
-
columns: ['state', 'start_after'],
|
|
46
|
-
unique: false,
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
name: 'idx_rangka_jobs_name_state',
|
|
50
|
-
table: 'rangka_jobs',
|
|
51
|
-
columns: ['name', 'state'],
|
|
52
|
-
unique: false,
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
name: 'idx_rangka_jobs_unique_key_active',
|
|
56
|
-
table: 'rangka_jobs',
|
|
57
|
-
columns: ['unique_key'],
|
|
58
|
-
unique: true,
|
|
59
|
-
where: "state IN ('created', 'active')",
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function buildDeadLettersTable(): TableDefinition {
|
|
66
|
-
return {
|
|
67
|
-
name: 'rangka_dead_letters',
|
|
68
|
-
columns: [
|
|
69
|
-
{
|
|
70
|
-
name: 'id',
|
|
71
|
-
type: 'UUID',
|
|
72
|
-
nullable: false,
|
|
73
|
-
primaryKey: true,
|
|
74
|
-
defaultValue: 'gen_random_uuid()',
|
|
75
|
-
},
|
|
76
|
-
{ name: 'job_id', type: 'UUID', nullable: false },
|
|
77
|
-
{ name: 'name', type: 'TEXT', nullable: false },
|
|
78
|
-
{ name: 'data', type: 'JSONB', nullable: true },
|
|
79
|
-
{ name: 'error', type: 'TEXT', nullable: true },
|
|
80
|
-
{ name: 'retry_count', type: 'INTEGER', nullable: false, defaultValue: '0' },
|
|
81
|
-
{ name: 'failed_at', type: 'TIMESTAMPTZ', nullable: false, defaultValue: 'NOW()' },
|
|
82
|
-
{ name: 'created_at', type: 'TIMESTAMPTZ', nullable: false, defaultValue: 'NOW()' },
|
|
83
|
-
],
|
|
84
|
-
foreignKeys: [],
|
|
85
|
-
checkConstraints: [],
|
|
86
|
-
indexes: [
|
|
87
|
-
{
|
|
88
|
-
name: 'idx_rangka_dead_letters_name',
|
|
89
|
-
table: 'rangka_dead_letters',
|
|
90
|
-
columns: ['name'],
|
|
91
|
-
unique: false,
|
|
92
|
-
},
|
|
93
|
-
],
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function buildScheduledJobsTable(): TableDefinition {
|
|
98
|
-
return {
|
|
99
|
-
name: 'rangka_scheduled_jobs',
|
|
100
|
-
columns: [
|
|
101
|
-
{
|
|
102
|
-
name: 'id',
|
|
103
|
-
type: 'UUID',
|
|
104
|
-
nullable: false,
|
|
105
|
-
primaryKey: true,
|
|
106
|
-
defaultValue: 'gen_random_uuid()',
|
|
107
|
-
},
|
|
108
|
-
{ name: 'name', type: 'TEXT', nullable: false },
|
|
109
|
-
{ name: 'cron', type: 'TEXT', nullable: false },
|
|
110
|
-
{ name: 'data', type: 'JSONB', nullable: true },
|
|
111
|
-
{ name: 'last_run_at', type: 'TIMESTAMPTZ', nullable: true },
|
|
112
|
-
{ name: 'next_run_at', type: 'TIMESTAMPTZ', nullable: true },
|
|
113
|
-
{ name: 'created_at', type: 'TIMESTAMPTZ', nullable: false, defaultValue: 'NOW()' },
|
|
114
|
-
],
|
|
115
|
-
foreignKeys: [],
|
|
116
|
-
checkConstraints: [],
|
|
117
|
-
indexes: [
|
|
118
|
-
{
|
|
119
|
-
name: 'uidx_rangka_scheduled_jobs_name',
|
|
120
|
-
table: 'rangka_scheduled_jobs',
|
|
121
|
-
columns: ['name'],
|
|
122
|
-
unique: true,
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
name: 'idx_rangka_scheduled_jobs_next_run',
|
|
126
|
-
table: 'rangka_scheduled_jobs',
|
|
127
|
-
columns: ['next_run_at'],
|
|
128
|
-
unique: false,
|
|
129
|
-
},
|
|
130
|
-
],
|
|
131
|
-
};
|
|
132
|
-
}
|
package/src/jobs/types.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { JobConfig } from '@rangka/shared';
|
|
2
|
-
|
|
3
|
-
export type JobState = 'created' | 'active' | 'completed' | 'failed';
|
|
4
|
-
|
|
5
|
-
export type BackoffStrategy = 'exponential' | 'linear' | 'fixed';
|
|
6
|
-
|
|
7
|
-
export interface JobRecord {
|
|
8
|
-
id: string;
|
|
9
|
-
name: string;
|
|
10
|
-
data: unknown;
|
|
11
|
-
state: JobState;
|
|
12
|
-
retry_count: number;
|
|
13
|
-
max_retries: number;
|
|
14
|
-
backoff: BackoffStrategy;
|
|
15
|
-
start_after: Date | null;
|
|
16
|
-
started_at: Date | null;
|
|
17
|
-
completed_at: Date | null;
|
|
18
|
-
failed_at: Date | null;
|
|
19
|
-
error: string | null;
|
|
20
|
-
unique_key: string | null;
|
|
21
|
-
created_at: Date;
|
|
22
|
-
expire_in: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface DeadLetterRecord {
|
|
26
|
-
id: string;
|
|
27
|
-
job_id: string;
|
|
28
|
-
name: string;
|
|
29
|
-
data: unknown;
|
|
30
|
-
error: string | null;
|
|
31
|
-
retry_count: number;
|
|
32
|
-
failed_at: Date;
|
|
33
|
-
created_at: Date;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface ScheduledJobRecord {
|
|
37
|
-
id: string;
|
|
38
|
-
name: string;
|
|
39
|
-
cron: string;
|
|
40
|
-
data: unknown;
|
|
41
|
-
last_run_at: Date | null;
|
|
42
|
-
next_run_at: Date | null;
|
|
43
|
-
created_at: Date;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface JobWorkerConfig {
|
|
47
|
-
pollInterval?: number;
|
|
48
|
-
enabled?: boolean;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface RegisteredJob {
|
|
52
|
-
name: string;
|
|
53
|
-
config: JobConfig;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface EnqueueOptions {
|
|
57
|
-
delay?: number;
|
|
58
|
-
unique?: boolean;
|
|
59
|
-
uniqueKey?: string;
|
|
60
|
-
retries?: number;
|
|
61
|
-
backoff?: BackoffStrategy;
|
|
62
|
-
}
|