@learncard/network-brain-service 4.0.2 → 4.0.4

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 (55) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/accesslayer/boost/read.d.ts.map +1 -1
  3. package/dist/accesslayer/boost/relationships/read.d.ts.map +1 -1
  4. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  5. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  6. package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
  7. package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
  8. package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
  9. package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
  10. package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
  11. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
  12. package/dist/accesslayer/profile/create.d.ts.map +1 -1
  13. package/dist/accesslayer/profile/read.d.ts.map +1 -1
  14. package/dist/accesslayer/profile/search-fields.d.ts +16 -0
  15. package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
  16. package/dist/accesslayer/profile/update.d.ts.map +1 -1
  17. package/dist/helpers/boost.helpers.d.ts +1 -0
  18. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  21. package/dist/models/index.d.ts.map +1 -1
  22. package/dist/publish-metafile.json +1 -1
  23. package/dist/routes/workflows.d.ts.map +1 -1
  24. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  25. package/esbuildPlugins.cjs +9 -6
  26. package/package.json +19 -16
  27. package/project.json +18 -0
  28. package/scripts/backfill-profile-search.ts +79 -0
  29. package/scripts/seed-demo-persona.ts +6 -1
  30. package/src/accesslayer/boost/read.ts +171 -34
  31. package/src/accesslayer/boost/relationships/read.ts +163 -126
  32. package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
  33. package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
  34. package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
  35. package/src/accesslayer/inbox-batch/schema.ts +54 -0
  36. package/src/accesslayer/inbox-batch/store.ts +2 -38
  37. package/src/accesslayer/profile/create.ts +2 -1
  38. package/src/accesslayer/profile/read.ts +23 -3
  39. package/src/accesslayer/profile/search-fields.ts +69 -0
  40. package/src/accesslayer/profile/update.ts +7 -1
  41. package/src/helpers/boost-storage.helpers.test.ts +155 -1
  42. package/src/helpers/boost.helpers.ts +52 -10
  43. package/src/models/credential-refresh-constraints.test.ts +44 -0
  44. package/src/models/credential-refresh-constraints.ts +29 -3
  45. package/src/models/index.ts +15 -4
  46. package/src/routes/workflows.ts +8 -1
  47. package/src/seed/seedSkillFrameworks.ts +4 -1
  48. package/test/boost.familial-equivalence.spec.ts +482 -0
  49. package/test/boost.recipientsJoin.spec.ts +508 -0
  50. package/test/consentflow.spec.ts +101 -0
  51. package/test/inbox-batch.spec.ts +189 -1
  52. package/test/profile-search.spec.ts +307 -0
  53. package/vitest.boost-recipients-neo4j.config.ts +21 -0
  54. package/vitest.config.ts +1 -0
  55. package/vitest.profile-search-neo4j.config.ts +20 -0
