@rangka/core 0.1.1 → 0.1.3
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 -25
- 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/worker.ts
DELETED
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
import type { Kysely } from 'kysely';
|
|
2
|
-
import { sql } from 'kysely';
|
|
3
|
-
import type { FrameworkContext } from '@rangka/shared';
|
|
4
|
-
import type { JobRegistry } from './registry.js';
|
|
5
|
-
import type { JobRecord, BackoffStrategy, JobWorkerConfig } from './types.js';
|
|
6
|
-
import { toCount } from '../helpers/coerce.js';
|
|
7
|
-
|
|
8
|
-
const MAX_BACKOFF_MS = 600_000; // 10 minutes
|
|
9
|
-
const BATCH_SIZE = 10;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Polls the database for pending jobs, claims them, and executes their handlers.
|
|
13
|
-
* Supports concurrency limits, retry with backoff, and graceful shutdown.
|
|
14
|
-
*/
|
|
15
|
-
export class JobWorker {
|
|
16
|
-
private running = false;
|
|
17
|
-
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
18
|
-
private activeJobIds = new Set<string>();
|
|
19
|
-
private readonly pollInterval: number;
|
|
20
|
-
private readonly db: Kysely<unknown>;
|
|
21
|
-
private readonly registry: JobRegistry;
|
|
22
|
-
private readonly ctx: FrameworkContext;
|
|
23
|
-
private shutdownResolve: (() => void) | null = null;
|
|
24
|
-
|
|
25
|
-
constructor(
|
|
26
|
-
db: Kysely<unknown>,
|
|
27
|
-
registry: JobRegistry,
|
|
28
|
-
ctx: FrameworkContext,
|
|
29
|
-
config: JobWorkerConfig = {},
|
|
30
|
-
) {
|
|
31
|
-
this.db = db;
|
|
32
|
-
this.registry = registry;
|
|
33
|
-
this.ctx = ctx;
|
|
34
|
-
this.pollInterval = config.pollInterval ?? 2000;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Begin polling for jobs. */
|
|
38
|
-
start(): void {
|
|
39
|
-
if (this.running) return;
|
|
40
|
-
this.running = true;
|
|
41
|
-
this.poll();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Stop polling and wait for in-flight jobs to finish. */
|
|
45
|
-
async stop(): Promise<void> {
|
|
46
|
-
if (!this.running) return;
|
|
47
|
-
this.running = false;
|
|
48
|
-
|
|
49
|
-
if (this.pollTimer) {
|
|
50
|
-
clearTimeout(this.pollTimer);
|
|
51
|
-
this.pollTimer = null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// If jobs are still executing, return a promise that resolves when they drain.
|
|
55
|
-
if (this.activeJobIds.size > 0) {
|
|
56
|
-
return new Promise<void>((resolve) => {
|
|
57
|
-
this.shutdownResolve = resolve;
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
isRunning(): boolean {
|
|
63
|
-
return this.running;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
getInFlightCount(): number {
|
|
67
|
-
return this.activeJobIds.size;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
// Polling loop
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
/** Single poll iteration: claim a batch of jobs, execute them, then schedule the next poll. */
|
|
75
|
-
private poll(): void {
|
|
76
|
-
if (!this.running) return;
|
|
77
|
-
|
|
78
|
-
this.claimAndExecute().finally(() => {
|
|
79
|
-
if (this.running) {
|
|
80
|
-
this.pollTimer = setTimeout(() => this.poll(), this.pollInterval);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Claim available jobs from the database and run them concurrently. */
|
|
86
|
-
private async claimAndExecute(): Promise<void> {
|
|
87
|
-
const jobs = await this.claimJobs();
|
|
88
|
-
await Promise.allSettled(jobs.map((job) => this.executeJob(job)));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
// Job claiming
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Fetch pending jobs that this worker can handle, respecting concurrency limits.
|
|
97
|
-
* Uses SELECT FOR UPDATE SKIP LOCKED to allow multiple workers without conflicts.
|
|
98
|
-
*/
|
|
99
|
-
private async claimJobs(): Promise<JobRecord[]> {
|
|
100
|
-
const registeredNames = this.registry.getAll().map((entry) => entry.name);
|
|
101
|
-
if (registeredNames.length === 0) return [];
|
|
102
|
-
|
|
103
|
-
const pendingJobs = await this.fetchPendingJobs(registeredNames);
|
|
104
|
-
return this.claimWithConcurrencyCheck(pendingJobs);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Query the database for jobs that are ready to run. */
|
|
108
|
-
private async fetchPendingJobs(jobNames: string[]): Promise<JobRecord[]> {
|
|
109
|
-
const namePlaceholders = jobNames.map((name) => `'${name}'`).join(', ');
|
|
110
|
-
|
|
111
|
-
const result = await sql<JobRecord>`
|
|
112
|
-
SELECT * FROM rangka_jobs
|
|
113
|
-
WHERE state = 'created'
|
|
114
|
-
AND name IN (${sql.raw(namePlaceholders)})
|
|
115
|
-
AND (start_after IS NULL OR start_after <= NOW())
|
|
116
|
-
ORDER BY created_at ASC
|
|
117
|
-
LIMIT ${BATCH_SIZE}
|
|
118
|
-
FOR UPDATE SKIP LOCKED
|
|
119
|
-
`.execute(this.db);
|
|
120
|
-
|
|
121
|
-
return result.rows;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Filter jobs by concurrency limits and mark them as active. */
|
|
125
|
-
private async claimWithConcurrencyCheck(pendingJobs: JobRecord[]): Promise<JobRecord[]> {
|
|
126
|
-
const claimed: JobRecord[] = [];
|
|
127
|
-
|
|
128
|
-
for (const job of pendingJobs) {
|
|
129
|
-
const registration = this.registry.get(job.name);
|
|
130
|
-
if (!registration) continue;
|
|
131
|
-
|
|
132
|
-
if (await this.exceedsConcurrencyLimit(job.name, registration.config.concurrency)) {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
await sql`
|
|
137
|
-
UPDATE rangka_jobs SET state = 'active', started_at = NOW()
|
|
138
|
-
WHERE id = ${job.id}
|
|
139
|
-
`.execute(this.db);
|
|
140
|
-
|
|
141
|
-
claimed.push({ ...job, state: 'active' });
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return claimed;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Returns true if the job type already has too many active instances. */
|
|
148
|
-
private async exceedsConcurrencyLimit(
|
|
149
|
-
jobName: string,
|
|
150
|
-
maxConcurrency: number | undefined,
|
|
151
|
-
): Promise<boolean> {
|
|
152
|
-
const limit = maxConcurrency ?? Infinity;
|
|
153
|
-
if (limit === Infinity) return false;
|
|
154
|
-
|
|
155
|
-
const activeCount = await this.getActiveCount(jobName);
|
|
156
|
-
return activeCount >= limit;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
private async getActiveCount(jobName: string): Promise<number> {
|
|
160
|
-
const result = await sql<{ count: string }>`
|
|
161
|
-
SELECT COUNT(*) as count FROM rangka_jobs
|
|
162
|
-
WHERE name = ${jobName} AND state = 'active'
|
|
163
|
-
`.execute(this.db);
|
|
164
|
-
|
|
165
|
-
return toCount(result.rows[0]);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
// Job execution
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
|
|
172
|
-
/** Run a single job's handler, then mark it completed or handle failure. */
|
|
173
|
-
private async executeJob(job: JobRecord): Promise<void> {
|
|
174
|
-
const registration = this.registry.get(job.name);
|
|
175
|
-
if (!registration) return;
|
|
176
|
-
|
|
177
|
-
this.activeJobIds.add(job.id);
|
|
178
|
-
|
|
179
|
-
try {
|
|
180
|
-
await registration.config.handler(job.data, this.ctx);
|
|
181
|
-
await this.markCompleted(job.id);
|
|
182
|
-
} catch (err: unknown) {
|
|
183
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
184
|
-
await this.handleFailure(job, errorMessage);
|
|
185
|
-
} finally {
|
|
186
|
-
this.activeJobIds.delete(job.id);
|
|
187
|
-
this.resolveShutdownIfDrained();
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** If the worker is stopping and no jobs remain in flight, signal completion. */
|
|
192
|
-
private resolveShutdownIfDrained(): void {
|
|
193
|
-
if (!this.running && this.activeJobIds.size === 0 && this.shutdownResolve) {
|
|
194
|
-
this.shutdownResolve();
|
|
195
|
-
this.shutdownResolve = null;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
private async markCompleted(jobId: string): Promise<void> {
|
|
200
|
-
await sql`
|
|
201
|
-
UPDATE rangka_jobs SET state = 'completed', completed_at = NOW()
|
|
202
|
-
WHERE id = ${jobId}
|
|
203
|
-
`.execute(this.db);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ---------------------------------------------------------------------------
|
|
207
|
-
// Failure handling and retries
|
|
208
|
-
// ---------------------------------------------------------------------------
|
|
209
|
-
|
|
210
|
-
/** Either schedule a retry (with backoff) or move the job to the dead-letter table. */
|
|
211
|
-
private async handleFailure(job: JobRecord, error: string): Promise<void> {
|
|
212
|
-
const attemptNumber = job.retry_count + 1;
|
|
213
|
-
|
|
214
|
-
if (attemptNumber > job.max_retries) {
|
|
215
|
-
await this.moveToDeadLetter(job, error);
|
|
216
|
-
} else {
|
|
217
|
-
await this.scheduleRetry(job, attemptNumber, error);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Reset the job to 'created' with a delayed start_after timestamp. */
|
|
222
|
-
private async scheduleRetry(job: JobRecord, attemptNumber: number, error: string): Promise<void> {
|
|
223
|
-
const delayMs = this.computeBackoffDelay(job.backoff, attemptNumber);
|
|
224
|
-
const retryAfter = new Date(Date.now() + delayMs);
|
|
225
|
-
|
|
226
|
-
await sql`
|
|
227
|
-
UPDATE rangka_jobs
|
|
228
|
-
SET state = 'created',
|
|
229
|
-
retry_count = ${attemptNumber},
|
|
230
|
-
failed_at = NOW(),
|
|
231
|
-
error = ${error},
|
|
232
|
-
start_after = ${retryAfter}
|
|
233
|
-
WHERE id = ${job.id}
|
|
234
|
-
`.execute(this.db);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Permanently fail the job: archive it to dead letters and mark it failed. */
|
|
238
|
-
private async moveToDeadLetter(job: JobRecord, error: string): Promise<void> {
|
|
239
|
-
await sql`
|
|
240
|
-
INSERT INTO rangka_dead_letters (job_id, name, data, error, retry_count, failed_at)
|
|
241
|
-
VALUES (
|
|
242
|
-
${job.id},
|
|
243
|
-
${job.name},
|
|
244
|
-
${JSON.stringify(job.data)}::jsonb,
|
|
245
|
-
${error},
|
|
246
|
-
${job.retry_count + 1},
|
|
247
|
-
NOW()
|
|
248
|
-
)
|
|
249
|
-
`.execute(this.db);
|
|
250
|
-
|
|
251
|
-
await sql`
|
|
252
|
-
UPDATE rangka_jobs SET state = 'failed', failed_at = NOW(), error = ${error}
|
|
253
|
-
WHERE id = ${job.id}
|
|
254
|
-
`.execute(this.db);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Calculate how long to wait before the next retry attempt. */
|
|
258
|
-
private computeBackoffDelay(backoff: BackoffStrategy, retryCount: number): number {
|
|
259
|
-
switch (backoff) {
|
|
260
|
-
case 'exponential': {
|
|
261
|
-
const delayMs = 1000 * Math.pow(2, retryCount);
|
|
262
|
-
return Math.min(delayMs, MAX_BACKOFF_MS);
|
|
263
|
-
}
|
|
264
|
-
case 'linear':
|
|
265
|
-
return 10_000 * retryCount;
|
|
266
|
-
case 'fixed':
|
|
267
|
-
return 5000;
|
|
268
|
-
default:
|
|
269
|
-
return 1000 * Math.pow(2, retryCount);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
@@ -1,366 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { resolveModelIncludes } from '../include-resolver.js';
|
|
3
|
-
import type { SchemaRegistry } from '../../schema/registry.js';
|
|
4
|
-
import type { ModelRelationship, ResolvedModel } from '../../schema/types.js';
|
|
5
|
-
import { AdapterRegistry } from '../../plugins/adapter-registry.js';
|
|
6
|
-
import type { DataAdapter } from '../../plugins/types.js';
|
|
7
|
-
|
|
8
|
-
function makeRegistry(
|
|
9
|
-
models: ResolvedModel[],
|
|
10
|
-
relationships: ModelRelationship[] = [],
|
|
11
|
-
): SchemaRegistry {
|
|
12
|
-
return {
|
|
13
|
-
getModel: (qn: string) => models.find((m) => m.qualifiedName === qn),
|
|
14
|
-
getAllModels: () => models,
|
|
15
|
-
getRelationshipsForModel: () => relationships,
|
|
16
|
-
getFieldsForModel: (qn: string) => models.find((m) => m.qualifiedName === qn)?.fields ?? [],
|
|
17
|
-
} as unknown as SchemaRegistry;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function makeExternalModel(name: string, source: string): ResolvedModel {
|
|
21
|
-
return {
|
|
22
|
-
qualifiedName: name,
|
|
23
|
-
app: 'test',
|
|
24
|
-
module: name.split('.')[0],
|
|
25
|
-
name: name.split('.')[1],
|
|
26
|
-
auditLog: false,
|
|
27
|
-
traits: [],
|
|
28
|
-
fields: [],
|
|
29
|
-
indexes: [],
|
|
30
|
-
source,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function makeInternalModel(name: string): ResolvedModel {
|
|
35
|
-
return {
|
|
36
|
-
qualifiedName: name,
|
|
37
|
-
app: 'test',
|
|
38
|
-
module: name.split('.')[0],
|
|
39
|
-
name: name.split('.')[1],
|
|
40
|
-
auditLog: false,
|
|
41
|
-
traits: [],
|
|
42
|
-
fields: [],
|
|
43
|
-
indexes: [],
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
describe('resolveModelIncludes - cross-boundary', () => {
|
|
48
|
-
describe('internal to external (link)', () => {
|
|
49
|
-
it('uses batchGet when adapter supports it', async () => {
|
|
50
|
-
const batchGet = vi.fn().mockResolvedValue([
|
|
51
|
-
{ id: 'cus_1', email: 'a@b.com' },
|
|
52
|
-
{ id: 'cus_2', email: 'c@d.com' },
|
|
53
|
-
]);
|
|
54
|
-
const adapter: DataAdapter = {
|
|
55
|
-
async get() {
|
|
56
|
-
return null;
|
|
57
|
-
},
|
|
58
|
-
batchGet,
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const adapterRegistry = new AdapterRegistry();
|
|
62
|
-
adapterRegistry.register('stripe', adapter);
|
|
63
|
-
|
|
64
|
-
const externalModel = makeExternalModel('billing.Customer', 'stripe');
|
|
65
|
-
const internalModel = makeInternalModel('sales.Order');
|
|
66
|
-
|
|
67
|
-
const relationships: ModelRelationship[] = [
|
|
68
|
-
{ type: 'link', from: 'sales.Order', field: 'customer', to: 'billing.Customer' },
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
const registry = makeRegistry([internalModel, externalModel], relationships);
|
|
72
|
-
|
|
73
|
-
const records = [
|
|
74
|
-
{ id: 'ord_1', customer: 'cus_1', total: 100 },
|
|
75
|
-
{ id: 'ord_2', customer: 'cus_2', total: 200 },
|
|
76
|
-
{ id: 'ord_3', customer: 'cus_1', total: 150 },
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
const fields = {
|
|
80
|
-
'billing.Customer': {
|
|
81
|
-
id: { type: 'string' as const },
|
|
82
|
-
email: { type: 'string' as const },
|
|
83
|
-
},
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
await resolveModelIncludes(records, ['customer'], registry, {} as any, 'sales.Order', {
|
|
87
|
-
adapterRegistry,
|
|
88
|
-
externalModelFields: fields,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
expect(batchGet).toHaveBeenCalledWith('billing.Customer', ['cus_1', 'cus_2']);
|
|
92
|
-
expect(records[0].customer).toEqual({ id: 'cus_1', email: 'a@b.com' });
|
|
93
|
-
expect(records[1].customer).toEqual({ id: 'cus_2', email: 'c@d.com' });
|
|
94
|
-
expect(records[2].customer).toEqual({ id: 'cus_1', email: 'a@b.com' });
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('falls back to N+1 get when batchGet not available', async () => {
|
|
98
|
-
const get = vi.fn().mockImplementation(async (_model: string, id: string) => {
|
|
99
|
-
if (id === 'cus_1') return { id: 'cus_1', email: 'a@b.com' };
|
|
100
|
-
if (id === 'cus_2') return { id: 'cus_2', email: 'c@d.com' };
|
|
101
|
-
return null;
|
|
102
|
-
});
|
|
103
|
-
const adapter: DataAdapter = { get };
|
|
104
|
-
|
|
105
|
-
const adapterRegistry = new AdapterRegistry();
|
|
106
|
-
adapterRegistry.register('stripe', adapter);
|
|
107
|
-
|
|
108
|
-
const externalModel = makeExternalModel('billing.Customer', 'stripe');
|
|
109
|
-
const internalModel = makeInternalModel('sales.Order');
|
|
110
|
-
|
|
111
|
-
const relationships: ModelRelationship[] = [
|
|
112
|
-
{ type: 'link', from: 'sales.Order', field: 'customer', to: 'billing.Customer' },
|
|
113
|
-
];
|
|
114
|
-
|
|
115
|
-
const registry = makeRegistry([internalModel, externalModel], relationships);
|
|
116
|
-
|
|
117
|
-
const records = [
|
|
118
|
-
{ id: 'ord_1', customer: 'cus_1', total: 100 },
|
|
119
|
-
{ id: 'ord_2', customer: 'cus_2', total: 200 },
|
|
120
|
-
];
|
|
121
|
-
|
|
122
|
-
await resolveModelIncludes(records, ['customer'], registry, {} as any, 'sales.Order', {
|
|
123
|
-
adapterRegistry,
|
|
124
|
-
externalModelFields: {
|
|
125
|
-
'billing.Customer': { id: { type: 'string' }, email: { type: 'string' } },
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
expect(get).toHaveBeenCalledTimes(2);
|
|
130
|
-
expect(records[0].customer).toEqual({ id: 'cus_1', email: 'a@b.com' });
|
|
131
|
-
expect(records[1].customer).toEqual({ id: 'cus_2', email: 'c@d.com' });
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('sets null for missing external records', async () => {
|
|
135
|
-
const batchGet = vi.fn().mockResolvedValue([{ id: 'cus_1', email: 'a@b.com' }]);
|
|
136
|
-
const adapter: DataAdapter = {
|
|
137
|
-
async get() {
|
|
138
|
-
return null;
|
|
139
|
-
},
|
|
140
|
-
batchGet,
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const adapterRegistry = new AdapterRegistry();
|
|
144
|
-
adapterRegistry.register('stripe', adapter);
|
|
145
|
-
|
|
146
|
-
const externalModel = makeExternalModel('billing.Customer', 'stripe');
|
|
147
|
-
const internalModel = makeInternalModel('sales.Order');
|
|
148
|
-
|
|
149
|
-
const relationships: ModelRelationship[] = [
|
|
150
|
-
{ type: 'link', from: 'sales.Order', field: 'customer', to: 'billing.Customer' },
|
|
151
|
-
];
|
|
152
|
-
|
|
153
|
-
const registry = makeRegistry([internalModel, externalModel], relationships);
|
|
154
|
-
|
|
155
|
-
const records = [
|
|
156
|
-
{ id: 'ord_1', customer: 'cus_1', total: 100 },
|
|
157
|
-
{ id: 'ord_2', customer: 'cus_99', total: 200 },
|
|
158
|
-
];
|
|
159
|
-
|
|
160
|
-
await resolveModelIncludes(records, ['customer'], registry, {} as any, 'sales.Order', {
|
|
161
|
-
adapterRegistry,
|
|
162
|
-
externalModelFields: {
|
|
163
|
-
'billing.Customer': { id: { type: 'string' }, email: { type: 'string' } },
|
|
164
|
-
},
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
expect(records[0].customer).toEqual({ id: 'cus_1', email: 'a@b.com' });
|
|
168
|
-
expect(records[1].customer).toBeNull();
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('applies field mapping to resolved records', async () => {
|
|
172
|
-
const batchGet = vi
|
|
173
|
-
.fn()
|
|
174
|
-
.mockResolvedValue([
|
|
175
|
-
{ id: 'cus_1', metadata: { company_name: 'Acme Corp' }, email: 'acme@test.com' },
|
|
176
|
-
]);
|
|
177
|
-
const adapter: DataAdapter = {
|
|
178
|
-
async get() {
|
|
179
|
-
return null;
|
|
180
|
-
},
|
|
181
|
-
batchGet,
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
const adapterRegistry = new AdapterRegistry();
|
|
185
|
-
adapterRegistry.register('stripe', adapter);
|
|
186
|
-
|
|
187
|
-
const externalModel = makeExternalModel('billing.Customer', 'stripe');
|
|
188
|
-
const internalModel = makeInternalModel('sales.Order');
|
|
189
|
-
|
|
190
|
-
const relationships: ModelRelationship[] = [
|
|
191
|
-
{ type: 'link', from: 'sales.Order', field: 'customer', to: 'billing.Customer' },
|
|
192
|
-
];
|
|
193
|
-
|
|
194
|
-
const registry = makeRegistry([internalModel, externalModel], relationships);
|
|
195
|
-
|
|
196
|
-
const records = [{ id: 'ord_1', customer: 'cus_1' }];
|
|
197
|
-
|
|
198
|
-
await resolveModelIncludes(records, ['customer'], registry, {} as any, 'sales.Order', {
|
|
199
|
-
adapterRegistry,
|
|
200
|
-
externalModelFields: {
|
|
201
|
-
'billing.Customer': {
|
|
202
|
-
id: { type: 'string' },
|
|
203
|
-
name: { type: 'string', from: 'metadata.company_name' },
|
|
204
|
-
email: { type: 'string' },
|
|
205
|
-
},
|
|
206
|
-
},
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
expect(records[0].customer).toEqual({
|
|
210
|
-
id: 'cus_1',
|
|
211
|
-
name: 'Acme Corp',
|
|
212
|
-
email: 'acme@test.com',
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
describe('internal to external (hasMany)', () => {
|
|
218
|
-
it('uses adapter.list with foreign key filter', async () => {
|
|
219
|
-
const list = vi.fn().mockResolvedValue({
|
|
220
|
-
data: [
|
|
221
|
-
{ id: 'inv_1', order_id: 'ord_1', amount: 100 },
|
|
222
|
-
{ id: 'inv_2', order_id: 'ord_1', amount: 50 },
|
|
223
|
-
{ id: 'inv_3', order_id: 'ord_2', amount: 200 },
|
|
224
|
-
],
|
|
225
|
-
});
|
|
226
|
-
const adapter: DataAdapter = {
|
|
227
|
-
async get() {
|
|
228
|
-
return null;
|
|
229
|
-
},
|
|
230
|
-
list,
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const adapterRegistry = new AdapterRegistry();
|
|
234
|
-
adapterRegistry.register('stripe', adapter);
|
|
235
|
-
|
|
236
|
-
const externalModel = makeExternalModel('billing.Invoice', 'stripe');
|
|
237
|
-
const internalModel = makeInternalModel('sales.Order');
|
|
238
|
-
|
|
239
|
-
const relationships: ModelRelationship[] = [
|
|
240
|
-
{
|
|
241
|
-
type: 'hasMany',
|
|
242
|
-
from: 'sales.Order',
|
|
243
|
-
field: 'invoices',
|
|
244
|
-
to: 'billing.Invoice',
|
|
245
|
-
foreignKey: 'order_id',
|
|
246
|
-
},
|
|
247
|
-
];
|
|
248
|
-
|
|
249
|
-
const registry = makeRegistry([internalModel, externalModel], relationships);
|
|
250
|
-
|
|
251
|
-
const records: Record<string, unknown>[] = [
|
|
252
|
-
{ id: 'ord_1', total: 150 },
|
|
253
|
-
{ id: 'ord_2', total: 200 },
|
|
254
|
-
];
|
|
255
|
-
|
|
256
|
-
await resolveModelIncludes(records, ['invoices'], registry, {} as any, 'sales.Order', {
|
|
257
|
-
adapterRegistry,
|
|
258
|
-
externalModelFields: {
|
|
259
|
-
'billing.Invoice': {
|
|
260
|
-
id: { type: 'string' },
|
|
261
|
-
order_id: { type: 'string' },
|
|
262
|
-
amount: { type: 'decimal' },
|
|
263
|
-
},
|
|
264
|
-
},
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
expect(list).toHaveBeenCalledWith('billing.Invoice', {
|
|
268
|
-
filters: [{ field: 'order_id', operator: 'in', value: ['ord_1', 'ord_2'] }],
|
|
269
|
-
});
|
|
270
|
-
expect(records[0].invoices).toHaveLength(2);
|
|
271
|
-
expect(records[1].invoices).toHaveLength(1);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it('returns empty array when adapter has no list capability', async () => {
|
|
275
|
-
const adapter: DataAdapter = {
|
|
276
|
-
async get() {
|
|
277
|
-
return null;
|
|
278
|
-
},
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const adapterRegistry = new AdapterRegistry();
|
|
282
|
-
adapterRegistry.register('stripe', adapter);
|
|
283
|
-
|
|
284
|
-
const externalModel = makeExternalModel('billing.Invoice', 'stripe');
|
|
285
|
-
const internalModel = makeInternalModel('sales.Order');
|
|
286
|
-
|
|
287
|
-
const relationships: ModelRelationship[] = [
|
|
288
|
-
{
|
|
289
|
-
type: 'hasMany',
|
|
290
|
-
from: 'sales.Order',
|
|
291
|
-
field: 'invoices',
|
|
292
|
-
to: 'billing.Invoice',
|
|
293
|
-
foreignKey: 'order_id',
|
|
294
|
-
},
|
|
295
|
-
];
|
|
296
|
-
|
|
297
|
-
const registry = makeRegistry([internalModel, externalModel], relationships);
|
|
298
|
-
|
|
299
|
-
const records: Record<string, unknown>[] = [{ id: 'ord_1', total: 100 }];
|
|
300
|
-
|
|
301
|
-
await resolveModelIncludes(records, ['invoices'], registry, {} as any, 'sales.Order', {
|
|
302
|
-
adapterRegistry,
|
|
303
|
-
externalModelFields: {
|
|
304
|
-
'billing.Invoice': { id: { type: 'string' }, order_id: { type: 'string' } },
|
|
305
|
-
},
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
expect(records[0].invoices).toEqual([]);
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
describe('edge cases', () => {
|
|
313
|
-
it('handles empty records array', async () => {
|
|
314
|
-
const adapterRegistry = new AdapterRegistry();
|
|
315
|
-
adapterRegistry.register('stripe', {
|
|
316
|
-
async get() {
|
|
317
|
-
return null;
|
|
318
|
-
},
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
const registry = makeRegistry([], []);
|
|
322
|
-
const records: Record<string, unknown>[] = [];
|
|
323
|
-
|
|
324
|
-
await resolveModelIncludes(records, ['customer'], registry, {} as any, 'sales.Order', {
|
|
325
|
-
adapterRegistry,
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
expect(records).toEqual([]);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it('handles null foreign key values gracefully', async () => {
|
|
332
|
-
const batchGet = vi.fn().mockResolvedValue([]);
|
|
333
|
-
const adapter: DataAdapter = {
|
|
334
|
-
async get() {
|
|
335
|
-
return null;
|
|
336
|
-
},
|
|
337
|
-
batchGet,
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
const adapterRegistry = new AdapterRegistry();
|
|
341
|
-
adapterRegistry.register('stripe', adapter);
|
|
342
|
-
|
|
343
|
-
const externalModel = makeExternalModel('billing.Customer', 'stripe');
|
|
344
|
-
const internalModel = makeInternalModel('sales.Order');
|
|
345
|
-
|
|
346
|
-
const relationships: ModelRelationship[] = [
|
|
347
|
-
{ type: 'link', from: 'sales.Order', field: 'customer', to: 'billing.Customer' },
|
|
348
|
-
];
|
|
349
|
-
|
|
350
|
-
const registry = makeRegistry([internalModel, externalModel], relationships);
|
|
351
|
-
|
|
352
|
-
const records = [
|
|
353
|
-
{ id: 'ord_1', customer: null },
|
|
354
|
-
{ id: 'ord_2', customer: undefined },
|
|
355
|
-
];
|
|
356
|
-
|
|
357
|
-
await resolveModelIncludes(records, ['customer'], registry, {} as any, 'sales.Order', {
|
|
358
|
-
adapterRegistry,
|
|
359
|
-
externalModelFields: { 'billing.Customer': { id: { type: 'string' } } },
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
expect(records[0].customer).toBeNull();
|
|
363
|
-
expect(records[1].customer).toBeNull();
|
|
364
|
-
});
|
|
365
|
-
});
|
|
366
|
-
});
|