@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.
- package/CHANGELOG.md +35 -0
- package/dist/accesslayer/boost/read.d.ts.map +1 -1
- package/dist/accesslayer/boost/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/profile/create.d.ts.map +1 -1
- package/dist/accesslayer/profile/read.d.ts.map +1 -1
- package/dist/accesslayer/profile/search-fields.d.ts +16 -0
- package/dist/accesslayer/profile/search-fields.d.ts.map +1 -0
- package/dist/accesslayer/profile/update.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
- package/dist/models/index.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
- package/package.json +19 -16
- package/project.json +18 -0
- package/scripts/backfill-profile-search.ts +79 -0
- package/scripts/seed-demo-persona.ts +6 -1
- package/src/accesslayer/boost/read.ts +171 -34
- package/src/accesslayer/boost/relationships/read.ts +163 -126
- package/src/accesslayer/profile/create.ts +2 -1
- package/src/accesslayer/profile/read.ts +23 -3
- package/src/accesslayer/profile/search-fields.ts +69 -0
- package/src/accesslayer/profile/update.ts +7 -1
- package/src/models/credential-refresh-constraints.test.ts +44 -0
- package/src/models/credential-refresh-constraints.ts +29 -3
- package/src/models/index.ts +15 -4
- package/src/seed/seedSkillFrameworks.ts +4 -1
- package/test/boost.familial-equivalence.spec.ts +482 -0
- package/test/boost.recipientsJoin.spec.ts +508 -0
- package/test/profile-search.spec.ts +307 -0
- package/vitest.boost-recipients-neo4j.config.ts +21 -0
- package/vitest.profile-search-neo4j.config.ts +20 -0
package/src/models/index.ts
CHANGED
|
@@ -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
|
+
});
|