@bulkimport/core 0.1.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.
- package/LICENSE +21 -0
- package/README.md +373 -0
- package/dist/index.cjs +819 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +335 -0
- package/dist/index.d.ts +335 -0
- package/dist/index.js +777 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
// src/domain/model/ImportStatus.ts
|
|
2
|
+
var ImportStatus = {
|
|
3
|
+
CREATED: "CREATED",
|
|
4
|
+
PREVIEWING: "PREVIEWING",
|
|
5
|
+
PREVIEWED: "PREVIEWED",
|
|
6
|
+
PROCESSING: "PROCESSING",
|
|
7
|
+
PAUSED: "PAUSED",
|
|
8
|
+
COMPLETED: "COMPLETED",
|
|
9
|
+
ABORTED: "ABORTED",
|
|
10
|
+
FAILED: "FAILED"
|
|
11
|
+
};
|
|
12
|
+
var VALID_TRANSITIONS = {
|
|
13
|
+
[ImportStatus.CREATED]: [ImportStatus.PREVIEWING, ImportStatus.PROCESSING],
|
|
14
|
+
[ImportStatus.PREVIEWING]: [ImportStatus.PREVIEWED, ImportStatus.FAILED],
|
|
15
|
+
[ImportStatus.PREVIEWED]: [ImportStatus.PROCESSING],
|
|
16
|
+
[ImportStatus.PROCESSING]: [ImportStatus.PAUSED, ImportStatus.COMPLETED, ImportStatus.ABORTED, ImportStatus.FAILED],
|
|
17
|
+
[ImportStatus.PAUSED]: [ImportStatus.PROCESSING, ImportStatus.ABORTED],
|
|
18
|
+
[ImportStatus.COMPLETED]: [],
|
|
19
|
+
[ImportStatus.ABORTED]: [],
|
|
20
|
+
[ImportStatus.FAILED]: []
|
|
21
|
+
};
|
|
22
|
+
function canTransition(from, to) {
|
|
23
|
+
return VALID_TRANSITIONS[from].includes(to);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/domain/model/Batch.ts
|
|
27
|
+
function createBatch(id, index, records) {
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
index,
|
|
31
|
+
status: "PENDING",
|
|
32
|
+
records,
|
|
33
|
+
processedCount: 0,
|
|
34
|
+
failedCount: 0
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/domain/model/Record.ts
|
|
39
|
+
function createPendingRecord(index, raw) {
|
|
40
|
+
return {
|
|
41
|
+
index,
|
|
42
|
+
raw,
|
|
43
|
+
parsed: raw,
|
|
44
|
+
status: "pending",
|
|
45
|
+
errors: []
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function markRecordValid(record, parsed) {
|
|
49
|
+
return { ...record, parsed, status: "valid", errors: [] };
|
|
50
|
+
}
|
|
51
|
+
function markRecordInvalid(record, errors) {
|
|
52
|
+
return { ...record, status: "invalid", errors };
|
|
53
|
+
}
|
|
54
|
+
function markRecordProcessed(record) {
|
|
55
|
+
return { ...record, status: "processed" };
|
|
56
|
+
}
|
|
57
|
+
function markRecordFailed(record, error) {
|
|
58
|
+
return { ...record, status: "failed", processingError: error };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/domain/model/ValidationResult.ts
|
|
62
|
+
function validResult() {
|
|
63
|
+
return { isValid: true, errors: [] };
|
|
64
|
+
}
|
|
65
|
+
function invalidResult(errors) {
|
|
66
|
+
return { isValid: false, errors };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/domain/services/SchemaValidator.ts
|
|
70
|
+
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
71
|
+
var SchemaValidator = class {
|
|
72
|
+
constructor(schema) {
|
|
73
|
+
this.schema = schema;
|
|
74
|
+
}
|
|
75
|
+
validate(record) {
|
|
76
|
+
const errors = [];
|
|
77
|
+
for (const field of this.schema.fields) {
|
|
78
|
+
const value = record[field.name];
|
|
79
|
+
const fieldErrors = this.validateField(field, value);
|
|
80
|
+
errors.push(...fieldErrors);
|
|
81
|
+
}
|
|
82
|
+
if (this.schema.strict) {
|
|
83
|
+
const definedFields = new Set(this.schema.fields.map((f) => f.name));
|
|
84
|
+
for (const key of Object.keys(record)) {
|
|
85
|
+
if (!definedFields.has(key)) {
|
|
86
|
+
errors.push({
|
|
87
|
+
field: key,
|
|
88
|
+
message: `Unknown field '${key}' is not allowed in strict mode`,
|
|
89
|
+
code: "UNKNOWN_FIELD",
|
|
90
|
+
value: record[key]
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return errors.length === 0 ? validResult() : invalidResult(errors);
|
|
96
|
+
}
|
|
97
|
+
applyTransforms(record) {
|
|
98
|
+
const transformed = { ...record };
|
|
99
|
+
for (const field of this.schema.fields) {
|
|
100
|
+
if (field.transform && transformed[field.name] !== void 0) {
|
|
101
|
+
transformed[field.name] = field.transform(transformed[field.name]);
|
|
102
|
+
}
|
|
103
|
+
if (transformed[field.name] === void 0 && field.defaultValue !== void 0) {
|
|
104
|
+
transformed[field.name] = field.defaultValue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return transformed;
|
|
108
|
+
}
|
|
109
|
+
validateField(field, value) {
|
|
110
|
+
const errors = [];
|
|
111
|
+
if (this.isEmpty(value)) {
|
|
112
|
+
if (field.required) {
|
|
113
|
+
errors.push({
|
|
114
|
+
field: field.name,
|
|
115
|
+
message: `Field '${field.name}' is required`,
|
|
116
|
+
code: "REQUIRED",
|
|
117
|
+
value
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return errors;
|
|
121
|
+
}
|
|
122
|
+
if (field.type !== "custom") {
|
|
123
|
+
const typeError = this.validateType(field, value);
|
|
124
|
+
if (typeError) {
|
|
125
|
+
errors.push(typeError);
|
|
126
|
+
return errors;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (field.pattern) {
|
|
130
|
+
const stringValue = String(value);
|
|
131
|
+
if (!field.pattern.test(stringValue)) {
|
|
132
|
+
errors.push({
|
|
133
|
+
field: field.name,
|
|
134
|
+
message: `Field '${field.name}' does not match pattern ${String(field.pattern)}`,
|
|
135
|
+
code: "PATTERN_MISMATCH",
|
|
136
|
+
value
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (field.customValidator) {
|
|
141
|
+
const result = field.customValidator(value);
|
|
142
|
+
if (!result.valid) {
|
|
143
|
+
errors.push({
|
|
144
|
+
field: field.name,
|
|
145
|
+
message: result.message ?? `Custom validation failed for field '${field.name}'`,
|
|
146
|
+
code: "CUSTOM_VALIDATION",
|
|
147
|
+
value
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return errors;
|
|
152
|
+
}
|
|
153
|
+
validateType(field, value) {
|
|
154
|
+
const stringValue = String(value);
|
|
155
|
+
switch (field.type) {
|
|
156
|
+
case "number": {
|
|
157
|
+
const num = Number(stringValue);
|
|
158
|
+
if (isNaN(num)) {
|
|
159
|
+
return {
|
|
160
|
+
field: field.name,
|
|
161
|
+
message: `Field '${field.name}' must be a number`,
|
|
162
|
+
code: "TYPE_MISMATCH",
|
|
163
|
+
value
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
case "boolean": {
|
|
169
|
+
const lower = stringValue.toLowerCase();
|
|
170
|
+
if (!["true", "false", "1", "0", "yes", "no"].includes(lower)) {
|
|
171
|
+
return {
|
|
172
|
+
field: field.name,
|
|
173
|
+
message: `Field '${field.name}' must be a boolean`,
|
|
174
|
+
code: "TYPE_MISMATCH",
|
|
175
|
+
value
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
case "date": {
|
|
181
|
+
const date = new Date(stringValue);
|
|
182
|
+
if (isNaN(date.getTime())) {
|
|
183
|
+
return {
|
|
184
|
+
field: field.name,
|
|
185
|
+
message: `Field '${field.name}' must be a valid date`,
|
|
186
|
+
code: "TYPE_MISMATCH",
|
|
187
|
+
value
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
case "email": {
|
|
193
|
+
if (!EMAIL_PATTERN.test(stringValue)) {
|
|
194
|
+
return {
|
|
195
|
+
field: field.name,
|
|
196
|
+
message: `Field '${field.name}' must be a valid email`,
|
|
197
|
+
code: "TYPE_MISMATCH",
|
|
198
|
+
value
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
case "string":
|
|
204
|
+
return null;
|
|
205
|
+
default:
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
isEmpty(value) {
|
|
210
|
+
return value === void 0 || value === null || value === "";
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// src/application/EventBus.ts
|
|
215
|
+
var EventBus = class {
|
|
216
|
+
constructor() {
|
|
217
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
218
|
+
}
|
|
219
|
+
on(type, handler) {
|
|
220
|
+
const existing = this.handlers.get(type) ?? /* @__PURE__ */ new Set();
|
|
221
|
+
existing.add(handler);
|
|
222
|
+
this.handlers.set(type, existing);
|
|
223
|
+
}
|
|
224
|
+
off(type, handler) {
|
|
225
|
+
const existing = this.handlers.get(type);
|
|
226
|
+
if (existing) {
|
|
227
|
+
existing.delete(handler);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
emit(event) {
|
|
231
|
+
const handlers = this.handlers.get(event.type);
|
|
232
|
+
if (handlers) {
|
|
233
|
+
for (const handler of handlers) {
|
|
234
|
+
handler(event);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// src/infrastructure/state/InMemoryStateStore.ts
|
|
241
|
+
var InMemoryStateStore = class {
|
|
242
|
+
constructor() {
|
|
243
|
+
this.jobs = /* @__PURE__ */ new Map();
|
|
244
|
+
this.records = /* @__PURE__ */ new Map();
|
|
245
|
+
}
|
|
246
|
+
saveJobState(job) {
|
|
247
|
+
this.jobs.set(job.id, job);
|
|
248
|
+
return Promise.resolve();
|
|
249
|
+
}
|
|
250
|
+
getJobState(jobId) {
|
|
251
|
+
return Promise.resolve(this.jobs.get(jobId) ?? null);
|
|
252
|
+
}
|
|
253
|
+
updateBatchState(jobId, batchId, state) {
|
|
254
|
+
const job = this.jobs.get(jobId);
|
|
255
|
+
if (!job) return Promise.resolve();
|
|
256
|
+
const batches = job.batches.map(
|
|
257
|
+
(b) => b.id === batchId ? { ...b, status: state.status, processedCount: state.processedCount, failedCount: state.failedCount } : b
|
|
258
|
+
);
|
|
259
|
+
this.jobs.set(jobId, { ...job, batches });
|
|
260
|
+
return Promise.resolve();
|
|
261
|
+
}
|
|
262
|
+
saveProcessedRecord(jobId, _batchId, record) {
|
|
263
|
+
const key = jobId;
|
|
264
|
+
const existing = this.records.get(key) ?? [];
|
|
265
|
+
const index = existing.findIndex((r) => r.index === record.index);
|
|
266
|
+
if (index >= 0) {
|
|
267
|
+
existing[index] = record;
|
|
268
|
+
} else {
|
|
269
|
+
existing.push(record);
|
|
270
|
+
}
|
|
271
|
+
this.records.set(key, existing);
|
|
272
|
+
return Promise.resolve();
|
|
273
|
+
}
|
|
274
|
+
getFailedRecords(jobId) {
|
|
275
|
+
const all = this.records.get(jobId) ?? [];
|
|
276
|
+
return Promise.resolve(all.filter((r) => r.status === "failed" || r.status === "invalid"));
|
|
277
|
+
}
|
|
278
|
+
getPendingRecords(jobId) {
|
|
279
|
+
const all = this.records.get(jobId) ?? [];
|
|
280
|
+
return Promise.resolve(all.filter((r) => r.status === "pending" || r.status === "valid"));
|
|
281
|
+
}
|
|
282
|
+
getProcessedRecords(jobId) {
|
|
283
|
+
const all = this.records.get(jobId) ?? [];
|
|
284
|
+
return Promise.resolve(all.filter((r) => r.status === "processed"));
|
|
285
|
+
}
|
|
286
|
+
getProgress(jobId) {
|
|
287
|
+
const job = this.jobs.get(jobId);
|
|
288
|
+
const all = this.records.get(jobId) ?? [];
|
|
289
|
+
const processed = all.filter((r) => r.status === "processed").length;
|
|
290
|
+
const failed = all.filter((r) => r.status === "failed" || r.status === "invalid").length;
|
|
291
|
+
const total = job?.totalRecords ?? all.length;
|
|
292
|
+
const pending = total - processed - failed;
|
|
293
|
+
const elapsed = job?.startedAt ? Date.now() - job.startedAt : 0;
|
|
294
|
+
const currentBatch = job?.batches.filter((b) => b.status === "COMPLETED").length ?? 0;
|
|
295
|
+
const totalBatches = job?.batches.length ?? 0;
|
|
296
|
+
return Promise.resolve({
|
|
297
|
+
totalRecords: total,
|
|
298
|
+
processedRecords: processed,
|
|
299
|
+
failedRecords: failed,
|
|
300
|
+
pendingRecords: pending,
|
|
301
|
+
percentage: total > 0 ? Math.round(processed / total * 100) : 0,
|
|
302
|
+
currentBatch,
|
|
303
|
+
totalBatches,
|
|
304
|
+
elapsedMs: elapsed
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/BulkImport.ts
|
|
310
|
+
var BulkImport = class {
|
|
311
|
+
constructor(config) {
|
|
312
|
+
this.source = null;
|
|
313
|
+
this.parser = null;
|
|
314
|
+
this.status = "CREATED";
|
|
315
|
+
this.batches = [];
|
|
316
|
+
this.allRecords = [];
|
|
317
|
+
this.totalRecords = 0;
|
|
318
|
+
this.abortController = null;
|
|
319
|
+
this.pausePromise = null;
|
|
320
|
+
this.config = {
|
|
321
|
+
...config,
|
|
322
|
+
batchSize: config.batchSize ?? 100,
|
|
323
|
+
continueOnError: config.continueOnError ?? false
|
|
324
|
+
};
|
|
325
|
+
this.validator = new SchemaValidator(config.schema);
|
|
326
|
+
this.eventBus = new EventBus();
|
|
327
|
+
this.stateStore = config.stateStore ?? new InMemoryStateStore();
|
|
328
|
+
this.jobId = crypto.randomUUID();
|
|
329
|
+
}
|
|
330
|
+
from(source, parser) {
|
|
331
|
+
this.source = source;
|
|
332
|
+
this.parser = parser;
|
|
333
|
+
return this;
|
|
334
|
+
}
|
|
335
|
+
on(type, handler) {
|
|
336
|
+
this.eventBus.on(type, handler);
|
|
337
|
+
return this;
|
|
338
|
+
}
|
|
339
|
+
async preview(maxRecords = 10) {
|
|
340
|
+
this.assertSourceConfigured();
|
|
341
|
+
this.transitionTo("PREVIEWING");
|
|
342
|
+
const records = await this.parseRecords(maxRecords);
|
|
343
|
+
const validRecords = [];
|
|
344
|
+
const invalidRecords = [];
|
|
345
|
+
const columns = /* @__PURE__ */ new Set();
|
|
346
|
+
for (const record of records) {
|
|
347
|
+
for (const key of Object.keys(record.raw)) {
|
|
348
|
+
columns.add(key);
|
|
349
|
+
}
|
|
350
|
+
const transformed = this.validator.applyTransforms(record.raw);
|
|
351
|
+
const result = this.validator.validate(transformed);
|
|
352
|
+
if (result.isValid) {
|
|
353
|
+
validRecords.push(markRecordValid(record, transformed));
|
|
354
|
+
} else {
|
|
355
|
+
invalidRecords.push(markRecordInvalid(record, result.errors));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
this.transitionTo("PREVIEWED");
|
|
359
|
+
return {
|
|
360
|
+
validRecords,
|
|
361
|
+
invalidRecords,
|
|
362
|
+
totalSampled: records.length,
|
|
363
|
+
columns: [...columns]
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
async start(processor) {
|
|
367
|
+
this.assertSourceConfigured();
|
|
368
|
+
this.assertCanStart();
|
|
369
|
+
this.transitionTo("PROCESSING");
|
|
370
|
+
this.abortController = new AbortController();
|
|
371
|
+
this.startedAt = Date.now();
|
|
372
|
+
const allRawRecords = await this.parseRecords();
|
|
373
|
+
this.totalRecords = allRawRecords.length;
|
|
374
|
+
this.batches = this.splitIntoBatches(allRawRecords);
|
|
375
|
+
await this.saveState();
|
|
376
|
+
this.eventBus.emit({
|
|
377
|
+
type: "import:started",
|
|
378
|
+
jobId: this.jobId,
|
|
379
|
+
totalRecords: this.totalRecords,
|
|
380
|
+
totalBatches: this.batches.length,
|
|
381
|
+
timestamp: Date.now()
|
|
382
|
+
});
|
|
383
|
+
try {
|
|
384
|
+
for (let i = 0; i < this.batches.length; i++) {
|
|
385
|
+
if (this.abortController.signal.aborted) break;
|
|
386
|
+
await this.checkPause();
|
|
387
|
+
const batch = this.batches[i];
|
|
388
|
+
if (!batch) break;
|
|
389
|
+
await this.processBatch(batch, processor);
|
|
390
|
+
}
|
|
391
|
+
if (!this.abortController.signal.aborted && this.status !== "ABORTED") {
|
|
392
|
+
this.transitionTo("COMPLETED");
|
|
393
|
+
const summary = this.buildSummary();
|
|
394
|
+
this.eventBus.emit({
|
|
395
|
+
type: "import:completed",
|
|
396
|
+
jobId: this.jobId,
|
|
397
|
+
summary,
|
|
398
|
+
timestamp: Date.now()
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
} catch (error) {
|
|
402
|
+
if (this.status !== "ABORTED") {
|
|
403
|
+
this.transitionTo("FAILED");
|
|
404
|
+
this.eventBus.emit({
|
|
405
|
+
type: "import:failed",
|
|
406
|
+
jobId: this.jobId,
|
|
407
|
+
error: error instanceof Error ? error.message : String(error),
|
|
408
|
+
timestamp: Date.now()
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
await this.saveState();
|
|
413
|
+
}
|
|
414
|
+
async pause() {
|
|
415
|
+
if (this.status !== "PROCESSING") {
|
|
416
|
+
throw new Error(`Cannot pause import from status '${this.status}'`);
|
|
417
|
+
}
|
|
418
|
+
this.transitionTo("PAUSED");
|
|
419
|
+
this.pausePromise = this.createPausePromise();
|
|
420
|
+
const progress = this.buildProgress();
|
|
421
|
+
this.eventBus.emit({
|
|
422
|
+
type: "import:paused",
|
|
423
|
+
jobId: this.jobId,
|
|
424
|
+
progress,
|
|
425
|
+
timestamp: Date.now()
|
|
426
|
+
});
|
|
427
|
+
await this.saveState();
|
|
428
|
+
}
|
|
429
|
+
resume() {
|
|
430
|
+
if (this.status === "ABORTED") {
|
|
431
|
+
throw new Error("Cannot resume an aborted import");
|
|
432
|
+
}
|
|
433
|
+
if (this.status !== "PAUSED") {
|
|
434
|
+
throw new Error(`Cannot resume import from status '${this.status}'`);
|
|
435
|
+
}
|
|
436
|
+
this.transitionTo("PROCESSING");
|
|
437
|
+
if (this.pausePromise) {
|
|
438
|
+
this.pausePromise.resolve();
|
|
439
|
+
this.pausePromise = null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async abort() {
|
|
443
|
+
if (this.status !== "PROCESSING" && this.status !== "PAUSED") {
|
|
444
|
+
throw new Error(`Cannot abort import from status '${this.status}'`);
|
|
445
|
+
}
|
|
446
|
+
this.transitionTo("ABORTED");
|
|
447
|
+
this.abortController?.abort();
|
|
448
|
+
if (this.pausePromise) {
|
|
449
|
+
this.pausePromise.resolve();
|
|
450
|
+
this.pausePromise = null;
|
|
451
|
+
}
|
|
452
|
+
const progress = this.buildProgress();
|
|
453
|
+
this.eventBus.emit({
|
|
454
|
+
type: "import:aborted",
|
|
455
|
+
jobId: this.jobId,
|
|
456
|
+
progress,
|
|
457
|
+
timestamp: Date.now()
|
|
458
|
+
});
|
|
459
|
+
await this.saveState();
|
|
460
|
+
}
|
|
461
|
+
getStatus() {
|
|
462
|
+
return {
|
|
463
|
+
state: this.status,
|
|
464
|
+
progress: this.buildProgress(),
|
|
465
|
+
batches: this.batches
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
getFailedRecords() {
|
|
469
|
+
return this.allRecords.filter((r) => r.status === "failed" || r.status === "invalid");
|
|
470
|
+
}
|
|
471
|
+
getPendingRecords() {
|
|
472
|
+
return this.allRecords.filter((r) => r.status === "pending" || r.status === "valid");
|
|
473
|
+
}
|
|
474
|
+
getJobId() {
|
|
475
|
+
return this.jobId;
|
|
476
|
+
}
|
|
477
|
+
// --- Private methods ---
|
|
478
|
+
async processBatch(batch, processor) {
|
|
479
|
+
const batchIndex = this.batches.indexOf(batch);
|
|
480
|
+
this.updateBatchStatus(batch.id, "PROCESSING");
|
|
481
|
+
this.eventBus.emit({
|
|
482
|
+
type: "batch:started",
|
|
483
|
+
jobId: this.jobId,
|
|
484
|
+
batchId: batch.id,
|
|
485
|
+
batchIndex,
|
|
486
|
+
recordCount: batch.records.length,
|
|
487
|
+
timestamp: Date.now()
|
|
488
|
+
});
|
|
489
|
+
let processedCount = 0;
|
|
490
|
+
let failedCount = 0;
|
|
491
|
+
for (const record of batch.records) {
|
|
492
|
+
if (this.abortController?.signal.aborted) break;
|
|
493
|
+
await this.checkPause();
|
|
494
|
+
const transformed = this.validator.applyTransforms(record.raw);
|
|
495
|
+
const validation = this.validator.validate(transformed);
|
|
496
|
+
if (!validation.isValid) {
|
|
497
|
+
const invalidRecord = markRecordInvalid(record, validation.errors);
|
|
498
|
+
this.updateRecord(record.index, invalidRecord);
|
|
499
|
+
failedCount++;
|
|
500
|
+
this.eventBus.emit({
|
|
501
|
+
type: "record:failed",
|
|
502
|
+
jobId: this.jobId,
|
|
503
|
+
batchId: batch.id,
|
|
504
|
+
recordIndex: record.index,
|
|
505
|
+
error: validation.errors.map((e) => e.message).join("; "),
|
|
506
|
+
record: invalidRecord,
|
|
507
|
+
timestamp: Date.now()
|
|
508
|
+
});
|
|
509
|
+
if (!this.config.continueOnError) {
|
|
510
|
+
throw new Error(`Validation failed for record ${String(record.index)}`);
|
|
511
|
+
}
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const validRecord = markRecordValid(record, transformed);
|
|
515
|
+
try {
|
|
516
|
+
const context = {
|
|
517
|
+
jobId: this.jobId,
|
|
518
|
+
batchId: batch.id,
|
|
519
|
+
batchIndex,
|
|
520
|
+
recordIndex: record.index,
|
|
521
|
+
totalRecords: this.totalRecords,
|
|
522
|
+
signal: this.abortController?.signal ?? new AbortController().signal
|
|
523
|
+
};
|
|
524
|
+
await processor(validRecord.parsed, context);
|
|
525
|
+
const processed = markRecordProcessed(validRecord);
|
|
526
|
+
this.updateRecord(record.index, processed);
|
|
527
|
+
processedCount++;
|
|
528
|
+
this.eventBus.emit({
|
|
529
|
+
type: "record:processed",
|
|
530
|
+
jobId: this.jobId,
|
|
531
|
+
batchId: batch.id,
|
|
532
|
+
recordIndex: record.index,
|
|
533
|
+
timestamp: Date.now()
|
|
534
|
+
});
|
|
535
|
+
} catch (error) {
|
|
536
|
+
const failedRecord = markRecordFailed(validRecord, error instanceof Error ? error.message : String(error));
|
|
537
|
+
this.updateRecord(record.index, failedRecord);
|
|
538
|
+
failedCount++;
|
|
539
|
+
this.eventBus.emit({
|
|
540
|
+
type: "record:failed",
|
|
541
|
+
jobId: this.jobId,
|
|
542
|
+
batchId: batch.id,
|
|
543
|
+
recordIndex: record.index,
|
|
544
|
+
error: error instanceof Error ? error.message : String(error),
|
|
545
|
+
record: failedRecord,
|
|
546
|
+
timestamp: Date.now()
|
|
547
|
+
});
|
|
548
|
+
if (!this.config.continueOnError) {
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
this.updateBatchStatus(batch.id, "COMPLETED", processedCount, failedCount);
|
|
554
|
+
this.eventBus.emit({
|
|
555
|
+
type: "batch:completed",
|
|
556
|
+
jobId: this.jobId,
|
|
557
|
+
batchId: batch.id,
|
|
558
|
+
batchIndex,
|
|
559
|
+
processedCount,
|
|
560
|
+
failedCount,
|
|
561
|
+
totalCount: batch.records.length,
|
|
562
|
+
timestamp: Date.now()
|
|
563
|
+
});
|
|
564
|
+
this.emitProgress();
|
|
565
|
+
}
|
|
566
|
+
async parseRecords(maxRecords) {
|
|
567
|
+
const source = this.source;
|
|
568
|
+
const parser = this.parser;
|
|
569
|
+
if (!source || !parser) {
|
|
570
|
+
throw new Error("Source and parser must be configured. Call .from(source, parser) first.");
|
|
571
|
+
}
|
|
572
|
+
const records = [];
|
|
573
|
+
let index = 0;
|
|
574
|
+
for await (const chunk of source.read()) {
|
|
575
|
+
for await (const raw of parser.parse(chunk)) {
|
|
576
|
+
if (maxRecords !== void 0 && records.length >= maxRecords) {
|
|
577
|
+
return records;
|
|
578
|
+
}
|
|
579
|
+
records.push(createPendingRecord(index, raw));
|
|
580
|
+
index++;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return records;
|
|
584
|
+
}
|
|
585
|
+
splitIntoBatches(records) {
|
|
586
|
+
const batches = [];
|
|
587
|
+
const { batchSize } = this.config;
|
|
588
|
+
for (let i = 0; i < records.length; i += batchSize) {
|
|
589
|
+
const batchRecords = records.slice(i, i + batchSize);
|
|
590
|
+
const batchId = crypto.randomUUID();
|
|
591
|
+
batches.push(createBatch(batchId, batches.length, batchRecords));
|
|
592
|
+
}
|
|
593
|
+
this.allRecords = [...records];
|
|
594
|
+
return batches;
|
|
595
|
+
}
|
|
596
|
+
updateRecord(index, record) {
|
|
597
|
+
const pos = this.allRecords.findIndex((r) => r.index === index);
|
|
598
|
+
if (pos >= 0) {
|
|
599
|
+
this.allRecords[pos] = record;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
updateBatchStatus(batchId, status, processedCount, failedCount) {
|
|
603
|
+
this.batches = this.batches.map(
|
|
604
|
+
(b) => b.id === batchId ? {
|
|
605
|
+
...b,
|
|
606
|
+
status,
|
|
607
|
+
processedCount: processedCount ?? b.processedCount,
|
|
608
|
+
failedCount: failedCount ?? b.failedCount
|
|
609
|
+
} : b
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
transitionTo(newStatus) {
|
|
613
|
+
if (!canTransition(this.status, newStatus)) {
|
|
614
|
+
throw new Error(`Invalid state transition: ${this.status} \u2192 ${newStatus}`);
|
|
615
|
+
}
|
|
616
|
+
this.status = newStatus;
|
|
617
|
+
}
|
|
618
|
+
buildProgress() {
|
|
619
|
+
const processed = this.allRecords.filter((r) => r.status === "processed").length;
|
|
620
|
+
const failed = this.allRecords.filter((r) => r.status === "failed" || r.status === "invalid").length;
|
|
621
|
+
const pending = this.totalRecords - processed - failed;
|
|
622
|
+
const completedBatches = this.batches.filter((b) => b.status === "COMPLETED").length;
|
|
623
|
+
const elapsed = this.startedAt ? Date.now() - this.startedAt : 0;
|
|
624
|
+
return {
|
|
625
|
+
totalRecords: this.totalRecords,
|
|
626
|
+
processedRecords: processed,
|
|
627
|
+
failedRecords: failed,
|
|
628
|
+
pendingRecords: pending,
|
|
629
|
+
percentage: this.totalRecords > 0 ? Math.round(processed / this.totalRecords * 100) : 0,
|
|
630
|
+
currentBatch: completedBatches,
|
|
631
|
+
totalBatches: this.batches.length,
|
|
632
|
+
elapsedMs: elapsed
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
buildSummary() {
|
|
636
|
+
const processed = this.allRecords.filter((r) => r.status === "processed").length;
|
|
637
|
+
const failed = this.allRecords.filter((r) => r.status === "failed" || r.status === "invalid").length;
|
|
638
|
+
const skipped = this.totalRecords - processed - failed;
|
|
639
|
+
const elapsed = this.startedAt ? Date.now() - this.startedAt : 0;
|
|
640
|
+
return { total: this.totalRecords, processed, failed, skipped, elapsedMs: elapsed };
|
|
641
|
+
}
|
|
642
|
+
emitProgress() {
|
|
643
|
+
this.eventBus.emit({
|
|
644
|
+
type: "import:progress",
|
|
645
|
+
jobId: this.jobId,
|
|
646
|
+
progress: this.buildProgress(),
|
|
647
|
+
timestamp: Date.now()
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
async saveState() {
|
|
651
|
+
const state = {
|
|
652
|
+
id: this.jobId,
|
|
653
|
+
config: {
|
|
654
|
+
schema: this.config.schema,
|
|
655
|
+
batchSize: this.config.batchSize,
|
|
656
|
+
continueOnError: this.config.continueOnError
|
|
657
|
+
},
|
|
658
|
+
status: this.status,
|
|
659
|
+
batches: this.batches,
|
|
660
|
+
totalRecords: this.totalRecords,
|
|
661
|
+
startedAt: this.startedAt
|
|
662
|
+
};
|
|
663
|
+
await this.stateStore.saveJobState(state);
|
|
664
|
+
}
|
|
665
|
+
async checkPause() {
|
|
666
|
+
if (this.pausePromise) {
|
|
667
|
+
await this.pausePromise.promise;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
createPausePromise() {
|
|
671
|
+
let resolveRef;
|
|
672
|
+
const promise = new Promise((resolve) => {
|
|
673
|
+
resolveRef = resolve;
|
|
674
|
+
});
|
|
675
|
+
return { resolve: resolveRef, promise };
|
|
676
|
+
}
|
|
677
|
+
assertSourceConfigured() {
|
|
678
|
+
if (!this.source || !this.parser) {
|
|
679
|
+
throw new Error("Source and parser must be configured. Call .from(source, parser) first.");
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
assertCanStart() {
|
|
683
|
+
if (this.status !== "PREVIEWED" && this.status !== "CREATED") {
|
|
684
|
+
throw new Error(`Cannot start import from status '${this.status}'`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// src/domain/model/BatchStatus.ts
|
|
690
|
+
var BatchStatus = {
|
|
691
|
+
PENDING: "PENDING",
|
|
692
|
+
PROCESSING: "PROCESSING",
|
|
693
|
+
PAUSED: "PAUSED",
|
|
694
|
+
COMPLETED: "COMPLETED",
|
|
695
|
+
FAILED: "FAILED"
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
// src/infrastructure/parsers/CsvParser.ts
|
|
699
|
+
import Papa from "papaparse";
|
|
700
|
+
var CsvParser = class {
|
|
701
|
+
constructor(options) {
|
|
702
|
+
this.options = {
|
|
703
|
+
delimiter: options?.delimiter,
|
|
704
|
+
encoding: options?.encoding ?? "utf-8",
|
|
705
|
+
hasHeader: options?.hasHeader ?? true
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
*parse(data) {
|
|
709
|
+
const content = typeof data === "string" ? data : data.toString("utf-8");
|
|
710
|
+
const result = Papa.parse(content, {
|
|
711
|
+
header: this.options.hasHeader,
|
|
712
|
+
delimiter: this.options.delimiter || void 0,
|
|
713
|
+
skipEmptyLines: true,
|
|
714
|
+
dynamicTyping: false
|
|
715
|
+
});
|
|
716
|
+
for (const row of result.data) {
|
|
717
|
+
if (this.isEmptyRow(row)) continue;
|
|
718
|
+
yield row;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
detect(sample) {
|
|
722
|
+
const content = typeof sample === "string" ? sample : sample.toString("utf-8");
|
|
723
|
+
const firstLines = content.split("\n").slice(0, 5).join("\n");
|
|
724
|
+
const delimiters = [",", ";", " ", "|"];
|
|
725
|
+
let bestDelimiter = ",";
|
|
726
|
+
let maxColumns = 0;
|
|
727
|
+
for (const delimiter of delimiters) {
|
|
728
|
+
const result = Papa.parse(firstLines, { delimiter, header: false });
|
|
729
|
+
const firstRow = result.data[0];
|
|
730
|
+
if (firstRow && firstRow.length > maxColumns) {
|
|
731
|
+
maxColumns = firstRow.length;
|
|
732
|
+
bestDelimiter = delimiter;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
736
|
+
delimiter: bestDelimiter,
|
|
737
|
+
encoding: "utf-8",
|
|
738
|
+
hasHeader: true
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
isEmptyRow(row) {
|
|
742
|
+
return Object.values(row).every((v) => v === null || v === void 0 || v === "");
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
// src/infrastructure/sources/BufferSource.ts
|
|
747
|
+
var BufferSource = class {
|
|
748
|
+
constructor(data, metadata) {
|
|
749
|
+
this.content = typeof data === "string" ? data : data.toString("utf-8");
|
|
750
|
+
this.meta = {
|
|
751
|
+
fileName: metadata?.fileName ?? "buffer-input",
|
|
752
|
+
fileSize: this.content.length,
|
|
753
|
+
mimeType: metadata?.mimeType ?? "text/plain"
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
async *read() {
|
|
757
|
+
yield await Promise.resolve(this.content);
|
|
758
|
+
}
|
|
759
|
+
sample(maxBytes) {
|
|
760
|
+
if (maxBytes && maxBytes < this.content.length) {
|
|
761
|
+
return Promise.resolve(this.content.slice(0, maxBytes));
|
|
762
|
+
}
|
|
763
|
+
return Promise.resolve(this.content);
|
|
764
|
+
}
|
|
765
|
+
metadata() {
|
|
766
|
+
return this.meta;
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
export {
|
|
770
|
+
BatchStatus,
|
|
771
|
+
BufferSource,
|
|
772
|
+
BulkImport,
|
|
773
|
+
CsvParser,
|
|
774
|
+
ImportStatus,
|
|
775
|
+
InMemoryStateStore
|
|
776
|
+
};
|
|
777
|
+
//# sourceMappingURL=index.js.map
|