-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathseed-loader-deferred-failure.test.ts
More file actions
190 lines (171 loc) · 6.97 KB
/
Copy pathseed-loader-deferred-failure.test.ts
File metadata and controls
190 lines (171 loc) · 6.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { describe, it, expect, vi } from 'vitest';
import { SeedLoaderService } from './seed-loader';
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
/**
* framework#2805: a pass-2 (deferred) reference back-fill that FAILS must be
* reported — not silently swallowed.
*
* Two records reference each other (a circular dependency). The parent is
* inserted first without the back-reference (deferred to pass 2); pass 2 then
* issues an `engine.update` to fill the reference in. Before this fix, if that
* update threw, `resolveDeferredUpdates` only logged a warning: the reference
* stayed NULL yet the loader still returned `success: true`, `errors: []`,
* `totalErrored: 0` — an incomplete relationship reported as a clean load.
*/
function createLogger() {
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
}
function createFaithfulEngine(): { engine: IDataEngine; store: Record<string, any[]> } {
const store: Record<string, any[]> = {};
let idCounter = 0;
const engine = {
find: vi.fn(async (objectName: string, query?: any) => {
let records = store[objectName] || [];
if (query?.where) {
records = records.filter((r) =>
Object.entries(query.where).every(([k, v]) => r[k] === v),
);
}
if (typeof query?.limit === 'number') records = records.slice(0, query.limit);
return records;
}),
findOne: vi.fn(async (objectName: string, query?: any) => {
const rows = await (engine.find as any)(objectName, { ...query, limit: 1 });
return rows[0] ?? null;
}),
insert: vi.fn(async (objectName: string, data: any) => {
if (!store[objectName]) store[objectName] = [];
if (Array.isArray(data)) {
const records = data.map((d) => ({ id: `gen-${++idCounter}`, ...d }));
store[objectName].push(...records);
return records;
}
const record = { id: `gen-${++idCounter}`, ...data };
store[objectName].push(record);
return record;
}),
update: vi.fn(async (objectName: string, data: any) => {
const records = store[objectName] || [];
const idx = records.findIndex((r) => r.id === data.id);
if (idx >= 0) { records[idx] = { ...records[idx], ...data }; return records[idx]; }
return data;
}),
delete: vi.fn(async () => ({ deleted: 1 })),
count: vi.fn(async (objectName: string) => (store[objectName] || []).length),
aggregate: vi.fn(async () => []),
} as unknown as IDataEngine;
return { engine, store };
}
// Two objects that reference each other → a circular dependency that forces
// the multi-pass deferred back-fill (audit_department.head_id is filled in
// during pass 2, once audit_worker "Alice" exists).
function createMetadata(): IMetadataService {
const objects: Record<string, any> = {
audit_department: {
name: 'audit_department',
fields: {
name: { type: 'text' },
head_id: { type: 'lookup', reference: 'audit_worker' },
},
},
audit_worker: {
name: 'audit_worker',
fields: {
name: { type: 'text' },
department_id: { type: 'lookup', reference: 'audit_department' },
},
},
};
return {
getObject: vi.fn(async (name: string) => objects[name]),
listObjects: vi.fn(async () => Object.values(objects)),
register: vi.fn(async () => {}),
get: vi.fn(async (_t: string, name: string) => objects[name]),
list: vi.fn(async () => []),
unregister: vi.fn(async () => {}),
exists: vi.fn(async () => false),
listNames: vi.fn(async () => []),
} as unknown as IMetadataService;
}
const CONFIG = {
dryRun: false,
haltOnError: false,
multiPass: true,
defaultMode: 'insert',
batchSize: 1000,
transaction: false,
} as any;
const SEEDS = [
{
object: 'audit_department',
externalId: 'name',
mode: 'insert',
env: ['prod', 'dev', 'test'],
records: [{ name: 'Engineering', head_id: 'Alice' }],
},
{
object: 'audit_worker',
externalId: 'name',
mode: 'insert',
env: ['prod', 'dev', 'test'],
records: [{ name: 'Alice', department_id: 'Engineering' }],
},
] as any[];
describe('seed deferred back-fill failure is reported, not swallowed (framework#2805)', () => {
it('a failing pass-2 reference update flips success=false and counts an error', async () => {
const { engine, store } = createFaithfulEngine();
const metadata = createMetadata();
// The ONLY update in this load is pass-2's back-fill of
// audit_department.head_id. Make every attempt fail (a persistent
// "fetch failed" outlasts the transient-retry budget) so the deferred
// reference genuinely never lands.
const realUpdate = (engine.update as any).getMockImplementation();
let deptUpdateAttempts = 0;
(engine.update as any).mockImplementation(async (obj: string, data: any, opts: any) => {
if (obj === 'audit_department') {
deptUpdateAttempts++;
throw new Error('fetch failed');
}
return realUpdate(obj, data, opts);
});
const result = await new SeedLoaderService(engine, metadata, createLogger()).load({
seeds: SEEDS,
config: CONFIG,
});
// The back-fill was attempted (and exhausted its retries).
expect(deptUpdateAttempts).toBeGreaterThan(0);
// The relationship is genuinely incomplete: Engineering.head_id is still null.
const engineeringRow = store.audit_department.find((r) => r.name === 'Engineering')!;
expect(engineeringRow.head_id == null).toBe(true);
// ...so the load must NOT report clean success.
expect(result.success).toBe(false);
expect(result.summary.totalErrored).toBeGreaterThan(0);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors.some((e: { field: string }) => e.field === 'head_id')).toBe(true);
});
it('a transient blip that recovers on retry still reports clean success', async () => {
const { engine, store } = createFaithfulEngine();
const metadata = createMetadata();
// First back-fill attempt blips, the retry succeeds — the reference lands,
// so this is NOT an error.
const realUpdate = (engine.update as any).getMockImplementation();
let deptUpdateAttempts = 0;
(engine.update as any).mockImplementation(async (obj: string, data: any, opts: any) => {
if (obj === 'audit_department') {
deptUpdateAttempts++;
if (deptUpdateAttempts === 1) throw new Error('fetch failed');
}
return realUpdate(obj, data, opts);
});
const result = await new SeedLoaderService(engine, metadata, createLogger()).load({
seeds: SEEDS,
config: CONFIG,
});
expect(deptUpdateAttempts).toBe(2); // blipped once, then succeeded
const aliceId = store.audit_worker.find((r) => r.name === 'Alice')!.id;
expect(store.audit_department.find((r) => r.name === 'Engineering')!.head_id).toBe(aliceId);
expect(result.success).toBe(true);
expect(result.summary.totalErrored).toBe(0);
});
});