@@ -0,0 +1,482 @@
1
+ import { describe, it, beforeAll, afterAll, expect } from 'vitest';
2
+ import { BindParam, QueryBuilder } from 'neogma';
3
+
4
+ import { neogma } from '@instance';
5
+ import { Boost, BoostInstance, Profile } from '@models';
6
+ import { countFamilialBoosts, getFamilialBoosts } from '@accesslayer/boost/read';
7
+ import {
8
+ buildWhereForQueryBuilder,
9
+ convertObjectRegExpToNeo4j,
10
+ convertQueryResultToPropertiesObjectArray,
11
+ } from '@helpers/neo4j.helpers';
12
+ import { inflateObject } from '@helpers/objects.helpers';
13
+ import { BoostQuery } from '@learncard/types';
14
+
15
+ /**
16
+ * Old-vs-new comparison for familial boosts.
17
+ *
18
+ * `reference*` below is the familial query as it was before it was rewritten to expand outward
19
+ * from the start boost (one MATCH over every Boost, filtered by OR-ed path predicates). Every case
20
+ * runs both forms against the same small graphs and expects the same answer.
21
+ *
22
+ * Ordering among boosts with the same CREATED_BY date is unspecified in both forms, so result sets
23
+ * are compared as sorted multisets, and cursor pages compare the dates on each page (always
24
+ * deterministic) plus the ids where no dates tie.
25
+ */
26
+
27
+ type WhereQuery = Parameters<typeof buildWhereForQueryBuilder>[1];
28
+
29
+ type Generations = {
30
+ parentGenerations: number;
31
+ childGenerations: number;
32
+ includeExtendedFamily: boolean;
33
+ };
34
+
35
+ const familyPredicate = ({
36
+ parentGenerations,
37
+ childGenerations,
38
+ includeExtendedFamily,
39
+ }: Generations): string => {
40
+ const p = Number.isFinite(parentGenerations) ? parentGenerations : '';
41
+ const c = Number.isFinite(childGenerations) ? childGenerations : '';
42
+
43
+ return `
44
+ ((start)<-[:PARENT_OF*1..${p}]-(boost) OR
45
+ (start)-[:PARENT_OF*1..${c}]->(boost) OR
46
+ (start)<-[:PARENT_OF${includeExtendedFamily ? `*1..${p}` : ''}]-(:Boost)-[:PARENT_OF${
47
+ includeExtendedFamily ? `*1..${c}` : ''
48
+ }]->(boost) AND boost <> start)`;
49
+ };
50
+
51
+ const referenceGetFamilialBoosts = async (
52
+ id: string,
53
+ generations: Generations,
54
+ { limit, cursor, query = {} }: { limit: number; cursor?: string; query?: BoostQuery }
55
+ ) => {
56
+ const { whereClause, params } = buildWhereForQueryBuilder(
57
+ 'boost',
58
+ convertObjectRegExpToNeo4j(query) as WhereQuery
59
+ );
60
+
61
+ const _query = new QueryBuilder(new BindParam({ cursor, ...params }))
62
+ .match({ model: Boost, where: { id }, identifier: 'start' })
63
+ .match({ model: Boost, identifier: 'boost' })
64
+ .where(`${familyPredicate(generations)}\nAND ${whereClause}`)
65
+ .raw('MATCH (boost)-[createdBy:CREATED_BY]-()');
66
+
67
+ const results = convertQueryResultToPropertiesObjectArray<{
68
+ boost: Record<string, unknown>;
69
+ createdBy: { date: string };
70
+ }>(
71
+ await (cursor ? _query.where('createdBy.date < $cursor') : _query)
72
+ .return('DISTINCT boost, createdBy')
73
+ .orderBy('createdBy.date DESC')
74
+ .limit(limit)
75
+ .run()
76
+ );
77
+
78
+ return results.map(result => ({
79
+ ...(inflateObject(result.boost) as Record<string, unknown>),
80
+ id: String(result.boost.id),
81
+ created: result.createdBy.date,
82
+ }));
83
+ };
84
+
85
+ const referenceCountFamilialBoosts = async (
86
+ id: string,
87
+ generations: Generations,
88
+ query: BoostQuery = {}
89
+ ): Promise<number> => {
90
+ const { whereClause, params } = buildWhereForQueryBuilder(
91
+ 'boost',
92
+ convertObjectRegExpToNeo4j(query) as WhereQuery
93
+ );
94
+
95
+ const result = await new QueryBuilder(new BindParam({ ...params }))
96
+ .match({ model: Boost, where: { id }, identifier: 'start' })
97
+ .match({ model: Boost, identifier: 'boost' })
98
+ .where(`${familyPredicate(generations)}\nAND ${whereClause}`)
99
+ .return('COUNT(DISTINCT boost) AS count')
100
+ .run();
101
+
102
+ return Number(result.records[0]?.get('count') ?? 0);
103
+ };
104
+
105
+ const asBoost = (id: string) => ({ id }) as unknown as BoostInstance;
106
+
107
+ const projection = (rows: Array<{ id: string; created: string }>) =>
108
+ rows.map(row => `${row.created}|${row.id}`).sort();
109
+
110
+ /**
111
+ * Graph shapes (every PARENT_OF points parent -> child). `date` is the CREATED_BY date; `null`
112
+ * leaves the boost without one, and an array gives it several. `labels` overrides `:Boost` so a
113
+ * non-Boost node can sit on a PARENT_OF path.
114
+ */
115
+ type FixtureNode = {
116
+ id: string;
117
+ category?: string;
118
+ status?: string;
119
+ date?: string | string[] | null;
120
+ labels?: string;
121
+ };
122
+
123
+ const nodes: FixtureNode[] = [];
124
+ const edges: Array<[string, string]> = [];
125
+
126
+ const day = (n: number) => `2025-01-${String((n % 28) + 1).padStart(2, '0')}T00:00:00.000Z`;
127
+ let dateCounter = 0;
128
+ const uniqueDate = () => {
129
+ dateCounter += 1;
130
+ return `2025-03-01T00:00:${String(Math.floor(dateCounter / 60)).padStart(2, '0')}.${String(
131
+ dateCounter % 60
132
+ ).padStart(3, '0')}Z`;
133
+ };
134
+
135
+ const node = (id: string, extra: Omit<FixtureNode, 'id'> = {}) => {
136
+ nodes.push({ category: 'A', status: 'LIVE', date: uniqueDate(), ...extra, id });
137
+ return id;
138
+ };
139
+ const edge = (parent: string, child: string) => edges.push([parent, child]);
140
+
141
+ // Tree: root -> 3 children -> 3 grandchildren each -> 2 great-grandchildren each
142
+ const tree = node('tree-root', { category: 'Root' });
143
+ ['a', 'b', 'c'].forEach((x, i) => {
144
+ const child = node(`tree-${x}`, { category: i === 0 ? 'Few' : 'B' });
145
+ edge(tree, child);
146
+ [1, 2, 3].forEach(y => {
147
+ const grandchild = node(`tree-${x}${y}`, {
148
+ category: y === 1 ? 'A' : 'B',
149
+ status: y === 3 ? 'DRAFT' : 'LIVE',
150
+ });
151
+ edge(child, grandchild);
152
+ [1, 2].forEach(z => edge(grandchild, node(`tree-${x}${y}${z}`, { category: 'C' })));
153
+ });
154
+ });
155
+
156
+ // Cycles and a self-loop
157
+ ['cyc-a', 'cyc-b', 'cyc-c'].forEach(id => node(id, { category: 'Few' }));
158
+ edge('cyc-a', 'cyc-b');
159
+ edge('cyc-b', 'cyc-c');
160
+ edge('cyc-c', 'cyc-a');
161
+ node('cyc-d', { category: 'B' });
162
+ edge('cyc-a', 'cyc-d');
163
+ edge('cyc-d', 'cyc-d');
164
+ node('cyc-e');
165
+ edge('cyc-d', 'cyc-e');
166
+
167
+ // Several paths to one node, and a parallel edge
168
+ ['mp-r', 'mp-x', 'mp-y', 'mp-z', 'mp-w', 'mp-v'].forEach(id => node(id, { category: 'B' }));
169
+ edge('mp-r', 'mp-x');
170
+ edge('mp-r', 'mp-x');
171
+ edge('mp-r', 'mp-y');
172
+ edge('mp-x', 'mp-z');
173
+ edge('mp-y', 'mp-z');
174
+ edge('mp-z', 'mp-w');
175
+ edge('mp-y', 'mp-v');
176
+
177
+ // Non-Boost nodes on PARENT_OF paths. nb-s's parent is a non-Boost node, so set-based cousin
178
+ // shortcuts must not apply there.
179
+ node('nb-x', { category: 'Few' });
180
+ node('nb-w', { labels: 'Thing' });
181
+ node('nb-s');
182
+ node('nb-z', { category: 'Few' });
183
+ node('nb-y');
184
+ node('nb-q', { category: 'Few' });
185
+ edge('nb-x', 'nb-w');
186
+ edge('nb-w', 'nb-s');
187
+ edge('nb-w', 'nb-z');
188
+ edge('nb-x', 'nb-y');
189
+ edge('nb-s', 'nb-q');
190
+ node('nb-m', { labels: 'Thing' });
191
+ node('nb-t', { category: 'Few' });
192
+ edge('tree-a', 'nb-m');
193
+ edge('nb-m', 'nb-t');
194
+
195
+ // Missing and duplicate CREATED_BY relationships
196
+ node('cb-root', { category: 'B' });
197
+ node('cb-none', { date: null });
198
+ node('cb-two', { date: [day(3), day(4)] });
199
+ node('cb-same', { date: [day(5), day(5)] });
200
+ node('cb-leaf', { category: 'Few' });
201
+ edge('cb-root', 'cb-none');
202
+ edge('cb-root', 'cb-two');
203
+ edge('cb-root', 'cb-same');
204
+ edge('cb-two', 'cb-leaf');
205
+
206
+ // Equal dates between siblings
207
+ node('tie-root', { category: 'B' });
208
+ [1, 2, 3, 4, 5].forEach(n => edge('tie-root', node(`tie-${n}`, { date: day(9) })));
209
+
210
+ // Isolated boost
211
+ node('iso', { category: 'Few' });
212
+
213
+ // Wide parent: more indexed candidates than the per-candidate path takes ("Many"), plus a few
214
+ // ("Few" and "Rare") that also exist outside the family
215
+ node('wide', { category: 'B' });
216
+ for (let n = 0; n < 30; n += 1) edge('wide', node(`wide-${n}`, { category: 'Many' }));
217
+ ['wide-f1', 'wide-f2', 'wide-f3'].forEach(id => edge('wide', node(id, { category: 'Rare' })));
218
+ node('rare-outside', { category: 'Rare' });
219
+
220
+ // Deep chain with a side child on every level
221
+ for (let n = 0; n <= 20; n += 1) node(`deep-${n}`, { category: n % 5 === 0 ? 'Few' : 'Deep' });
222
+ for (let n = 0; n < 20; n += 1) edge(`deep-${n + 1}`, `deep-${n}`);
223
+ for (let n = 1; n <= 20; n += 1) edge(`deep-${n}`, node(`deep-side-${n}`, { category: 'Side' }));
224
+
225
+ const starts = [
226
+ 'tree-root',
227
+ 'tree-a',
228
+ 'tree-b2',
229
+ 'tree-c31',
230
+ 'cyc-a',
231
+ 'cyc-d',
232
+ 'mp-z',
233
+ 'mp-x',
234
+ 'nb-s',
235
+ 'nb-t',
236
+ 'cb-two',
237
+ 'tie-3',
238
+ 'iso',
239
+ 'wide-4',
240
+ 'deep-0',
241
+ 'deep-10',
242
+ 'missing-boost',
243
+ ];
244
+
245
+ const generationSets: Generations[] = [
246
+ { parentGenerations: 1, childGenerations: 1, includeExtendedFamily: false },
247
+ { parentGenerations: 2, childGenerations: 1, includeExtendedFamily: false },
248
+ { parentGenerations: 1, childGenerations: 3, includeExtendedFamily: false },
249
+ { parentGenerations: Infinity, childGenerations: 1, includeExtendedFamily: false },
250
+ { parentGenerations: 1, childGenerations: Infinity, includeExtendedFamily: false },
251
+ { parentGenerations: 1, childGenerations: 1, includeExtendedFamily: true },
252
+ { parentGenerations: 2, childGenerations: 2, includeExtendedFamily: true },
253
+ { parentGenerations: Infinity, childGenerations: Infinity, includeExtendedFamily: true },
254
+ ];
255
+
256
+ const filters: Array<[string, BoostQuery]> = [
257
+ ['no filter', {}],
258
+ ['category, few matches', { category: 'Few' }],
259
+ ['category, more than 16 matches', { category: 'Many' }],
260
+ ['category, absent', { category: 'Absent' }],
261
+ ['category $in', { category: { $in: ['Rare', 'Few', 'Side'] } }],
262
+ ['id', { id: 'tree-a1' }],
263
+ ['category and status', { category: 'B', status: 'DRAFT' }],
264
+ ['status only', { status: 'DRAFT' }],
265
+ ['document-level $or', { $or: [{ category: 'Few' }, { category: 'Side' }] } as BoostQuery],
266
+ ];
267
+
268
+ const genLabel = (g: Generations) =>
269
+ `p${g.parentGenerations} c${g.childGenerations}${g.includeExtendedFamily ? ' extended' : ''}`;
270
+
271
+ const cases = starts.flatMap(start =>
272
+ generationSets.flatMap(generations =>
273
+ filters.map(([filterName, query]) => ({ start, generations, filterName, query }))
274
+ )
275
+ );
276
+
277
+ describe('Familial boosts match the previous query', () => {
278
+ beforeAll(async () => {
279
+ await Boost.delete({ detach: true, where: {} });
280
+ await Profile.delete({ detach: true, where: {} });
281
+ await neogma.queryRunner.run('MATCH (n:Thing) DETACH DELETE n', {});
282
+
283
+ await neogma.queryRunner.run(
284
+ `CREATE (:Profile {profileId: 'familial-creator', did: 'did:test:familial-creator'})`,
285
+ {}
286
+ );
287
+
288
+ for (const n of nodes) {
289
+ await neogma.queryRunner.run(
290
+ `MATCH (creator:Profile {profileId: 'familial-creator'})
291
+ CREATE (b:${n.labels ?? 'Boost'} {id: $id, name: $id, category: $category, status: $status, type: 'test', boost: '{}'})
292
+ WITH b, creator
293
+ UNWIND $dates AS date
294
+ CREATE (b)-[:CREATED_BY {date: date}]->(creator)`,
295
+ {
296
+ id: n.id,
297
+ category: n.category,
298
+ status: n.status,
299
+ dates: n.date === null ? [] : Array.isArray(n.date) ? n.date : [n.date],
300
+ }
301
+ );
302
+ }
303
+
304
+ for (const [parent, child] of edges) {
305
+ await neogma.queryRunner.run(
306
+ `MATCH (p {id: $parent}) MATCH (c {id: $child}) CREATE (p)-[:PARENT_OF]->(c)`,
307
+ { parent, child }
308
+ );
309
+ }
310
+ });
311
+
312
+ afterAll(async () => {
313
+ await Boost.delete({ detach: true, where: {} });
314
+ await Profile.delete({ detach: true, where: {} });
315
+ await neogma.queryRunner.run('MATCH (n:Thing) DETACH DELETE n', {});
316
+ });
317
+
318
+ it('covers every start, generation setting and filter', () => {
319
+ expect(cases.length).toBe(starts.length * generationSets.length * filters.length);
320
+ expect(cases.length).toBe(1224);
321
+ });
322
+
323
+ // Split per start so each case stays well under Vitest's 30 s timeout
324
+ describe.each(starts)('from %s', start => {
325
+ const startCases = cases.filter(c => c.start === start);
326
+
327
+ it(`counts match on ${startCases.length} cases`, async () => {
328
+ const diffs: string[] = [];
329
+
330
+ for (const { start, generations, filterName, query } of startCases) {
331
+ const expected = await referenceCountFamilialBoosts(start, generations, query);
332
+ const actual = await countFamilialBoosts(asBoost(start), { ...generations, query });
333
+
334
+ if (actual !== expected) {
335
+ diffs.push(
336
+ `${start} ${genLabel(generations)} ${filterName}: ${actual} != ${expected}`
337
+ );
338
+ }
339
+ }
340
+
341
+ expect(diffs).toEqual([]);
342
+ });
343
+
344
+ it(`full result sets match on ${startCases.length} cases`, async () => {
345
+ const diffs: string[] = [];
346
+
347
+ for (const { start, generations, filterName, query } of startCases) {
348
+ const expected = await referenceGetFamilialBoosts(start, generations, {
349
+ limit: 1000,
350
+ query,
351
+ });
352
+ const actual = await getFamilialBoosts(asBoost(start), {
353
+ ...generations,
354
+ limit: 1000,
355
+ query,
356
+ });
357
+
358
+ const dates = actual.map(row => row.created);
359
+ const sortedDesc = [...dates].sort().reverse();
360
+
361
+ if (JSON.stringify(projection(actual)) !== JSON.stringify(projection(expected))) {
362
+ diffs.push(`${start} ${genLabel(generations)} ${filterName}: rows differ`);
363
+ } else if (JSON.stringify(dates) !== JSON.stringify(sortedDesc)) {
364
+ diffs.push(`${start} ${genLabel(generations)} ${filterName}: not date-ordered`);
365
+ }
366
+ }
367
+
368
+ expect(diffs).toEqual([]);
369
+ });
370
+
371
+ it(`cursor pages of 2 match on ${startCases.length} cases`, async () => {
372
+ const diffs: string[] = [];
373
+ let pages = 0;
374
+
375
+ for (const { start, generations, filterName, query } of startCases) {
376
+ const all = await referenceGetFamilialBoosts(start, generations, {
377
+ limit: 1000,
378
+ query,
379
+ });
380
+ const dateCounts = new Map<string, number>();
381
+ all.forEach(row =>
382
+ dateCounts.set(row.created, (dateCounts.get(row.created) ?? 0) + 1)
383
+ );
384
+
385
+ let cursor: string | undefined;
386
+
387
+ for (let page = 0; page < 8; page += 1) {
388
+ const expected = await referenceGetFamilialBoosts(start, generations, {
389
+ limit: 2,
390
+ cursor,
391
+ query,
392
+ });
393
+ const actual = await getFamilialBoosts(asBoost(start), {
394
+ ...generations,
395
+ limit: 2,
396
+ cursor,
397
+ query,
398
+ });
399
+ pages += 1;
400
+
401
+ const label = `${start} ${genLabel(generations)} ${filterName} page ${page}`;
402
+ const expectedDates = expected.map(row => row.created);
403
+
404
+ if (
405
+ JSON.stringify(actual.map(row => row.created)) !==
406
+ JSON.stringify(expectedDates)
407
+ ) {
408
+ diffs.push(`${label}: dates differ`);
409
+ break;
410
+ }
411
+
412
+ // Ids are only deterministic when no other row shares the date
413
+ const uniqueIds = (rows: typeof expected) =>
414
+ rows.filter(row => dateCounts.get(row.created) === 1).map(row => row.id);
415
+
416
+ if (JSON.stringify(uniqueIds(actual)) !== JSON.stringify(uniqueIds(expected))) {
417
+ diffs.push(`${label}: ids differ`);
418
+ break;
419
+ }
420
+
421
+ if (expected.length < 2) break;
422
+
423
+ cursor = expected[expected.length - 1]!.created;
424
+ }
425
+ }
426
+
427
+ // A missing or isolated start still fetches one (empty or short) page per case
428
+ expect(pages).toBeGreaterThanOrEqual(startCases.length);
429
+ expect(diffs).toEqual([]);
430
+ });
431
+ });
432
+
433
+ it.each([
434
+ ['tree-a', { parentGenerations: 1, childGenerations: 2, includeExtendedFamily: false }, {}],
435
+ [
436
+ 'deep-10',
437
+ { parentGenerations: Infinity, childGenerations: 1, includeExtendedFamily: true },
438
+ {},
439
+ ],
440
+ [
441
+ 'wide-4',
442
+ { parentGenerations: 1, childGenerations: 1, includeExtendedFamily: false },
443
+ { category: 'Rare' },
444
+ ],
445
+ ['mp-z', { parentGenerations: 2, childGenerations: 2, includeExtendedFamily: true }, {}],
446
+ [
447
+ 'tree-a2',
448
+ { parentGenerations: 2, childGenerations: 2, includeExtendedFamily: true },
449
+ { category: 'Few' },
450
+ ],
451
+ ] as Array<[string, Generations, BoostQuery]>)(
452
+ 'returns the same boosts, in order, for %s',
453
+ async (start, generations, query) => {
454
+ const expected = await referenceGetFamilialBoosts(start, generations, {
455
+ limit: 1000,
456
+ query,
457
+ });
458
+ const actual = await getFamilialBoosts(asBoost(start), {
459
+ ...generations,
460
+ limit: 1000,
461
+ query,
462
+ });
463
+
464
+ // These families have no tied dates, so the order is fully determined
465
+ expect(new Set(expected.map(row => row.created)).size).toBe(expected.length);
466
+ expect(expected.length).toBeGreaterThan(1);
467
+ expect(actual).toEqual(expected);
468
+
469
+ const expectedPage = await referenceGetFamilialBoosts(start, generations, {
470
+ limit: 2,
471
+ query,
472
+ });
473
+ const actualPage = await getFamilialBoosts(asBoost(start), {
474
+ ...generations,
475
+ limit: 2,
476
+ query,
477
+ });
478
+
479
+ expect(actualPage).toEqual(expectedPage);
480
+ }
481
+ );
482
+ });