@nest-batch/typeorm 0.2.0

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/src/adapters/index.d.ts +18 -0
  4. package/dist/src/adapters/index.d.ts.map +1 -0
  5. package/dist/src/adapters/index.js +35 -0
  6. package/dist/src/adapters/index.js.map +1 -0
  7. package/dist/src/adapters/typeorm.adapter.d.ts +42 -0
  8. package/dist/src/adapters/typeorm.adapter.d.ts.map +1 -0
  9. package/dist/src/adapters/typeorm.adapter.js +85 -0
  10. package/dist/src/adapters/typeorm.adapter.js.map +1 -0
  11. package/dist/src/entities/index.d.ts +2 -0
  12. package/dist/src/entities/index.d.ts.map +1 -0
  13. package/dist/src/entities/index.js +20 -0
  14. package/dist/src/entities/index.js.map +1 -0
  15. package/dist/src/entities/job-meta.entities.d.ts +96 -0
  16. package/dist/src/entities/job-meta.entities.d.ts.map +1 -0
  17. package/dist/src/entities/job-meta.entities.js +357 -0
  18. package/dist/src/entities/job-meta.entities.js.map +1 -0
  19. package/dist/src/index.d.ts +6 -0
  20. package/dist/src/index.d.ts.map +1 -0
  21. package/dist/src/index.js +74 -0
  22. package/dist/src/index.js.map +1 -0
  23. package/dist/src/migrations/1700000000000-CreateBatchMeta.d.ts +28 -0
  24. package/dist/src/migrations/1700000000000-CreateBatchMeta.d.ts.map +1 -0
  25. package/dist/src/migrations/1700000000000-CreateBatchMeta.js +83 -0
  26. package/dist/src/migrations/1700000000000-CreateBatchMeta.js.map +1 -0
  27. package/dist/src/repository/typeorm-job-repository.d.ts +57 -0
  28. package/dist/src/repository/typeorm-job-repository.d.ts.map +1 -0
  29. package/dist/src/repository/typeorm-job-repository.js +489 -0
  30. package/dist/src/repository/typeorm-job-repository.js.map +1 -0
  31. package/dist/src/transaction/typeorm-transaction-manager.d.ts +24 -0
  32. package/dist/src/transaction/typeorm-transaction-manager.d.ts.map +1 -0
  33. package/dist/src/transaction/typeorm-transaction-manager.js +55 -0
  34. package/dist/src/transaction/typeorm-transaction-manager.js.map +1 -0
  35. package/dist/src/typeorm.driver-provider.d.ts +22 -0
  36. package/dist/src/typeorm.driver-provider.d.ts.map +1 -0
  37. package/dist/src/typeorm.driver-provider.js +32 -0
  38. package/dist/src/typeorm.driver-provider.js.map +1 -0
  39. package/package.json +69 -0
  40. package/src/adapters/index.ts +17 -0
  41. package/src/adapters/typeorm.adapter.ts +82 -0
  42. package/src/entities/index.ts +1 -0
  43. package/src/entities/job-meta.entities.ts +184 -0
  44. package/src/index.ts +42 -0
  45. package/src/migrations/1700000000000-CreateBatchMeta.ts +100 -0
  46. package/src/repository/typeorm-job-repository.ts +548 -0
  47. package/src/transaction/typeorm-transaction-manager.ts +47 -0
  48. package/src/typeorm.driver-provider.ts +23 -0
