spice-js 2.7.29 → 2.7.31
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/build/index.js +3 -1
- package/build/models/SpiceModel.js +306 -81
- package/package.json +1 -1
- package/src/index.js +3 -1
- package/src/models/SpiceModel.js +277 -26
- package/tests/__mocks__/globalMocks.js +3 -1
- package/tests/fixtures/testData.js +3 -1
- package/tests/helpers/modelFactory.js +7 -1
- package/tests/jest-env.js +3 -1
- package/tests/models/SpiceModel.mapping-depth.test.js +320 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
const { createTestModel, seedDatabase } = require('../helpers/modelFactory');
|
|
2
|
+
|
|
3
|
+
describe('SpiceModel type-specific mapping depth', () => {
|
|
4
|
+
function createRelatedModel(records, capture) {
|
|
5
|
+
return class RelatedModel {
|
|
6
|
+
constructor(args) {
|
|
7
|
+
capture.constructorArgs.push(args);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async getMulti(args) {
|
|
11
|
+
const { ids } = args;
|
|
12
|
+
capture.getMultiArgs.push(args);
|
|
13
|
+
capture.getMultiIds.push(ids);
|
|
14
|
+
return records.filter((record) => ids.includes(record.id));
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createMappedModel(args = {}) {
|
|
20
|
+
const scalarCapture = {
|
|
21
|
+
constructorArgs: [],
|
|
22
|
+
getMultiArgs: [],
|
|
23
|
+
getMultiIds: [],
|
|
24
|
+
};
|
|
25
|
+
const arrayCapture = {
|
|
26
|
+
constructorArgs: [],
|
|
27
|
+
getMultiArgs: [],
|
|
28
|
+
getMultiIds: [],
|
|
29
|
+
};
|
|
30
|
+
const ScalarModel = createRelatedModel(
|
|
31
|
+
[{ id: 'group-1', name: 'Administrators' }],
|
|
32
|
+
scalarCapture
|
|
33
|
+
);
|
|
34
|
+
const ArrayModel = createRelatedModel(
|
|
35
|
+
[
|
|
36
|
+
{ id: 'child-1', name: 'First' },
|
|
37
|
+
{ id: 'child-2', name: 'Second' }
|
|
38
|
+
],
|
|
39
|
+
arrayCapture
|
|
40
|
+
);
|
|
41
|
+
const model = createTestModel({
|
|
42
|
+
type: 'parent',
|
|
43
|
+
args,
|
|
44
|
+
props: {
|
|
45
|
+
group: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
map: { type: 'model', reference: ScalarModel }
|
|
48
|
+
},
|
|
49
|
+
children: {
|
|
50
|
+
type: 'array',
|
|
51
|
+
map: { type: 'model', reference: ArrayModel }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
seedDatabase(model, {
|
|
56
|
+
id: 'parent-1',
|
|
57
|
+
type: 'parent',
|
|
58
|
+
group: 'group-1',
|
|
59
|
+
children: ['child-1', 'child-2']
|
|
60
|
+
});
|
|
61
|
+
return { model, scalarCapture, arrayCapture };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function createConcurrencyModel(mappingConcurrency, extraArgs = {}) {
|
|
65
|
+
const stats = { active: 0, maxActive: 0 };
|
|
66
|
+
class RelatedModel {
|
|
67
|
+
async getMulti({ ids }) {
|
|
68
|
+
stats.active += 1;
|
|
69
|
+
stats.maxActive = Math.max(stats.maxActive, stats.active);
|
|
70
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
71
|
+
stats.active -= 1;
|
|
72
|
+
return ids.map((id) => ({ id, name: id }));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const props = {};
|
|
77
|
+
const record = { id: 'parent-concurrent', type: 'parent' };
|
|
78
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
79
|
+
const field = `relationship_${index}`;
|
|
80
|
+
props[field] = {
|
|
81
|
+
type: 'string',
|
|
82
|
+
map: { type: 'model', reference: RelatedModel }
|
|
83
|
+
};
|
|
84
|
+
record[field] = `related-${index}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const model = createTestModel({
|
|
88
|
+
type: 'parent',
|
|
89
|
+
args: {
|
|
90
|
+
...extraArgs,
|
|
91
|
+
...(mappingConcurrency ? { mapping_concurrency: mappingConcurrency } : {})
|
|
92
|
+
},
|
|
93
|
+
props
|
|
94
|
+
});
|
|
95
|
+
seedDatabase(model, record);
|
|
96
|
+
return { model, stats };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('uses scalar depth 1 and array depth 3 from the request profile', async () => {
|
|
100
|
+
const ctx = {
|
|
101
|
+
state: {
|
|
102
|
+
read_profile: {
|
|
103
|
+
mapping_dept_scalar: 1,
|
|
104
|
+
mapping_dept_array: 3
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const { model, scalarCapture, arrayCapture } = createMappedModel({ ctx });
|
|
109
|
+
|
|
110
|
+
const result = await model.get({ id: 'parent-1' });
|
|
111
|
+
|
|
112
|
+
expect(result.group).toBe('group-1');
|
|
113
|
+
expect(result.children).toEqual([
|
|
114
|
+
expect.objectContaining({ id: 'child-1', name: 'First' }),
|
|
115
|
+
expect.objectContaining({ id: 'child-2', name: 'Second' })
|
|
116
|
+
]);
|
|
117
|
+
expect(scalarCapture.getMultiIds).toEqual([]);
|
|
118
|
+
expect(arrayCapture.getMultiIds).toEqual([['child-1', 'child-2']]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('keeps legacy mapping_dept behavior for both relationship types', async () => {
|
|
122
|
+
const { model, scalarCapture, arrayCapture } = createMappedModel({
|
|
123
|
+
mapping_dept: 1
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = await model.get({ id: 'parent-1' });
|
|
127
|
+
|
|
128
|
+
expect(result.group).toBe('group-1');
|
|
129
|
+
expect(result.children).toEqual(['child-1', 'child-2']);
|
|
130
|
+
expect(scalarCapture.getMultiIds).toEqual([]);
|
|
131
|
+
expect(arrayCapture.getMultiIds).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('explicit read arguments override the request profile independently', async () => {
|
|
135
|
+
const ctx = {
|
|
136
|
+
state: {
|
|
137
|
+
read_profile: {
|
|
138
|
+
mapping_dept_scalar: 1,
|
|
139
|
+
mapping_dept_array: 1
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const { model } = createMappedModel({ ctx });
|
|
144
|
+
|
|
145
|
+
const result = await model.get({
|
|
146
|
+
id: 'parent-1',
|
|
147
|
+
mapping_dept_scalar: 3,
|
|
148
|
+
mapping_dept_array: 3
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.group).toEqual(
|
|
152
|
+
expect.objectContaining({ id: 'group-1', name: 'Administrators' })
|
|
153
|
+
);
|
|
154
|
+
expect(result.children).toHaveLength(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('mapping depth exemptions continue to expand scalar paths', async () => {
|
|
158
|
+
const { model, scalarCapture } = createMappedModel({
|
|
159
|
+
mapping_dept_scalar: 1,
|
|
160
|
+
mapping_dept_array: 3,
|
|
161
|
+
mapping_dept_exempt: ['group']
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const result = await model.get({ id: 'parent-1' });
|
|
165
|
+
|
|
166
|
+
expect(result.group).toEqual(
|
|
167
|
+
expect.objectContaining({ id: 'group-1', name: 'Administrators' })
|
|
168
|
+
);
|
|
169
|
+
expect(scalarCapture.getMultiIds).toEqual([['group-1']]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('propagates both depths to related model instances', async () => {
|
|
173
|
+
const { model, arrayCapture } = createMappedModel({
|
|
174
|
+
mapping_dept_scalar: 1,
|
|
175
|
+
mapping_dept_array: 3,
|
|
176
|
+
mapping_concurrency: 6
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await model.getMulti({ ids: ['parent-1'] });
|
|
180
|
+
|
|
181
|
+
expect(arrayCapture.constructorArgs[0]).toEqual(
|
|
182
|
+
expect.objectContaining({
|
|
183
|
+
mapping_dept_scalar: 1,
|
|
184
|
+
mapping_dept_array: 3,
|
|
185
|
+
mapping_concurrency: 6,
|
|
186
|
+
_level: 1
|
|
187
|
+
})
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('runs independent relationship maps with bounded concurrency', async () => {
|
|
192
|
+
const { model, stats } = createConcurrencyModel(2);
|
|
193
|
+
|
|
194
|
+
const result = await model.get({ id: 'parent-concurrent' });
|
|
195
|
+
|
|
196
|
+
expect(stats.maxActive).toBe(2);
|
|
197
|
+
expect(result.relationship_1).toEqual(
|
|
198
|
+
expect.objectContaining({ id: 'related-1' })
|
|
199
|
+
);
|
|
200
|
+
expect(result.relationship_3).toEqual(
|
|
201
|
+
expect.objectContaining({ id: 'related-3' })
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('keeps relationship maps sequential when concurrency is not enabled', async () => {
|
|
206
|
+
const { model, stats } = createConcurrencyModel();
|
|
207
|
+
|
|
208
|
+
await model.get({ id: 'parent-concurrent' });
|
|
209
|
+
|
|
210
|
+
expect(stats.maxActive).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('keeps nested relationship maps sequential to avoid recursive slot use', async () => {
|
|
214
|
+
const { model, stats } = createConcurrencyModel(2, { _level: 1 });
|
|
215
|
+
|
|
216
|
+
await model.get({ id: 'parent-concurrent' });
|
|
217
|
+
|
|
218
|
+
expect(stats.maxActive).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('profiles concurrent groups and mapping slot pressure', async () => {
|
|
222
|
+
const calls = [];
|
|
223
|
+
const profiler = {
|
|
224
|
+
track: jest.fn(async (name, execute, metadata) => {
|
|
225
|
+
calls.push({ name, metadata });
|
|
226
|
+
return await execute();
|
|
227
|
+
})
|
|
228
|
+
};
|
|
229
|
+
const { model } = createConcurrencyModel(2, { ctx: { profiler } });
|
|
230
|
+
|
|
231
|
+
await model.get({ id: 'parent-concurrent' });
|
|
232
|
+
|
|
233
|
+
expect(calls).toEqual(
|
|
234
|
+
expect.arrayContaining([
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
name: 'parent.map.concurrent',
|
|
237
|
+
metadata: expect.objectContaining({
|
|
238
|
+
map_count: 3,
|
|
239
|
+
request_limit: 2
|
|
240
|
+
})
|
|
241
|
+
}),
|
|
242
|
+
expect.objectContaining({
|
|
243
|
+
name: 'parent.map.concurrent.relationship_1',
|
|
244
|
+
metadata: expect.objectContaining({
|
|
245
|
+
request_limit: 2,
|
|
246
|
+
active_request_mappings: expect.any(Number),
|
|
247
|
+
active_global_mappings: expect.any(Number),
|
|
248
|
+
request_queue_wait_ms: expect.any(Number),
|
|
249
|
+
global_queue_wait_ms: expect.any(Number)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
])
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('skips serialization for mapped child reads when requested by the profile', async () => {
|
|
257
|
+
const ctx = {
|
|
258
|
+
state: {
|
|
259
|
+
read_profile: {
|
|
260
|
+
mapping_dept_scalar: 2,
|
|
261
|
+
mapping_dept_array: 3,
|
|
262
|
+
skip_mapped_serialization: true
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const { model, scalarCapture, arrayCapture } = createMappedModel({ ctx });
|
|
267
|
+
|
|
268
|
+
const result = await model.get({ id: 'parent-1' });
|
|
269
|
+
|
|
270
|
+
expect(result.group).toEqual(
|
|
271
|
+
expect.objectContaining({ id: 'group-1', name: 'Administrators' })
|
|
272
|
+
);
|
|
273
|
+
expect(result.children).toHaveLength(2);
|
|
274
|
+
expect(scalarCapture.getMultiArgs[0]).toEqual(
|
|
275
|
+
expect.objectContaining({ skip_hooks: true, skip_read_serialize: true })
|
|
276
|
+
);
|
|
277
|
+
expect(arrayCapture.getMultiArgs[0]).toEqual(
|
|
278
|
+
expect.objectContaining({ skip_hooks: true, skip_read_serialize: true })
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('does not apply the request read profile to write serialization', async () => {
|
|
283
|
+
const capture = { constructorArgs: [], getMultiArgs: [], getMultiIds: [] };
|
|
284
|
+
const RelatedModel = createRelatedModel(
|
|
285
|
+
[{ id: 'group-1', name: 'Administrators' }],
|
|
286
|
+
capture
|
|
287
|
+
);
|
|
288
|
+
const model = createTestModel({
|
|
289
|
+
type: 'parent',
|
|
290
|
+
args: {
|
|
291
|
+
ctx: {
|
|
292
|
+
state: {
|
|
293
|
+
read_profile: {
|
|
294
|
+
mapping_dept_scalar: 1,
|
|
295
|
+
mapping_dept_array: 1
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
props: {
|
|
301
|
+
group: {
|
|
302
|
+
type: 'string',
|
|
303
|
+
map: { type: 'model', reference: RelatedModel, when: 'write' }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const result = await model.do_serialize(
|
|
309
|
+
{ id: 'parent-1', group: 'group-1' },
|
|
310
|
+
'write',
|
|
311
|
+
{},
|
|
312
|
+
{}
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(result.group).toEqual(
|
|
316
|
+
expect.objectContaining({ id: 'group-1', name: 'Administrators' })
|
|
317
|
+
);
|
|
318
|
+
expect(capture.getMultiIds).toEqual([['group-1']]);
|
|
319
|
+
});
|
|
320
|
+
});
|