@learncard/network-brain-service 4.0.3 → 4.1.0
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/.env.example +14 -0
- package/CHANGELOG.md +76 -0
- package/config/config.dev.json +5 -0
- package/config/config.example.json +5 -0
- package/config/config.production.json +5 -0
- package/config/config.scouts.dev.json +5 -0
- package/config/config.scouts.production.json +5 -0
- package/dist/accesslayer/campaign/audience.d.ts +7 -0
- package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
- package/dist/accesslayer/campaign/create.d.ts +3 -0
- package/dist/accesslayer/campaign/create.d.ts.map +1 -0
- package/dist/accesslayer/campaign/delete.d.ts +2 -0
- package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
- package/dist/accesslayer/campaign/read.d.ts +6 -0
- package/dist/accesslayer/campaign/read.d.ts.map +1 -0
- package/dist/accesslayer/campaign/shared.d.ts +16 -0
- package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
- package/dist/accesslayer/campaign/state.d.ts +4 -0
- package/dist/accesslayer/campaign/state.d.ts.map +1 -0
- package/dist/accesslayer/campaign/update.d.ts +3 -0
- package/dist/accesslayer/campaign/update.d.ts.map +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential-share-previews.d.ts +5 -0
- package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
- package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
- package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
- package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
- package/dist/accesslayer/profile/delete.d.ts.map +1 -1
- package/dist/accesslayer/skill/read.d.ts +12 -2
- package/dist/accesslayer/skill/read.d.ts.map +1 -1
- package/dist/accesslayer/skill/update.d.ts +11 -1
- package/dist/accesslayer/skill/update.d.ts.map +1 -1
- package/dist/app.d.ts +4 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/cache/app-store.caches.d.ts +2 -1
- package/dist/cache/app-store.caches.d.ts.map +1 -1
- package/dist/config/environment.d.ts +268 -178
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/config/skillEmbedding.d.ts +70 -0
- package/dist/config/skillEmbedding.d.ts.map +1 -0
- package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
- package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
- package/dist/config/stageConfig.d.ts +20 -0
- package/dist/config/stageConfig.d.ts.map +1 -1
- package/dist/constants/campaigns.d.ts +3 -0
- package/dist/constants/campaigns.d.ts.map +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
- package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
- package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
- package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
- package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
- package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
- package/dist/helpers/boost.helpers.d.ts +1 -0
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
- package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
- package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
- package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Campaign.d.ts +10 -0
- package/dist/models/Campaign.d.ts.map +1 -0
- package/dist/models/CredentialSharePreview.d.ts +14 -0
- package/dist/models/CredentialSharePreview.d.ts.map +1 -0
- package/dist/models/Integration.d.ts +1 -1
- package/dist/models/Profile.d.ts.map +1 -1
- package/dist/models/campaign-constraints.d.ts +2 -0
- package/dist/models/campaign-constraints.d.ts.map +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/app-store.d.ts +1 -1
- package/dist/routes/boosts.d.ts +24 -4
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/campaigns.d.ts +1411 -0
- package/dist/routes/campaigns.d.ts.map +1 -0
- package/dist/routes/contracts.d.ts +19 -6
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-share-previews.d.ts +39 -0
- package/dist/routes/credential-share-previews.d.ts.map +1 -0
- package/dist/routes/credentials.d.ts +4 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +5 -5
- package/dist/routes/index.d.ts +4 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/integrations.d.ts +3 -3
- package/dist/routes/profile-manager.d.ts +4 -0
- package/dist/routes/profile-manager.d.ts.map +1 -1
- package/dist/routes/profiles.d.ts +44 -0
- package/dist/routes/profiles.d.ts.map +1 -1
- package/dist/routes/skill-frameworks.d.ts +1 -0
- package/dist/routes/skill-frameworks.d.ts.map +1 -1
- package/dist/routes/skills-semantic-search.test.d.ts +2 -0
- package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/test.d.ts +3 -1
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.d.ts +1 -0
- package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
- package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
- package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
- package/dist/types/app-manifest.d.ts +2 -2
- package/dist/types/campaign.d.ts +19 -0
- package/dist/types/campaign.d.ts.map +1 -0
- package/dist/types/consentflowcontract.d.ts +1 -1
- package/dist/types/integration.d.ts +3 -3
- package/dist/types/profile.d.ts +9 -0
- package/dist/types/profile.d.ts.map +1 -1
- package/dist/types/skill.d.ts.map +1 -1
- package/esbuildPlugins.cjs +9 -6
- package/package.json +20 -17
- package/project.json +14 -0
- package/scripts/skill-embeddings-backfill.ts +161 -0
- package/serverless-local.yml +4 -0
- package/serverless.function-env.cjs +3 -0
- package/serverless.local.yml +4 -0
- package/serverless.yml +12 -1
- package/src/accesslayer/campaign/README.md +174 -0
- package/src/accesslayer/campaign/audience.ts +152 -0
- package/src/accesslayer/campaign/create.ts +47 -0
- package/src/accesslayer/campaign/delete.ts +18 -0
- package/src/accesslayer/campaign/read.ts +125 -0
- package/src/accesslayer/campaign/shared.ts +92 -0
- package/src/accesslayer/campaign/state.ts +80 -0
- package/src/accesslayer/campaign/update.ts +50 -0
- package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
- package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
- package/src/accesslayer/credential-share-previews.ts +76 -0
- package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
- package/src/accesslayer/inbox-batch/schema.ts +54 -0
- package/src/accesslayer/inbox-batch/store.ts +2 -38
- package/src/accesslayer/profile/delete.ts +6 -0
- package/src/accesslayer/skill/read.ts +56 -16
- package/src/accesslayer/skill/update.ts +43 -1
- package/src/app.ts +9 -0
- package/src/config/environment.test.ts +208 -0
- package/src/config/environment.ts +166 -11
- package/src/config/skillEmbedding.neo4j.test.ts +138 -0
- package/src/config/skillEmbedding.ts +202 -0
- package/src/constants/campaigns.ts +9 -0
- package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
- package/src/helpers/bedrock-embedding.helpers.ts +243 -0
- package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
- package/src/helpers/boost-storage.helpers.test.ts +155 -1
- package/src/helpers/boost.helpers.ts +52 -10
- package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
- package/src/helpers/skill-embedding.helpers.ts +611 -123
- package/src/models/Campaign.ts +32 -0
- package/src/models/CredentialSharePreview.ts +30 -0
- package/src/models/Profile.ts +1 -0
- package/src/models/campaign-constraints.ts +22 -0
- package/src/models/index.ts +29 -1
- package/src/routes/campaigns.ts +179 -0
- package/src/routes/credential-share-previews.ts +74 -0
- package/src/routes/profiles.ts +7 -0
- package/src/routes/skills-semantic-search.test.ts +168 -0
- package/src/routes/skills.ts +42 -6
- package/src/routes/workflows.ts +8 -1
- package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
- package/src/seed/seedSkillFrameworks.test.ts +6 -0
- package/src/seed/seedSkillFrameworks.ts +83 -18
- package/src/types/campaign.ts +17 -0
- package/src/types/skill.ts +6 -0
- package/test/campaigns.spec.ts +789 -0
- package/test/consentflow.spec.ts +101 -0
- package/test/credential-share-previews.spec.ts +97 -0
- package/test/inbox-batch.spec.ts +189 -1
- package/test/verification/campaigns.ts +49 -0
- package/vitest.campaigns.config.ts +21 -0
- package/vitest.config.ts +11 -0
- package/vitest.integration.config.ts +1 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { BindParam, QueryBuilder } from 'neogma';
|
|
3
|
+
import { neogma } from '@instance';
|
|
4
|
+
import { CredentialSharePreview } from '@models';
|
|
5
|
+
import type {
|
|
6
|
+
CredentialSharePreview as Preview,
|
|
7
|
+
PublishCredentialSharePreview,
|
|
8
|
+
} from '@learncard/types';
|
|
9
|
+
|
|
10
|
+
// shareUri is a client-supplied match key, not proof of presentation ownership or issuer claims.
|
|
11
|
+
export const createCredentialSharePreview = async (
|
|
12
|
+
ownerProfileId: string,
|
|
13
|
+
input: PublishCredentialSharePreview
|
|
14
|
+
): Promise<string | null> => {
|
|
15
|
+
const id = input.id ?? randomUUID();
|
|
16
|
+
const now = new Date().toISOString();
|
|
17
|
+
const params = {
|
|
18
|
+
id,
|
|
19
|
+
ownerProfileId,
|
|
20
|
+
name: input.name,
|
|
21
|
+
issuer: input.issuer,
|
|
22
|
+
shareUri: input.shareUri,
|
|
23
|
+
image: input.image ?? null,
|
|
24
|
+
createdAt: now,
|
|
25
|
+
updatedAt: now,
|
|
26
|
+
};
|
|
27
|
+
const result = input.id
|
|
28
|
+
? await neogma.queryRunner.run(
|
|
29
|
+
`MATCH (owner:Profile {profileId: $ownerProfileId})-[:PUBLISHED_CREDENTIAL_SHARE_PREVIEW]->(preview:CredentialSharePreview {id: $id, shareUri: $shareUri})
|
|
30
|
+
SET preview.name = $name, preview.issuer = $issuer, preview.image = $image, preview.updatedAt = $updatedAt
|
|
31
|
+
RETURN preview.id AS id`,
|
|
32
|
+
params
|
|
33
|
+
)
|
|
34
|
+
: await neogma.queryRunner.run(
|
|
35
|
+
`MATCH (owner:Profile {profileId: $ownerProfileId})
|
|
36
|
+
CREATE (preview:CredentialSharePreview {
|
|
37
|
+
id: $id, name: $name, issuer: $issuer, shareUri: $shareUri,
|
|
38
|
+
image: $image, createdAt: $createdAt, updatedAt: $updatedAt
|
|
39
|
+
})
|
|
40
|
+
CREATE (owner)-[:PUBLISHED_CREDENTIAL_SHARE_PREVIEW]->(preview)
|
|
41
|
+
RETURN preview.id AS id`,
|
|
42
|
+
params
|
|
43
|
+
);
|
|
44
|
+
return result.records[0]?.get('id') ?? null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const getCredentialSharePreview = async (
|
|
48
|
+
id: string,
|
|
49
|
+
shareUri: string
|
|
50
|
+
): Promise<Preview | null> => {
|
|
51
|
+
const result = await new QueryBuilder(new BindParam({ id, shareUri }))
|
|
52
|
+
.match({ model: CredentialSharePreview, identifier: 'preview' })
|
|
53
|
+
.where('preview.id = $id AND preview.shareUri = $shareUri')
|
|
54
|
+
.return('preview')
|
|
55
|
+
.limit(1)
|
|
56
|
+
.run();
|
|
57
|
+
const properties = result.records[0]?.get('preview').properties;
|
|
58
|
+
if (!properties) return null;
|
|
59
|
+
return {
|
|
60
|
+
name: String(properties.name),
|
|
61
|
+
issuer: String(properties.issuer),
|
|
62
|
+
...(typeof properties.image === 'string' ? { image: properties.image } : {}),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const deleteCredentialSharePreview = async (
|
|
67
|
+
ownerProfileId: string,
|
|
68
|
+
id: string
|
|
69
|
+
): Promise<boolean> => {
|
|
70
|
+
const result = await neogma.queryRunner.run(
|
|
71
|
+
`MATCH (owner:Profile {profileId: $ownerProfileId})-[:PUBLISHED_CREDENTIAL_SHARE_PREVIEW]->(preview:CredentialSharePreview {id: $id})
|
|
72
|
+
DETACH DELETE preview RETURN count(*) AS deleted`,
|
|
73
|
+
{ ownerProfileId, id }
|
|
74
|
+
);
|
|
75
|
+
return Number(result.records[0]?.get('deleted') ?? 0) > 0;
|
|
76
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Driver, ManagedTransaction } from 'neo4j-driver';
|
|
3
|
+
|
|
4
|
+
vi.mock('@instance', () => ({ neogma: { driver: { session: vi.fn() } } }));
|
|
5
|
+
import { createInboxBatchSchemaInitializer } from './schema';
|
|
6
|
+
|
|
7
|
+
const harness = () => {
|
|
8
|
+
const run = vi.fn().mockResolvedValue({});
|
|
9
|
+
const close = vi.fn().mockResolvedValue(undefined);
|
|
10
|
+
const executeWrite = vi.fn(async (callback: (tx: ManagedTransaction) => unknown) =>
|
|
11
|
+
callback({ run } as unknown as ManagedTransaction)
|
|
12
|
+
);
|
|
13
|
+
const driver = { session: vi.fn(() => ({ run: vi.fn(), executeWrite, close })) };
|
|
14
|
+
const ensure = createInboxBatchSchemaInitializer(driver as unknown as Driver);
|
|
15
|
+
return { run, close, executeWrite, driver, ensure };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
beforeEach(() => vi.clearAllMocks());
|
|
19
|
+
|
|
20
|
+
describe('inbox batch schema readiness', () => {
|
|
21
|
+
it('shares in-flight readiness and runs every constraint AND index in its own managed write', async () => {
|
|
22
|
+
const { ensure, driver, executeWrite, run, close } = harness();
|
|
23
|
+
const first = ensure();
|
|
24
|
+
expect(ensure()).toBe(first);
|
|
25
|
+
await first;
|
|
26
|
+
await ensure();
|
|
27
|
+
expect(driver.session).toHaveBeenCalledTimes(1);
|
|
28
|
+
expect(executeWrite).toHaveBeenCalledTimes(10);
|
|
29
|
+
expect(run).toHaveBeenCalledTimes(10);
|
|
30
|
+
expect(
|
|
31
|
+
run.mock.calls.filter(([query]) => query.startsWith('CREATE CONSTRAINT'))
|
|
32
|
+
).toHaveLength(4);
|
|
33
|
+
expect(run.mock.calls.filter(([query]) => query.startsWith('CREATE INDEX'))).toHaveLength(
|
|
34
|
+
6
|
|
35
|
+
);
|
|
36
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('lets independent API/worker initializers complete through a transient managed-transaction retry', async () => {
|
|
40
|
+
const api = harness();
|
|
41
|
+
const worker = harness();
|
|
42
|
+
// Model the driver re-entering the transaction callback after a DDL deadlock.
|
|
43
|
+
// The real-driver concurrent cold-start regression lives in inbox-batch.spec.ts.
|
|
44
|
+
const deadlock = { code: 'Neo.TransientError.Transaction.DeadlockDetected' };
|
|
45
|
+
api.run.mockRejectedValueOnce(deadlock);
|
|
46
|
+
api.executeWrite.mockImplementationOnce(async callback => {
|
|
47
|
+
await expect(callback({ run: api.run } as unknown as ManagedTransaction)).rejects.toBe(
|
|
48
|
+
deadlock
|
|
49
|
+
);
|
|
50
|
+
return callback({ run: api.run } as unknown as ManagedTransaction);
|
|
51
|
+
});
|
|
52
|
+
await expect(Promise.all([api.ensure(), worker.ensure()])).resolves.toEqual([
|
|
53
|
+
undefined,
|
|
54
|
+
undefined,
|
|
55
|
+
]);
|
|
56
|
+
const firstQuery =
|
|
57
|
+
'CREATE CONSTRAINT inboxbatch_id IF NOT EXISTS FOR (n:InboxBatch) REQUIRE n.id IS UNIQUE';
|
|
58
|
+
expect(api.run.mock.calls.slice(0, 2)).toEqual([[firstQuery], [firstQuery]]);
|
|
59
|
+
expect(api.run).toHaveBeenCalledTimes(11);
|
|
60
|
+
expect(worker.run).toHaveBeenCalledTimes(10);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each([0, 4])('accepts equivalent-schema races for statement %i', async position => {
|
|
64
|
+
const { ensure, run } = harness();
|
|
65
|
+
for (let index = 0; index < position; index++) run.mockResolvedValueOnce({});
|
|
66
|
+
run.mockRejectedValueOnce({
|
|
67
|
+
code: 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists',
|
|
68
|
+
});
|
|
69
|
+
await expect(ensure()).resolves.toBeUndefined();
|
|
70
|
+
expect(run).toHaveBeenCalledTimes(10);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it.each([
|
|
74
|
+
'Neo.ClientError.Security.Forbidden',
|
|
75
|
+
'Neo.TransientError.Transaction.DeadlockDetected',
|
|
76
|
+
])(
|
|
77
|
+
'propagates %s after the managed write fails and allows a later readiness attempt',
|
|
78
|
+
async code => {
|
|
79
|
+
const { ensure, executeWrite, run, close, driver } = harness();
|
|
80
|
+
const failure = { code };
|
|
81
|
+
executeWrite.mockRejectedValueOnce(failure);
|
|
82
|
+
await expect(ensure()).rejects.toBe(failure);
|
|
83
|
+
expect(run).not.toHaveBeenCalled();
|
|
84
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
85
|
+
await expect(ensure()).resolves.toBeUndefined();
|
|
86
|
+
expect(driver.session).toHaveBeenCalledTimes(2);
|
|
87
|
+
expect(run).toHaveBeenCalledTimes(10);
|
|
88
|
+
expect(close).toHaveBeenCalledTimes(2);
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Driver } from 'neo4j-driver';
|
|
2
|
+
import { neogma } from '@instance';
|
|
3
|
+
|
|
4
|
+
const SCHEMA_QUERIES = [
|
|
5
|
+
...['InboxBatch', 'InboxBatchItem', 'InboxBatchIssuer', 'InboxBatchReplay'].map(
|
|
6
|
+
label =>
|
|
7
|
+
`CREATE CONSTRAINT ${label.toLowerCase()}_id IF NOT EXISTS FOR (n:${label}) REQUIRE n.id IS UNIQUE`
|
|
8
|
+
),
|
|
9
|
+
'CREATE INDEX inbox_batch_request IF NOT EXISTS FOR (b:InboxBatch) ON (b.issuer, b.requestId)',
|
|
10
|
+
'CREATE INDEX inbox_batch_dispatch IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.dispatchAt)',
|
|
11
|
+
'CREATE INDEX inbox_batch_recovery IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.leaseUntil)',
|
|
12
|
+
'CREATE INDEX inbox_batch_replay_owner IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.itemId)',
|
|
13
|
+
'CREATE INDEX inbox_batch_completed IF NOT EXISTS FOR (b:InboxBatch) ON (b.completedAt)',
|
|
14
|
+
'CREATE INDEX inbox_batch_replay_expiry IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.expiresAt)',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** Each API/worker process shares readiness locally; the driver retries cross-process DDL deadlocks. */
|
|
18
|
+
export const createInboxBatchSchemaInitializer = (
|
|
19
|
+
driver: Pick<Driver, 'session'>
|
|
20
|
+
): (() => Promise<void>) => {
|
|
21
|
+
let readiness: Promise<void> | undefined;
|
|
22
|
+
return (): Promise<void> => {
|
|
23
|
+
if (!readiness) {
|
|
24
|
+
const pending = (async (): Promise<void> => {
|
|
25
|
+
const session = driver.session();
|
|
26
|
+
try {
|
|
27
|
+
for (const query of SCHEMA_QUERIES) {
|
|
28
|
+
try {
|
|
29
|
+
// One schema statement per managed transaction. IF NOT EXISTS is
|
|
30
|
+
// idempotent, but competing cold processes can still deadlock while
|
|
31
|
+
// acquiring label locks. Auto-commit queryRunner.run does not retry.
|
|
32
|
+
await session.executeWrite(tx => tx.run(query));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (
|
|
35
|
+
(error as { code?: string } | null)?.code !==
|
|
36
|
+
'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
|
|
37
|
+
)
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} finally {
|
|
42
|
+
await session.close();
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
readiness = pending;
|
|
46
|
+
void pending.catch(() => {
|
|
47
|
+
if (readiness === pending) readiness = undefined;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return readiness;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const ensureInboxBatchConstraints = createInboxBatchSchemaInitializer(neogma.driver);
|
|
@@ -6,49 +6,13 @@ import { neogma } from '@instance';
|
|
|
6
6
|
import { encryptInboxCredential, decryptInboxCredential } from '@helpers/inbox-encryption.helpers';
|
|
7
7
|
import type { BatchItem, BatchJob, BatchReplayStore, InboxDispatchLease } from 'types/inbox-batch';
|
|
8
8
|
import { getInboxBatchState } from '@helpers/inbox-batch-status.helpers';
|
|
9
|
+
import { ensureInboxBatchConstraints } from './schema';
|
|
9
10
|
|
|
10
11
|
export const DAY = 86_400_000;
|
|
11
12
|
export const LEASE_MS = 360_000;
|
|
12
13
|
const CLEANUP_MARGIN_MS = 5_000;
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
export const ensureInboxBatchConstraints = (): Promise<void> => {
|
|
16
|
-
if (!readiness) {
|
|
17
|
-
readiness = (async () => {
|
|
18
|
-
for (const label of [
|
|
19
|
-
'InboxBatch',
|
|
20
|
-
'InboxBatchItem',
|
|
21
|
-
'InboxBatchIssuer',
|
|
22
|
-
'InboxBatchReplay',
|
|
23
|
-
]) {
|
|
24
|
-
try {
|
|
25
|
-
await neogma.queryRunner.run(
|
|
26
|
-
`CREATE CONSTRAINT ${label.toLowerCase()}_id IF NOT EXISTS FOR (n:${label}) REQUIRE n.id IS UNIQUE`
|
|
27
|
-
);
|
|
28
|
-
} catch (error) {
|
|
29
|
-
if (
|
|
30
|
-
(error as { code?: string }).code !==
|
|
31
|
-
'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
|
|
32
|
-
)
|
|
33
|
-
throw error;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
for (const query of [
|
|
37
|
-
'CREATE INDEX inbox_batch_request IF NOT EXISTS FOR (b:InboxBatch) ON (b.issuer, b.requestId)',
|
|
38
|
-
'CREATE INDEX inbox_batch_dispatch IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.dispatchAt)',
|
|
39
|
-
'CREATE INDEX inbox_batch_recovery IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.leaseUntil)',
|
|
40
|
-
'CREATE INDEX inbox_batch_replay_owner IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.itemId)',
|
|
41
|
-
'CREATE INDEX inbox_batch_completed IF NOT EXISTS FOR (b:InboxBatch) ON (b.completedAt)',
|
|
42
|
-
'CREATE INDEX inbox_batch_replay_expiry IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.expiresAt)',
|
|
43
|
-
])
|
|
44
|
-
await neogma.queryRunner.run(query);
|
|
45
|
-
})();
|
|
46
|
-
void readiness.catch(() => {
|
|
47
|
-
readiness = undefined;
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
return readiness;
|
|
51
|
-
};
|
|
15
|
+
export { ensureInboxBatchConstraints } from './schema';
|
|
52
16
|
|
|
53
17
|
const transaction = async <T>(fn: (tx: ManagedTransaction) => Promise<T>): Promise<T> => {
|
|
54
18
|
await ensureInboxBatchConstraints();
|
|
@@ -27,5 +27,11 @@ export const deleteProfile = async (profile: ProfileType): Promise<void> => {
|
|
|
27
27
|
{ profileId: profile.profileId }
|
|
28
28
|
);
|
|
29
29
|
|
|
30
|
+
// Delete share previews owned by this profile to prevent orphaned nodes.
|
|
31
|
+
await neogma.queryRunner.run(
|
|
32
|
+
`MATCH (p:Profile {profileId: $profileId})-[:PUBLISHED_CREDENTIAL_SHARE_PREVIEW]->(preview:CredentialSharePreview)
|
|
33
|
+
DETACH DELETE preview`,
|
|
34
|
+
{ profileId: profile.profileId }
|
|
35
|
+
);
|
|
30
36
|
await Profile.delete({ detach: true, where: { profileId: profile.profileId } });
|
|
31
37
|
};
|
|
@@ -3,6 +3,10 @@ import { int } from 'neo4j-driver';
|
|
|
3
3
|
import { convertObjectRegExpToNeo4j, buildWhereClause } from '@helpers/neo4j.helpers';
|
|
4
4
|
import type { FlatSkillType, SkillType } from 'types/skill';
|
|
5
5
|
import type { SkillQuery } from '@learncard/types';
|
|
6
|
+
import type {
|
|
7
|
+
SkillEmbeddingGeneration,
|
|
8
|
+
SkillEmbeddingSearchIndex,
|
|
9
|
+
} from '../../config/skillEmbedding';
|
|
6
10
|
|
|
7
11
|
export const doesProfileManageSkill = async (
|
|
8
12
|
profileId: string,
|
|
@@ -448,21 +452,36 @@ export type SkillEmbeddingSearchResult = {
|
|
|
448
452
|
score: number;
|
|
449
453
|
};
|
|
450
454
|
|
|
455
|
+
/** Drop stored vectors (current and legacy generations) from node properties returned to callers. */
|
|
456
|
+
const withoutEmbeddingProperties = (props: Record<string, unknown>): Record<string, unknown> =>
|
|
457
|
+
Object.fromEntries(Object.entries(props).filter(([key]) => !key.startsWith('embedding')));
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* LC-2206: query one vector index (the Nova generation's, or the legacy Google one while
|
|
461
|
+
* SKILL_EMBEDDING_BEDROCK_READ_ENABLED is off). Vectors from other models or dimensions live
|
|
462
|
+
* in other properties and indexes, so one query never mixes embedding spaces.
|
|
463
|
+
*/
|
|
451
464
|
export const searchSkillsByEmbedding = async (
|
|
452
465
|
embedding: number[],
|
|
453
466
|
limit: number,
|
|
454
|
-
frameworkId
|
|
467
|
+
frameworkId: string | undefined,
|
|
468
|
+
index: Pick<SkillEmbeddingSearchIndex, 'indexName' | 'dimension'>
|
|
455
469
|
): Promise<SkillEmbeddingSearchResult[]> => {
|
|
470
|
+
if (embedding.length !== index.dimension) {
|
|
471
|
+
throw new Error('Skill search embedding does not match the index dimension');
|
|
472
|
+
}
|
|
473
|
+
|
|
456
474
|
const normalizedLimit = Math.min(Math.max(limit, 1), 200);
|
|
457
475
|
const queryLimit = frameworkId ? Math.min(normalizedLimit * 3, 200) : normalizedLimit;
|
|
458
476
|
|
|
459
477
|
const result = await neogma.queryRunner.run(
|
|
460
|
-
`CALL db.index.vector.queryNodes(
|
|
478
|
+
`CALL db.index.vector.queryNodes($indexName, $limit, $embedding)
|
|
461
479
|
YIELD node, score
|
|
462
480
|
WHERE $frameworkId IS NULL OR node.frameworkId = $frameworkId
|
|
463
481
|
RETURN node AS s, score AS score
|
|
464
482
|
ORDER BY score DESC`,
|
|
465
483
|
{
|
|
484
|
+
indexName: index.indexName,
|
|
466
485
|
limit: int(queryLimit),
|
|
467
486
|
embedding,
|
|
468
487
|
frameworkId: frameworkId ?? null,
|
|
@@ -471,7 +490,7 @@ export const searchSkillsByEmbedding = async (
|
|
|
471
490
|
|
|
472
491
|
const rows = result.records.map(record => {
|
|
473
492
|
const node = record.get('s') as { properties?: Record<string, unknown> } | undefined;
|
|
474
|
-
const props = (node?.properties ?? {})
|
|
493
|
+
const props = withoutEmbeddingProperties(node?.properties ?? {});
|
|
475
494
|
const skill = {
|
|
476
495
|
...props,
|
|
477
496
|
type: (props.type as string | undefined) ?? 'skill',
|
|
@@ -486,30 +505,51 @@ export const searchSkillsByEmbedding = async (
|
|
|
486
505
|
return rows.filter(row => row.skill.frameworkId === frameworkId).slice(0, normalizedLimit);
|
|
487
506
|
};
|
|
488
507
|
|
|
489
|
-
export
|
|
508
|
+
export type SkillEmbeddingCandidate = {
|
|
509
|
+
id: string;
|
|
510
|
+
frameworkId?: string;
|
|
511
|
+
statement: string;
|
|
512
|
+
description?: string | null;
|
|
513
|
+
code?: string | null;
|
|
514
|
+
/** sha256(identity + text) stored with the current generation's vector, if any. */
|
|
515
|
+
embeddingKey: string | null;
|
|
516
|
+
hasEmbedding: boolean;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Page through every skill (ordered by id) with its current-generation embedding key, without
|
|
521
|
+
* loading any vectors. The caller compares keys to decide which skills need (re-)embedding,
|
|
522
|
+
* which makes the backfill idempotent and resumable from any cursor.
|
|
523
|
+
*/
|
|
524
|
+
export const getSkillEmbeddingCandidates = async (
|
|
525
|
+
generation: SkillEmbeddingGeneration,
|
|
490
526
|
limit: number,
|
|
491
527
|
cursorId?: string | null
|
|
492
|
-
): Promise<Paginated<
|
|
528
|
+
): Promise<Paginated<SkillEmbeddingCandidate>> => {
|
|
493
529
|
const normalizedLimit = Math.min(Math.max(limit, 1), 500);
|
|
494
530
|
|
|
531
|
+
// Property names come from resolveSkillEmbeddingGeneration ([a-z0-9_] only).
|
|
495
532
|
const result = await neogma.queryRunner.run(
|
|
496
533
|
`MATCH (s:Skill)
|
|
497
|
-
WHERE s.
|
|
498
|
-
|
|
499
|
-
|
|
534
|
+
WHERE $cursorId IS NULL OR s.id > $cursorId
|
|
535
|
+
RETURN s.id AS id, s.frameworkId AS frameworkId, s.statement AS statement,
|
|
536
|
+
s.description AS description, s.code AS code,
|
|
537
|
+
s.${generation.keyProperty} AS embeddingKey,
|
|
538
|
+
s.${generation.vectorProperty} IS NOT NULL AS hasEmbedding
|
|
500
539
|
ORDER BY s.id
|
|
501
540
|
LIMIT $limitPlusOne`,
|
|
502
541
|
{ cursorId: cursorId ?? null, limitPlusOne: int(normalizedLimit + 1) }
|
|
503
542
|
);
|
|
504
543
|
|
|
505
|
-
const all = result.records.map(record => {
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
544
|
+
const all = result.records.map(record => ({
|
|
545
|
+
id: String(record.get('id')),
|
|
546
|
+
frameworkId: (record.get('frameworkId') as string | null) ?? undefined,
|
|
547
|
+
statement: String(record.get('statement') ?? ''),
|
|
548
|
+
description: record.get('description') as string | null,
|
|
549
|
+
code: record.get('code') as string | null,
|
|
550
|
+
embeddingKey: (record.get('embeddingKey') as string | null) ?? null,
|
|
551
|
+
hasEmbedding: Boolean(record.get('hasEmbedding')),
|
|
552
|
+
}));
|
|
513
553
|
|
|
514
554
|
const hasMore = all.length > normalizedLimit;
|
|
515
555
|
const page = all.slice(0, normalizedLimit);
|
|
@@ -2,6 +2,13 @@ import { TRPCError } from '@trpc/server';
|
|
|
2
2
|
|
|
3
3
|
import { neogma } from '@instance';
|
|
4
4
|
import type { FlatSkillType } from 'types/skill';
|
|
5
|
+
import {
|
|
6
|
+
LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION,
|
|
7
|
+
buildSkillEmbeddingUpdateParams,
|
|
8
|
+
buildSkillEmbeddingUpdateQuery,
|
|
9
|
+
type SkillEmbeddingGeneration,
|
|
10
|
+
type SkillEmbeddingSource,
|
|
11
|
+
} from '../../config/skillEmbedding';
|
|
5
12
|
|
|
6
13
|
export type UpdateSkillInput = Partial<
|
|
7
14
|
Omit<FlatSkillType, 'id' | 'frameworkId' | 'createdAt' | 'updatedAt' | 'parentId'>
|
|
@@ -154,11 +161,46 @@ export const deleteSkill = async (
|
|
|
154
161
|
}
|
|
155
162
|
};
|
|
156
163
|
|
|
157
|
-
export
|
|
164
|
+
export type SkillEmbeddingUpdate = {
|
|
165
|
+
skillId: string;
|
|
166
|
+
frameworkId?: string;
|
|
167
|
+
embedding: number[];
|
|
168
|
+
/** sha256(identity + text); lets the backfill skip skills already embedded with this generation. */
|
|
169
|
+
embeddingKey: string;
|
|
170
|
+
generation: SkillEmbeddingGeneration;
|
|
171
|
+
/** Statement/description the vector was computed from; the write is skipped if they changed. */
|
|
172
|
+
source: SkillEmbeddingSource;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Returns false when the skill is gone or its source changed since it was embedded. */
|
|
176
|
+
export const updateSkillEmbedding = async (update: SkillEmbeddingUpdate): Promise<boolean> => {
|
|
177
|
+
const { frameworkId, embedding, generation } = update;
|
|
178
|
+
|
|
179
|
+
if (embedding.length !== generation.dimension) {
|
|
180
|
+
throw new Error('Skill embedding does not match the configured dimension');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const result = await neogma.queryRunner.run(
|
|
184
|
+
buildSkillEmbeddingUpdateQuery(generation, Boolean(frameworkId)),
|
|
185
|
+
buildSkillEmbeddingUpdateParams(update)
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return Number(result.records[0]?.get('count') ?? 0) > 0;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* LC-2206 legacy write: the pre-Nova `s.embedding` (Google gemini-embedding-001, 768-d) exactly
|
|
193
|
+
* as main writes it, so the legacy index stays current while semantic search still reads it.
|
|
194
|
+
*/
|
|
195
|
+
export const updateLegacySkillEmbedding = async (
|
|
158
196
|
skillId: string,
|
|
159
197
|
embedding: number[],
|
|
160
198
|
frameworkId?: string
|
|
161
199
|
): Promise<boolean> => {
|
|
200
|
+
if (embedding.length !== LEGACY_GOOGLE_SKILL_EMBEDDING_DIMENSION) {
|
|
201
|
+
throw new Error('Legacy skill embedding does not match the legacy dimension');
|
|
202
|
+
}
|
|
203
|
+
|
|
162
204
|
if (frameworkId) {
|
|
163
205
|
const result = await neogma.queryRunner.run(
|
|
164
206
|
`MATCH (f:SkillFramework {id: $frameworkId})-[:CONTAINS]->(s:Skill {id: $skillId})
|
package/src/app.ts
CHANGED
|
@@ -19,10 +19,15 @@ import { skillFrameworksRouter, SkillFrameworksRouter } from '@routes/skill-fram
|
|
|
19
19
|
import { skillsRouter, SkillsRouter } from '@routes/skills';
|
|
20
20
|
import { integrationsRouter, IntegrationsRouter } from '@routes/integrations';
|
|
21
21
|
import { appStoreRouter, AppStoreRouter } from '@routes/app-store';
|
|
22
|
+
import { campaignsRouter, CampaignsRouter } from '@routes/campaigns';
|
|
22
23
|
import { activityRouter, ActivityRouter } from '@routes/activity';
|
|
23
24
|
import { federationRouter, FederationRouter } from '@routes/federation';
|
|
24
25
|
import { shareLinksRouter, ShareLinksRouter } from '@routes/share-links';
|
|
25
26
|
import { publicShareLinksRouter, PublicShareLinksRouter } from '@routes/public-share-links';
|
|
27
|
+
import {
|
|
28
|
+
credentialSharePreviewsRouter,
|
|
29
|
+
CredentialSharePreviewsRouter,
|
|
30
|
+
} from '@routes/credential-share-previews';
|
|
26
31
|
|
|
27
32
|
/** For end-to-end testing, only available in test environment */
|
|
28
33
|
import { testRouter, TestRouter } from '@routes/test';
|
|
@@ -52,10 +57,12 @@ export const appRouter = t.router<{
|
|
|
52
57
|
skills: SkillsRouter;
|
|
53
58
|
integrations: IntegrationsRouter;
|
|
54
59
|
appStore: AppStoreRouter;
|
|
60
|
+
campaigns: CampaignsRouter;
|
|
55
61
|
activity: ActivityRouter;
|
|
56
62
|
federation: FederationRouter;
|
|
57
63
|
shareLinks: ShareLinksRouter;
|
|
58
64
|
publicShareLinks: PublicShareLinksRouter;
|
|
65
|
+
credentialSharePreviews: CredentialSharePreviewsRouter;
|
|
59
66
|
test?: TestRouter;
|
|
60
67
|
bench?: BenchRouter;
|
|
61
68
|
}>({
|
|
@@ -78,10 +85,12 @@ export const appRouter = t.router<{
|
|
|
78
85
|
skills: skillsRouter,
|
|
79
86
|
integrations: integrationsRouter,
|
|
80
87
|
appStore: appStoreRouter,
|
|
88
|
+
campaigns: campaignsRouter,
|
|
81
89
|
activity: activityRouter,
|
|
82
90
|
federation: federationRouter,
|
|
83
91
|
shareLinks: shareLinksRouter,
|
|
84
92
|
publicShareLinks: publicShareLinksRouter,
|
|
93
|
+
credentialSharePreviews: credentialSharePreviewsRouter,
|
|
85
94
|
test: environment.IS_E2E_TEST ? testRouter : undefined,
|
|
86
95
|
bench: environment.ENABLE_BENCH_ROUTES ? benchRouter : undefined,
|
|
87
96
|
});
|