@@ -0,0 +1,548 @@
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { DataSource, EntityManager, In } from 'typeorm';
4
+ import {
5
+ JobRepository,
6
+ JobExecutionAlreadyRunningError,
7
+ assertJsonSerializable,
8
+ serializeContext,
9
+ deserializeContext,
10
+ JobStatus,
11
+ StepStatus,
12
+ } from '@nest-batch/core';
13
+ import type {
14
+ JobInstance,
15
+ JobExecution,
16
+ JobExecutionPatch,
17
+ JobParameters,
18
+ StepExecution,
19
+ StepExecutionPatch,
20
+ ExecutionContext,
21
+ ExecutionScope,
22
+ JobInstanceFilter,
23
+ JobExecutionFilter,
24
+ } from '@nest-batch/core';
25
+ import { TypeOrmDriverProvider } from '../typeorm.driver-provider';
26
+
27
+ function scopeKey(scope: ExecutionScope): string {
28
+ if ('jobExecutionId' in scope) return `job::${scope.jobExecutionId}`;
29
+ return `step::${scope.stepExecutionId}`;
30
+ }
31
+
32
+ function deepClone<T>(value: T): T {
33
+ if (value === null || typeof value !== 'object') return value;
34
+ if (value instanceof Date) return new Date(value.getTime()) as unknown as T;
35
+ if (Array.isArray(value)) return value.map((v) => deepClone(v)) as unknown as T;
36
+ const out: Record<string, unknown> = {};
37
+ for (const k of Object.keys(value as Record<string, unknown>)) {
38
+ out[k] = deepClone((value as Record<string, unknown>)[k]);
39
+ }
40
+ return out as T;
41
+ }
42
+
43
+ interface JobInstanceRow {
44
+ id: string;
45
+ job_name: string;
46
+ job_key: string;
47
+ created_at: string | Date;
48
+ }
49
+
50
+ interface JobExecutionRow {
51
+ id: string;
52
+ job_instance_id: string;
53
+ status: string;
54
+ start_time: string | Date | null;
55
+ end_time: string | Date | null;
56
+ exit_code: string;
57
+ exit_message: string;
58
+ params: string;
59
+ }
60
+
61
+ interface StepExecutionRow {
62
+ id: string;
63
+ job_execution_id: string;
64
+ step_name: string;
65
+ status: string;
66
+ read_count: number;
67
+ write_count: number;
68
+ skip_count: number;
69
+ rollback_count: number;
70
+ commit_count: number;
71
+ exit_code: string;
72
+ exit_message: string;
73
+ created_at: string | Date;
74
+ }
75
+
76
+ interface ContextRow {
77
+ data: string;
78
+ version: number;
79
+ }
80
+
81
+ function mapJobInstance(r: JobInstanceRow): JobInstance {
82
+ return {
83
+ id: r.id,
84
+ jobName: r.job_name,
85
+ jobKey: r.job_key,
86
+ createdAt: r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
87
+ };
88
+ }
89
+
90
+ function mapJobExecution(r: JobExecutionRow): JobExecution {
91
+ let params: JobParameters = {};
92
+ if (r.params && r.params.length > 0) {
93
+ try {
94
+ params = deserializeContext<JobParameters>(r.params);
95
+ } catch {
96
+ params = {};
97
+ }
98
+ }
99
+ return {
100
+ id: r.id,
101
+ jobInstanceId: r.job_instance_id,
102
+ status: r.status as JobStatus,
103
+ startTime: r.start_time ? (r.start_time instanceof Date ? r.start_time : new Date(r.start_time)) : null,
104
+ endTime: r.end_time ? (r.end_time instanceof Date ? r.end_time : new Date(r.end_time)) : null,
105
+ exitCode: r.exit_code,
106
+ exitMessage: r.exit_message,
107
+ params,
108
+ };
109
+ }
110
+
111
+ function mapStepExecution(r: StepExecutionRow): StepExecution {
112
+ return {
113
+ id: r.id,
114
+ jobExecutionId: r.job_execution_id,
115
+ stepName: r.step_name,
116
+ status: r.status as StepStatus,
117
+ readCount: r.read_count,
118
+ writeCount: r.write_count,
119
+ skipCount: r.skip_count,
120
+ rollbackCount: r.rollback_count,
121
+ commitCount: r.commit_count,
122
+ startTime: null,
123
+ endTime: null,
124
+ exitCode: r.exit_code,
125
+ exitMessage: r.exit_message,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * TypeORM 1.0.0-backed `JobRepository`.
131
+ *
132
+ * The package is driver-agnostic: the actual `DataSource` is
133
+ * provided by the `@nest-batch/postgresql` (or future
134
+ * `@nest-batch/mysql`) driver sibling via the `TypeOrmDriverProvider`
135
+ * token. The repository itself uses raw SQL via `EntityManager.query`
136
+ * so the column-shape contract is owned by the driver sibling
137
+ * (the bundled 6-table migration).
138
+ *
139
+ * The contract guarantees:
140
+ * - `getOrCreateJobInstance` is race-safe via the (jobName, jobKey)
141
+ * unique index.
142
+ * - `createExecutionAtomic` runs inside a single transaction that
143
+ * (a) idempotently upserts the instance row, (b) acquires a row
144
+ * lock with `SELECT ... FOR UPDATE SKIP LOCKED` (PostgreSQL) or
145
+ * a plain select (SQLite test driver), and (c) rejects with
146
+ * `JobExecutionAlreadyRunningError` if a STARTING/STARTED
147
+ * execution already exists.
148
+ * - `saveExecutionContext` deep-clones the data and auto-increments
149
+ * the version counter when `version` is omitted.
150
+ * - `findLatestStepExecution` orders by `created_at` descending.
151
+ */
152
+ @Injectable()
153
+ export class TypeOrmJobRepository extends JobRepository {
154
+ constructor(
155
+ @Inject(TypeOrmDriverProvider) private readonly dataSource: DataSource,
156
+ ) {
157
+ super();
158
+ }
159
+
160
+ private em(): EntityManager {
161
+ return this.dataSource.manager;
162
+ }
163
+
164
+ async getOrCreateJobInstance(name: string, jobKey: string): Promise<JobInstance> {
165
+ const existing = await this.em().query(
166
+ `SELECT "id", "job_name", "job_key", "created_at"
167
+ FROM "batch_job_instance"
168
+ WHERE "job_name" = $1 AND "job_key" = $2
169
+ LIMIT 1`,
170
+ [name, jobKey],
171
+ ) as JobInstanceRow[];
172
+ if (existing.length > 0) return mapJobInstance(existing[0]!);
173
+
174
+ const id = randomUUID();
175
+ try {
176
+ const inserted = await this.em().query(
177
+ `INSERT INTO "batch_job_instance" ("id", "job_name", "job_key", "created_at")
178
+ VALUES ($1, $2, $3, NOW())
179
+ ON CONFLICT ("job_name", "job_key") DO NOTHING
180
+ RETURNING "id", "job_name", "job_key", "created_at"`,
181
+ [id, name, jobKey],
182
+ ) as JobInstanceRow[];
183
+ if (inserted.length > 0) return mapJobInstance(inserted[0]!);
184
+ } catch {
185
+ // Fall through to read-back.
186
+ }
187
+ const winner = await this.em().query(
188
+ `SELECT "id", "job_name", "job_key", "created_at"
189
+ FROM "batch_job_instance"
190
+ WHERE "job_name" = $1 AND "job_key" = $2
191
+ LIMIT 1`,
192
+ [name, jobKey],
193
+ ) as JobInstanceRow[];
194
+ if (winner.length === 0) {
195
+ throw new Error(
196
+ `Failed to upsert JobInstance (${name}, ${jobKey}) and could not read it back`,
197
+ );
198
+ }
199
+ return mapJobInstance(winner[0]!);
200
+ }
201
+
202
+ async createJobExecution(
203
+ jobInstanceId: string,
204
+ params: JobParameters,
205
+ ): Promise<JobExecution> {
206
+ const exec = {
207
+ id: randomUUID(),
208
+ job_instance_id: jobInstanceId,
209
+ status: JobStatus.STARTING,
210
+ start_time: null as Date | null,
211
+ end_time: null as Date | null,
212
+ exit_code: '',
213
+ exit_message: '',
214
+ params: serializeContext(deepClone(params)),
215
+ };
216
+ const rows = await this.em().query(
217
+ `INSERT INTO "batch_job_execution" ("id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params")
218
+ VALUES ($1, $2, $3, NULL, NULL, $4, $5, $6)
219
+ RETURNING "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"`,
220
+ [exec.id, exec.job_instance_id, exec.status, exec.exit_code, exec.exit_message, exec.params],
221
+ ) as JobExecutionRow[];
222
+ return mapJobExecution(rows[0]!);
223
+ }
224
+
225
+ async createExecutionAtomic(
226
+ name: string,
227
+ jobKey: string,
228
+ params: JobParameters,
229
+ ): Promise<JobExecution> {
230
+ return this.dataSource.transaction(async (em) => {
231
+ // 1. Idempotent INSERT.
232
+ const instId = randomUUID();
233
+ await em.query(
234
+ `INSERT INTO "batch_job_instance" ("id", "job_name", "job_key", "created_at")
235
+ VALUES ($1, $2, $3, NOW())
236
+ ON CONFLICT ("job_name", "job_key") DO NOTHING`,
237
+ [instId, name, jobKey],
238
+ );
239
+
240
+ // 2. Lock the instance row.
241
+ const isSqlite = this.dataSource.options.type === 'better-sqlite3';
242
+ let instanceId: string;
243
+ if (isSqlite) {
244
+ const rows = await em.query(
245
+ `SELECT "id" FROM "batch_job_instance"
246
+ WHERE "job_name" = $1 AND "job_key" = $2
247
+ LIMIT 1`,
248
+ [name, jobKey],
249
+ ) as Array<{ id: string }>;
250
+ if (rows.length === 0) {
251
+ throw new JobExecutionAlreadyRunningError(name);
252
+ }
253
+ instanceId = rows[0]!.id;
254
+ } else {
255
+ const rows = await em.query(
256
+ `SELECT "id" FROM "batch_job_instance"
257
+ WHERE "job_name" = $1 AND "job_key" = $2
258
+ FOR UPDATE SKIP LOCKED`,
259
+ [name, jobKey],
260
+ ) as Array<{ id: string }>;
261
+ if (rows.length === 0) {
262
+ throw new JobExecutionAlreadyRunningError(name);
263
+ }
264
+ instanceId = rows[0]!.id;
265
+ }
266
+
267
+ // 3. Under the lock, verify no running execution.
268
+ const running = await em.query(
269
+ `SELECT "id" FROM "batch_job_execution"
270
+ WHERE "job_instance_id" = $1 AND "status" IN ($2, $3)
271
+ LIMIT 1`,
272
+ [instanceId, JobStatus.STARTING, JobStatus.STARTED],
273
+ ) as Array<{ id: string }>;
274
+ if (running.length > 0) {
275
+ throw new JobExecutionAlreadyRunningError(running[0]!.id);
276
+ }
277
+
278
+ // 4. Create the new execution row.
279
+ const execId = randomUUID();
280
+ const inserted = await em.query(
281
+ `INSERT INTO "batch_job_execution" ("id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params")
282
+ VALUES ($1, $2, $3, NULL, NULL, '', '', $4)
283
+ RETURNING "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"`,
284
+ [execId, instanceId, JobStatus.STARTING, serializeContext(deepClone(params))],
285
+ ) as JobExecutionRow[];
286
+ return mapJobExecution(inserted[0]!);
287
+ });
288
+ }
289
+
290
+ async updateJobExecution(executionId: string, patch: JobExecutionPatch): Promise<void> {
291
+ const sets: string[] = [];
292
+ const values: unknown[] = [];
293
+ let i = 1;
294
+ if (patch.status !== undefined) { sets.push(`"status" = $${i++}`); values.push(patch.status); }
295
+ if (patch.startTime !== undefined) { sets.push(`"start_time" = $${i++}`); values.push(patch.startTime); }
296
+ if (patch.endTime !== undefined) { sets.push(`"end_time" = $${i++}`); values.push(patch.endTime); }
297
+ if (patch.exitCode !== undefined) { sets.push(`"exit_code" = $${i++}`); values.push(patch.exitCode); }
298
+ if (patch.exitMessage !== undefined) { sets.push(`"exit_message" = $${i++}`); values.push(patch.exitMessage); }
299
+ if (sets.length === 0) return;
300
+ values.push(executionId);
301
+ await this.em().query(
302
+ `UPDATE "batch_job_execution" SET ${sets.join(', ')} WHERE "id" = $${i}`,
303
+ values,
304
+ );
305
+ }
306
+
307
+ async getJobExecution(executionId: string): Promise<JobExecution | null> {
308
+ const rows = await this.em().query(
309
+ `SELECT "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"
310
+ FROM "batch_job_execution" WHERE "id" = $1 LIMIT 1`,
311
+ [executionId],
312
+ ) as JobExecutionRow[];
313
+ return rows.length > 0 ? mapJobExecution(rows[0]!) : null;
314
+ }
315
+
316
+ override async getJobInstance(jobInstanceId: string): Promise<JobInstance | null> {
317
+ const rows = await this.em().query(
318
+ `SELECT "id", "job_name", "job_key", "created_at"
319
+ FROM "batch_job_instance"
320
+ WHERE "id" = $1
321
+ LIMIT 1`,
322
+ [jobInstanceId],
323
+ ) as JobInstanceRow[];
324
+ return rows.length > 0 ? mapJobInstance(rows[0]!) : null;
325
+ }
326
+
327
+ override async findJobInstances(filter: JobInstanceFilter = {}): Promise<JobInstance[]> {
328
+ const where: string[] = [];
329
+ const values: unknown[] = [];
330
+ let i = 1;
331
+ if (filter.jobName !== undefined) {
332
+ where.push(`"job_name" = $${i++}`);
333
+ values.push(filter.jobName);
334
+ }
335
+ if (filter.jobKey !== undefined) {
336
+ where.push(`"job_key" = $${i++}`);
337
+ values.push(filter.jobKey);
338
+ }
339
+ const rows = await this.em().query(
340
+ `SELECT "id", "job_name", "job_key", "created_at"
341
+ FROM "batch_job_instance"
342
+ ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''}
343
+ ORDER BY "created_at" ASC, "id" ASC`,
344
+ values,
345
+ ) as JobInstanceRow[];
346
+ return rows.map(mapJobInstance);
347
+ }
348
+
349
+ override async findJobExecutions(filter: JobExecutionFilter = {}): Promise<JobExecution[]> {
350
+ const where: string[] = [];
351
+ const values: unknown[] = [];
352
+ let i = 1;
353
+ if (filter.jobInstanceId !== undefined) {
354
+ where.push(`"job_instance_id" = $${i++}`);
355
+ values.push(filter.jobInstanceId);
356
+ }
357
+ if (filter.status !== undefined) {
358
+ const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
359
+ const placeholders = statuses.map(() => `$${i++}`);
360
+ where.push(`"status" IN (${placeholders.join(', ')})`);
361
+ values.push(...statuses);
362
+ }
363
+ if (filter.startedAfter !== undefined) {
364
+ where.push(`"start_time" >= $${i++}`);
365
+ values.push(filter.startedAfter);
366
+ }
367
+ if (filter.startedBefore !== undefined) {
368
+ where.push(`"start_time" <= $${i++}`);
369
+ values.push(filter.startedBefore);
370
+ }
371
+ const rows = await this.em().query(
372
+ `SELECT "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"
373
+ FROM "batch_job_execution"
374
+ ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''}
375
+ ORDER BY "start_time" DESC NULLS LAST, "id" DESC`,
376
+ values,
377
+ ) as JobExecutionRow[];
378
+ return rows.map(mapJobExecution);
379
+ }
380
+
381
+ async getRunningJobExecution(jobInstanceId: string): Promise<JobExecution | null> {
382
+ if (!jobInstanceId) return null;
383
+ const rows = await this.em().query(
384
+ `SELECT "id", "job_instance_id", "status", "start_time", "end_time", "exit_code", "exit_message", "params"
385
+ FROM "batch_job_execution"
386
+ WHERE "job_instance_id" = $1 AND "status" IN ($2, $3)
387
+ ORDER BY "start_time" DESC NULLS LAST LIMIT 1`,
388
+ [jobInstanceId, JobStatus.STARTING, JobStatus.STARTED],
389
+ ) as JobExecutionRow[];
390
+ return rows.length > 0 ? mapJobExecution(rows[0]!) : null;
391
+ }
392
+
393
+ async createStepExecution(
394
+ jobExecutionId: string,
395
+ stepName: string,
396
+ ): Promise<StepExecution> {
397
+ const stepId = randomUUID();
398
+ const rows = await this.em().query(
399
+ `INSERT INTO "batch_step_execution" ("id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at")
400
+ VALUES ($1, $2, $3, $4, 0, 0, 0, 0, 0, '', '', NOW())
401
+ RETURNING "id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at"`,
402
+ [stepId, jobExecutionId, stepName, StepStatus.STARTING],
403
+ ) as StepExecutionRow[];
404
+ return mapStepExecution(rows[0]!);
405
+ }
406
+
407
+ async updateStepExecution(
408
+ stepExecutionId: string,
409
+ patch: StepExecutionPatch,
410
+ ): Promise<void> {
411
+ const sets: string[] = [];
412
+ const values: unknown[] = [];
413
+ let i = 1;
414
+ if (patch.status !== undefined) { sets.push(`"status" = $${i++}`); values.push(patch.status); }
415
+ if (patch.readCount !== undefined) { sets.push(`"read_count" = $${i++}`); values.push(patch.readCount); }
416
+ if (patch.writeCount !== undefined) { sets.push(`"write_count" = $${i++}`); values.push(patch.writeCount); }
417
+ if (patch.skipCount !== undefined) { sets.push(`"skip_count" = $${i++}`); values.push(patch.skipCount); }
418
+ if (patch.rollbackCount !== undefined) { sets.push(`"rollback_count" = $${i++}`); values.push(patch.rollbackCount); }
419
+ if (patch.commitCount !== undefined) { sets.push(`"commit_count" = $${i++}`); values.push(patch.commitCount); }
420
+ if (patch.exitCode !== undefined) { sets.push(`"exit_code" = $${i++}`); values.push(patch.exitCode); }
421
+ if (patch.exitMessage !== undefined) { sets.push(`"exit_message" = $${i++}`); values.push(patch.exitMessage); }
422
+ if (sets.length === 0) return;
423
+ values.push(stepExecutionId);
424
+ await this.em().query(
425
+ `UPDATE "batch_step_execution" SET ${sets.join(', ')} WHERE "id" = $${i}`,
426
+ values,
427
+ );
428
+ }
429
+
430
+ async getStepExecution(stepExecutionId: string): Promise<StepExecution | null> {
431
+ const rows = await this.em().query(
432
+ `SELECT "id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at"
433
+ FROM "batch_step_execution" WHERE "id" = $1 LIMIT 1`,
434
+ [stepExecutionId],
435
+ ) as StepExecutionRow[];
436
+ return rows.length > 0 ? mapStepExecution(rows[0]!) : null;
437
+ }
438
+
439
+ override async findStepExecutions(jobExecutionId: string): Promise<StepExecution[]> {
440
+ const rows = await this.em().query(
441
+ `SELECT "id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at"
442
+ FROM "batch_step_execution"
443
+ WHERE "job_execution_id" = $1
444
+ ORDER BY "created_at" ASC, "id" ASC`,
445
+ [jobExecutionId],
446
+ ) as StepExecutionRow[];
447
+ return rows.map(mapStepExecution);
448
+ }
449
+
450
+ /**
451
+ * Find the most recently created step execution for the given
452
+ * `(jobExecutionId, stepName)` pair, or `null` when none exists.
453
+ * Insertion order is determined by the `created_at` column; the
454
+ * primary key is a v4 UUID which is random, so a `id DESC` order
455
+ * would not correspond to insertion time. The `created_at DESC,
456
+ * id DESC` secondary order keeps the result stable when two rows
457
+ * share the same `CURRENT_TIMESTAMP` resolution.
458
+ */
459
+ async findLatestStepExecution(
460
+ jobExecutionId: string,
461
+ stepName: string,
462
+ ): Promise<StepExecution | null> {
463
+ const rows = await this.em().query(
464
+ `SELECT "id", "job_execution_id", "step_name", "status", "read_count", "write_count", "skip_count", "rollback_count", "commit_count", "exit_code", "exit_message", "created_at"
465
+ FROM "batch_step_execution"
466
+ WHERE "job_execution_id" = $1 AND "step_name" = $2
467
+ ORDER BY "created_at" DESC, "id" DESC
468
+ LIMIT 1`,
469
+ [jobExecutionId, stepName],
470
+ ) as StepExecutionRow[];
471
+ return rows.length > 0 ? mapStepExecution(rows[0]!) : null;
472
+ }
473
+
474
+ async getExecutionContext(scope: ExecutionScope): Promise<ExecutionContext> {
475
+ const key = scopeKey(scope);
476
+ if (key.startsWith('job::')) {
477
+ const rows = await this.em().query(
478
+ `SELECT "data", "version" FROM "batch_job_execution_context" WHERE "job_execution_id" = $1 LIMIT 1`,
479
+ [key.slice(5)],
480
+ ) as ContextRow[];
481
+ if (rows.length > 0) {
482
+ return {
483
+ data: rows[0]!.data.length > 0 ? deserializeContext(rows[0]!.data) : null,
484
+ version: rows[0]!.version,
485
+ };
486
+ }
487
+ } else {
488
+ const rows = await this.em().query(
489
+ `SELECT "data", "version" FROM "batch_step_execution_context" WHERE "step_execution_id" = $1 LIMIT 1`,
490
+ [key.slice(6)],
491
+ ) as ContextRow[];
492
+ if (rows.length > 0) {
493
+ return {
494
+ data: rows[0]!.data.length > 0 ? deserializeContext(rows[0]!.data) : null,
495
+ version: rows[0]!.version,
496
+ };
497
+ }
498
+ }
499
+ return { data: null, version: 0 };
500
+ }
501
+
502
+ async saveExecutionContext(
503
+ scope: ExecutionScope,
504
+ ctx: ExecutionContext,
505
+ version?: number,
506
+ ): Promise<void> {
507
+ assertJsonSerializable(ctx.data);
508
+ const key = scopeKey(scope);
509
+ const serialized = serializeContext(deepClone(ctx.data));
510
+ if (key.startsWith('job::')) {
511
+ const jobExecutionId = key.slice(5);
512
+ const existing = await this.em().query(
513
+ `SELECT "version" FROM "batch_job_execution_context" WHERE "job_execution_id" = $1 LIMIT 1`,
514
+ [jobExecutionId],
515
+ ) as ContextRow[];
516
+ const nextVersion = version !== undefined ? version : (existing.length > 0 ? existing[0]!.version + 1 : 0);
517
+ if (existing.length > 0) {
518
+ await this.em().query(
519
+ `UPDATE "batch_job_execution_context" SET "data" = $1, "version" = $2 WHERE "job_execution_id" = $3`,
520
+ [serialized, nextVersion, jobExecutionId],
521
+ );
522
+ } else {
523
+ await this.em().query(
524
+ `INSERT INTO "batch_job_execution_context" ("job_execution_id", "data", "version") VALUES ($1, $2, $3)`,
525
+ [jobExecutionId, serialized, nextVersion],
526
+ );
527
+ }
528
+ } else {
529
+ const stepExecutionId = key.slice(6);
530
+ const existing = await this.em().query(
531
+ `SELECT "version" FROM "batch_step_execution_context" WHERE "step_execution_id" = $1 LIMIT 1`,
532
+ [stepExecutionId],
533
+ ) as ContextRow[];
534
+ const nextVersion = version !== undefined ? version : (existing.length > 0 ? existing[0]!.version + 1 : 0);
535
+ if (existing.length > 0) {
536
+ await this.em().query(
537
+ `UPDATE "batch_step_execution_context" SET "data" = $1, "version" = $2 WHERE "step_execution_id" = $3`,
538
+ [serialized, nextVersion, stepExecutionId],
539
+ );
540
+ } else {
541
+ await this.em().query(
542
+ `INSERT INTO "batch_step_execution_context" ("step_execution_id", "data", "version") VALUES ($1, $2, $3)`,
543
+ [stepExecutionId, serialized, nextVersion],
544
+ );
545
+ }
546
+ }
547
+ }
548
+ }
@@ -0,0 +1,47 @@
1
+ import { Inject, Injectable } from '@nestjs/common';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { DataSource, EntityManager } from 'typeorm';
4
+ import {
5
+ TransactionManager,
6
+ type TransactionContext,
7
+ } from '@nest-batch/core';
8
+ import { TypeOrmDriverProvider } from '../typeorm.driver-provider';
9
+
10
+ export interface TypeOrmTransactionContext extends TransactionContext {
11
+ readonly isActive: true;
12
+ readonly id: string;
13
+ readonly entityManager: EntityManager;
14
+ }
15
+
16
+ /**
17
+ * TransactionManager bound to TypeORM 1.0.0's `DataSource.transaction()`.
18
+ *
19
+ * Wraps the user callback in a real DB transaction. On success the
20
+ * transaction commits; if `fn(ctx)` throws, the transaction rolls back.
21
+ *
22
+ * The transactional EM is the one passed to the callback —
23
+ * consumers should use that `entityManager` (not any
24
+ * globally-injected one) so that all reads and writes are part of
25
+ * the same transaction.
26
+ */
27
+ @Injectable()
28
+ export class TypeOrmTransactionManager extends TransactionManager {
29
+ constructor(
30
+ @Inject(TypeOrmDriverProvider) private readonly dataSource: DataSource,
31
+ ) {
32
+ super();
33
+ }
34
+
35
+ async withTransaction<T>(
36
+ fn: (ctx: TypeOrmTransactionContext) => Promise<T>,
37
+ ): Promise<T> {
38
+ return this.dataSource.transaction(async (txEm) => {
39
+ const ctx: TypeOrmTransactionContext = {
40
+ isActive: true,
41
+ id: randomUUID(),
42
+ entityManager: txEm,
43
+ };
44
+ return fn(ctx);
45
+ });
46
+ }
47
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `TypeOrmDriverProvider` — the abstract injection token the
3
+ * `@nest-batch/postgresql` (and future `@nest-batch/mysql`) driver
4
+ * sibling packages bind to the concrete `DataSource` for the chosen
5
+ * database.
6
+ *
7
+ * This package (`@nest-batch/typeorm`) is **driver-agnostic**: it
8
+ * does not import `@nestjs/typeorm` (which carries the Postgres
9
+ * driver) or any MySQL-specific `@nestjs/typeorm` companion. Instead,
10
+ * it exports the `TypeOrmDriverProvider` symbol as a `Provider`
11
+ * token; the driver sibling package binds the token to a concrete
12
+ * `DataSource` in its own `forRoot()` factory.
13
+ *
14
+ * The `TypeOrmJobRepository` / `TypeOrmTransactionManager` classes
15
+ * inject the token via the standard `@Inject(TypeOrmDriverProvider)`
16
+ * decorator and cast the resolved value to the host-owned
17
+ * `DataSource` shape. This mirrors the
18
+ * `@nestjs/typeorm` pattern of "host owns the connection, adapter
19
+ * owns the repository".
20
+ */
21
+ export const TypeOrmDriverProvider: symbol = Symbol.for(
22
+ '@nest-batch/typeorm/TypeOrmDriverProvider',
23
+ );