@learncard/network-brain-service 4.0.1 → 4.0.3

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 (32) hide show
  1. package/CHANGELOG.md +35 -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/profile/create.d.ts.map +1 -1
  5. package/dist/accesslayer/profile/read.d.ts.map +1 -1
  6. package/dist/accesslayer/profile/search-fields.d.ts +16 -0
  7. package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
  8. package/dist/accesslayer/profile/update.d.ts.map +1 -1
  9. package/dist/index.js +2 -2
  10. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  11. package/dist/models/index.d.ts.map +1 -1
  12. package/dist/publish-metafile.json +1 -1
  13. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  14. package/package.json +19 -16
  15. package/project.json +18 -0
  16. package/scripts/backfill-profile-search.ts +79 -0
  17. package/scripts/seed-demo-persona.ts +6 -1
  18. package/src/accesslayer/boost/read.ts +171 -34
  19. package/src/accesslayer/boost/relationships/read.ts +163 -126
  20. package/src/accesslayer/profile/create.ts +2 -1
  21. package/src/accesslayer/profile/read.ts +23 -3
  22. package/src/accesslayer/profile/search-fields.ts +69 -0
  23. package/src/accesslayer/profile/update.ts +7 -1
  24. package/src/models/credential-refresh-constraints.test.ts +44 -0
  25. package/src/models/credential-refresh-constraints.ts +29 -3
  26. package/src/models/index.ts +15 -4
  27. package/src/seed/seedSkillFrameworks.ts +4 -1
  28. package/test/boost.familial-equivalence.spec.ts +482 -0
  29. package/test/boost.recipientsJoin.spec.ts +508 -0
  30. package/test/profile-search.spec.ts +307 -0
  31. package/vitest.boost-recipients-neo4j.config.ts +21 -0
  32. package/vitest.profile-search-neo4j.config.ts +20 -0
@@ -150,6 +150,8 @@ const indexQueries = [
150
150
  'CREATE INDEX profile_did_idx IF NOT EXISTS FOR (p:Profile) ON (p.did)',
151
151
  'CREATE TEXT INDEX profileId_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.profileId)',
152
152
  'CREATE TEXT INDEX profile_displayname_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.displayName)',
153
+ // Backs case-insensitive profile search (see accesslayer/profile/search-fields.ts)
154
+ 'CREATE TEXT INDEX profile_displayname_lower_text_idx IF NOT EXISTS FOR (n:Profile) ON (n.displayNameLower)',
153
155
  'CREATE INDEX boost_id_idx IF NOT EXISTS FOR (b:Boost) ON (b.id)',
154
156
  'CREATE INDEX boost_category_idx IF NOT EXISTS FOR (b:Boost) ON (b.category)',
155
157
  'CREATE INDEX boost_autoconnect_idx IF NOT EXISTS FOR (b:Boost) ON (b.autoConnectRecipients)',
@@ -210,6 +212,10 @@ const indexQueries = [
210
212
  'CREATE INDEX contact_method_primary_idx IF NOT EXISTS FOR (c:ContactMethod) ON (c.isPrimary)',
211
213
  'CREATE INDEX credential_refresh_credential_id_idx IF NOT EXISTS FOR (r:CredentialRefresh) ON (r.credentialId)',
212
214
  'CREATE INDEX credential_refresh_version_refresh_id_idx IF NOT EXISTS FOR (c:Credential) ON (c.refreshId)',
215
+ // Credential activity reads join each CredentialActivity to its credential through
216
+ // (sender)-[:CREDENTIAL_SENT { activityId }]->(credential). Without this index every
217
+ // activity row scans all CREDENTIAL_SENT relationships.
218
+ 'CREATE INDEX credential_sent_activity_id_idx IF NOT EXISTS FOR ()-[r:CREDENTIAL_SENT]-() ON (r.activityId)',
213
219
  ];
214
220
 
215
221
  const wait = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
@@ -240,11 +246,16 @@ const runIndexQuery = async (query: string): Promise<void> => {
240
246
  };
241
247
 
242
248
  if (shouldCreateIndices) {
243
- void ensureCredentialRefreshConstraints().catch(err => {
244
- console.error('Error creating credential refresh constraints:', err);
245
- });
246
-
247
249
  (async function createIndices() {
250
+ // Constraints first, then indexes, one schema statement at a time. Running the two
251
+ // lists concurrently made every cold start contend with itself for schema locks.
252
+ // The readiness promise is memoized, so refresh routes awaiting it later reuse this run.
253
+ try {
254
+ await ensureCredentialRefreshConstraints();
255
+ } catch (err) {
256
+ console.error('Error creating credential refresh constraints:', err);
257
+ }
258
+
248
259
  try {
249
260
  for (const query of indexQueries) {
250
261
  await runIndexQuery(query);
@@ -13,6 +13,8 @@ import {
13
13
  upsertSkillEmbeddings,
14
14
  type UpdateSkillEmbeddingFn,
15
15
  } from '@helpers/skill-embedding.helpers';
16
+ import { syncProfileDisplayNameLower } from '../accesslayer/profile/search-fields';
17
+
16
18
  // Inlined from @helpers/profile.helpers to avoid importing the access/model layer
17
19
  // (it transitively pulls in @models, which hits a circular-dependency TDZ under tsx).
18
20
  const transformProfileId = (rawInput: string): string =>
@@ -519,7 +521,8 @@ export const ensureSeedProfile = async (
519
521
  p.shortBio = coalesce(p.shortBio, $shortBio),
520
522
  p.profileVisibility = coalesce(p.profileVisibility, $profileVisibility),
521
523
  p.approved = coalesce(p.approved, $approved),
522
- p.isServiceProfile = coalesce(p.isServiceProfile, $isServiceProfile)`,
524
+ p.isServiceProfile = coalesce(p.isServiceProfile, $isServiceProfile)
525
+ SET ${syncProfileDisplayNameLower('p')}`,
523
526
  {
524
527
  profileId: normalizedProfileId,
525
528
  displayName: DEFAULT_OWNER_DISPLAY_NAME,
@@ -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
+ });