@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.
Files changed (197) hide show
  1. package/package.json +6 -2
  2. package/.claude/skills/extend-core/SKILL.md +0 -133
  3. package/.turbo/turbo-build.log +0 -4
  4. package/CHANGELOG.md +0 -25
  5. package/CLAUDE.md +0 -180
  6. package/src/__tests__/coerce.test.ts +0 -154
  7. package/src/__tests__/context.test.ts +0 -111
  8. package/src/__tests__/helpers.ts +0 -21
  9. package/src/__tests__/index.test.ts +0 -7
  10. package/src/__tests__/widgets.test.ts +0 -197
  11. package/src/api/__tests__/handlers.test.ts +0 -389
  12. package/src/api/__tests__/include-resolver.test.ts +0 -393
  13. package/src/api/__tests__/middleware.test.ts +0 -100
  14. package/src/api/__tests__/openapi-schema.test.ts +0 -210
  15. package/src/api/__tests__/query-parser.test.ts +0 -291
  16. package/src/api/__tests__/route-generator.test.ts +0 -137
  17. package/src/api/__tests__/server.test.ts +0 -73
  18. package/src/api/__tests__/swagger.test.ts +0 -166
  19. package/src/api/handlers.ts +0 -274
  20. package/src/api/include-resolver.ts +0 -27
  21. package/src/api/index.ts +0 -4
  22. package/src/api/meta-handler.ts +0 -254
  23. package/src/api/openapi-schema.ts +0 -99
  24. package/src/api/query-parser.ts +0 -315
  25. package/src/api/route-generator.ts +0 -448
  26. package/src/api/server.ts +0 -147
  27. package/src/api/types.ts +0 -16
  28. package/src/audit/__tests__/audit.test.ts +0 -144
  29. package/src/audit/index.ts +0 -3
  30. package/src/audit/record.ts +0 -69
  31. package/src/audit/tables.ts +0 -48
  32. package/src/audit/types.ts +0 -26
  33. package/src/auth/__tests__/core-module.test.ts +0 -54
  34. package/src/auth/__tests__/debug.test.ts +0 -47
  35. package/src/auth/__tests__/field-permissions.test.ts +0 -245
  36. package/src/auth/__tests__/integration.test.ts +0 -208
  37. package/src/auth/__tests__/meta-boot.test.ts +0 -538
  38. package/src/auth/__tests__/model-permissions.test.ts +0 -205
  39. package/src/auth/__tests__/password.test.ts +0 -29
  40. package/src/auth/__tests__/permission-registry.test.ts +0 -313
  41. package/src/auth/__tests__/scope-hook.test.ts +0 -509
  42. package/src/auth/__tests__/scope-registry.test.ts +0 -297
  43. package/src/auth/__tests__/scopes.test.ts +0 -66
  44. package/src/auth/__tests__/session.test.ts +0 -214
  45. package/src/auth/core-models.ts +0 -52
  46. package/src/auth/core-module.ts +0 -59
  47. package/src/auth/debug.ts +0 -157
  48. package/src/auth/field-permissions.ts +0 -116
  49. package/src/auth/index.ts +0 -37
  50. package/src/auth/model-permissions.ts +0 -59
  51. package/src/auth/password.ts +0 -22
  52. package/src/auth/permission-registry.ts +0 -171
  53. package/src/auth/scope-filters.ts +0 -11
  54. package/src/auth/scope-registry.ts +0 -121
  55. package/src/auth/scopes.ts +0 -146
  56. package/src/auth/seed.ts +0 -44
  57. package/src/auth/session.ts +0 -178
  58. package/src/auth/types.ts +0 -50
  59. package/src/boot/__tests__/page-scanning.test.ts +0 -170
  60. package/src/boot/__tests__/page-utils.test.ts +0 -225
  61. package/src/boot/__tests__/project-scanner.test.ts +0 -88
  62. package/src/boot/dependency-sort.ts +0 -82
  63. package/src/boot/discovery.ts +0 -85
  64. package/src/boot/index.ts +0 -457
  65. package/src/boot/page-utils.ts +0 -110
  66. package/src/boot/project-scanner.ts +0 -397
  67. package/src/boot/schema-loader.ts +0 -26
  68. package/src/boot/schema-merger.ts +0 -125
  69. package/src/boot/traits.ts +0 -25
  70. package/src/boot/types.ts +0 -73
  71. package/src/context.ts +0 -105
  72. package/src/db/__tests__/cascade-delete.test.ts +0 -182
  73. package/src/db/__tests__/desired-state.test.ts +0 -136
  74. package/src/db/__tests__/diff-engine.test.ts +0 -635
  75. package/src/db/__tests__/field-mapper.test.ts +0 -355
  76. package/src/db/__tests__/introspect.test.ts +0 -70
  77. package/src/db/__tests__/search-filter.test.ts +0 -45
  78. package/src/db/__tests__/sequence.test.ts +0 -221
  79. package/src/db/auto-sync.ts +0 -133
  80. package/src/db/client.ts +0 -147
  81. package/src/db/desired-state.ts +0 -98
  82. package/src/db/diff-engine.ts +0 -305
  83. package/src/db/field-mapper.ts +0 -504
  84. package/src/db/filter-applier.ts +0 -89
  85. package/src/db/include-resolver.ts +0 -40
  86. package/src/db/index.ts +0 -23
  87. package/src/db/introspect.ts +0 -265
  88. package/src/db/model-include-resolver.ts +0 -327
  89. package/src/db/model-ops.ts +0 -281
  90. package/src/db/scope-enforcer.ts +0 -37
  91. package/src/db/types.ts +0 -98
  92. package/src/errors.ts +0 -41
  93. package/src/events/__tests__/bus.test.ts +0 -105
  94. package/src/events/bus.ts +0 -89
  95. package/src/events/index.ts +0 -2
  96. package/src/events/types.ts +0 -9
  97. package/src/external-model/__tests__/computed-fields.test.ts +0 -106
  98. package/src/external-model/__tests__/field-mapper.test.ts +0 -160
  99. package/src/external-model/__tests__/in-memory-ops.test.ts +0 -247
  100. package/src/external-model/__tests__/mutation-executor.test.ts +0 -160
  101. package/src/external-model/__tests__/query-executor.test.ts +0 -284
  102. package/src/external-model/__tests__/schema-converter.test.ts +0 -174
  103. package/src/external-model/computed-fields.ts +0 -15
  104. package/src/external-model/define.ts +0 -5
  105. package/src/external-model/external-model-ops.ts +0 -108
  106. package/src/external-model/field-mapper.ts +0 -66
  107. package/src/external-model/in-memory-ops.ts +0 -107
  108. package/src/external-model/index.ts +0 -7
  109. package/src/external-model/mutation-executor.ts +0 -71
  110. package/src/external-model/query-executor.ts +0 -100
  111. package/src/external-model/schema-converter.ts +0 -53
  112. package/src/external-model/types.ts +0 -32
  113. package/src/fixtures/__tests__/fixtures.test.ts +0 -203
  114. package/src/fixtures/index.ts +0 -10
  115. package/src/fixtures/loader.ts +0 -196
  116. package/src/fixtures/registry.ts +0 -125
  117. package/src/fixtures/types.ts +0 -33
  118. package/src/helpers/assert-ownership.ts +0 -19
  119. package/src/helpers/coerce.ts +0 -28
  120. package/src/helpers/stamping.ts +0 -28
  121. package/src/helpers/validation.ts +0 -14
  122. package/src/hooks/__tests__/context.test.ts +0 -73
  123. package/src/hooks/__tests__/executor.test.ts +0 -433
  124. package/src/hooks/__tests__/middleware.test.ts +0 -224
  125. package/src/hooks/__tests__/registry.test.ts +0 -50
  126. package/src/hooks/context.ts +0 -89
  127. package/src/hooks/errors.ts +0 -11
  128. package/src/hooks/executor.ts +0 -115
  129. package/src/hooks/index.ts +0 -10
  130. package/src/hooks/middleware.ts +0 -220
  131. package/src/hooks/registry.ts +0 -20
  132. package/src/hooks/types.ts +0 -32
  133. package/src/index.ts +0 -172
  134. package/src/jobs/__tests__/enqueue.test.ts +0 -77
  135. package/src/jobs/__tests__/integration.test.ts +0 -71
  136. package/src/jobs/__tests__/registry.test.ts +0 -103
  137. package/src/jobs/__tests__/scheduler.test.ts +0 -92
  138. package/src/jobs/__tests__/worker-execution.test.ts +0 -202
  139. package/src/jobs/__tests__/worker.test.ts +0 -119
  140. package/src/jobs/enqueue.ts +0 -93
  141. package/src/jobs/index.ts +0 -14
  142. package/src/jobs/registry.ts +0 -92
  143. package/src/jobs/scheduler.ts +0 -205
  144. package/src/jobs/tables.ts +0 -132
  145. package/src/jobs/types.ts +0 -62
  146. package/src/jobs/worker.ts +0 -272
  147. package/src/model-api/__tests__/cross-boundary-includes.test.ts +0 -366
  148. package/src/model-api/__tests__/extended-api.test.ts +0 -244
  149. package/src/model-api/__tests__/filter-applier.test.ts +0 -177
  150. package/src/model-api/__tests__/filter-translator.test.ts +0 -186
  151. package/src/model-api/__tests__/include-resolver.test.ts +0 -226
  152. package/src/model-api/__tests__/model-access.test.ts +0 -284
  153. package/src/model-api/__tests__/query-builder.test.ts +0 -224
  154. package/src/model-api/__tests__/scope-enforcer.test.ts +0 -268
  155. package/src/model-api/field-access.ts +0 -28
  156. package/src/model-api/filter-applier.ts +0 -1
  157. package/src/model-api/filter-translator.ts +0 -67
  158. package/src/model-api/include-resolver.ts +0 -2
  159. package/src/model-api/index.ts +0 -86
  160. package/src/model-api/query-builder.ts +0 -155
  161. package/src/model-api/scope-enforcer.ts +0 -3
  162. package/src/model-api/types.ts +0 -139
  163. package/src/plugins/__tests__/adapter-registry.test.ts +0 -92
  164. package/src/plugins/__tests__/lifecycle.test.ts +0 -96
  165. package/src/plugins/__tests__/loader.test.ts +0 -273
  166. package/src/plugins/__tests__/validator.test.ts +0 -275
  167. package/src/plugins/adapter-registry.ts +0 -42
  168. package/src/plugins/define.ts +0 -5
  169. package/src/plugins/index.ts +0 -28
  170. package/src/plugins/lifecycle.ts +0 -27
  171. package/src/plugins/loader.ts +0 -126
  172. package/src/plugins/types.ts +0 -76
  173. package/src/plugins/validator.ts +0 -141
  174. package/src/schema/__tests__/registry-models-by-module.test.ts +0 -58
  175. package/src/schema/registry.ts +0 -93
  176. package/src/schema/relationships.ts +0 -93
  177. package/src/schema/types.ts +0 -43
  178. package/src/services/__tests__/integration.test.ts +0 -63
  179. package/src/services/__tests__/registry.test.ts +0 -175
  180. package/src/services/index.ts +0 -13
  181. package/src/services/registry.ts +0 -156
  182. package/src/services/types.ts +0 -27
  183. package/src/validation/__tests__/field-validator.test.ts +0 -195
  184. package/src/validation/field-validator.ts +0 -113
  185. package/src/validation/index.ts +0 -1
  186. package/src/widgets/index.ts +0 -3
  187. package/src/widgets/slot-validator.ts +0 -87
  188. package/src/widgets/widget-registry.ts +0 -32
  189. package/tests/boot.test.ts +0 -323
  190. package/tests/dependency-sort.test.ts +0 -99
  191. package/tests/discovery.test.ts +0 -126
  192. package/tests/registry.test.ts +0 -216
  193. package/tests/schema-loader.test.ts +0 -52
  194. package/tests/schema-merger.test.ts +0 -180
  195. package/tsconfig.json +0 -9
  196. package/tsconfig.tsbuildinfo +0 -1
  197. package/vitest.config.ts +0 -14
@@ -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
- });