@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.
Files changed (176) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +76 -0
  3. package/config/config.dev.json +5 -0
  4. package/config/config.example.json +5 -0
  5. package/config/config.production.json +5 -0
  6. package/config/config.scouts.dev.json +5 -0
  7. package/config/config.scouts.production.json +5 -0
  8. package/dist/accesslayer/campaign/audience.d.ts +7 -0
  9. package/dist/accesslayer/campaign/audience.d.ts.map +1 -0
  10. package/dist/accesslayer/campaign/create.d.ts +3 -0
  11. package/dist/accesslayer/campaign/create.d.ts.map +1 -0
  12. package/dist/accesslayer/campaign/delete.d.ts +2 -0
  13. package/dist/accesslayer/campaign/delete.d.ts.map +1 -0
  14. package/dist/accesslayer/campaign/read.d.ts +6 -0
  15. package/dist/accesslayer/campaign/read.d.ts.map +1 -0
  16. package/dist/accesslayer/campaign/shared.d.ts +16 -0
  17. package/dist/accesslayer/campaign/shared.d.ts.map +1 -0
  18. package/dist/accesslayer/campaign/state.d.ts +4 -0
  19. package/dist/accesslayer/campaign/state.d.ts.map +1 -0
  20. package/dist/accesslayer/campaign/update.d.ts +3 -0
  21. package/dist/accesslayer/campaign/update.d.ts.map +1 -0
  22. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  23. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  24. package/dist/accesslayer/credential-share-previews.d.ts +5 -0
  25. package/dist/accesslayer/credential-share-previews.d.ts.map +1 -0
  26. package/dist/accesslayer/inbox-batch/schema.d.ts +4 -0
  27. package/dist/accesslayer/inbox-batch/schema.d.ts.map +1 -0
  28. package/dist/accesslayer/inbox-batch/schema.test.d.ts +2 -0
  29. package/dist/accesslayer/inbox-batch/schema.test.d.ts.map +1 -0
  30. package/dist/accesslayer/inbox-batch/store.d.ts +1 -1
  31. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -1
  32. package/dist/accesslayer/profile/delete.d.ts.map +1 -1
  33. package/dist/accesslayer/skill/read.d.ts +12 -2
  34. package/dist/accesslayer/skill/read.d.ts.map +1 -1
  35. package/dist/accesslayer/skill/update.d.ts +11 -1
  36. package/dist/accesslayer/skill/update.d.ts.map +1 -1
  37. package/dist/app.d.ts +4 -0
  38. package/dist/app.d.ts.map +1 -1
  39. package/dist/cache/app-store.caches.d.ts +2 -1
  40. package/dist/cache/app-store.caches.d.ts.map +1 -1
  41. package/dist/config/environment.d.ts +268 -178
  42. package/dist/config/environment.d.ts.map +1 -1
  43. package/dist/config/skillEmbedding.d.ts +70 -0
  44. package/dist/config/skillEmbedding.d.ts.map +1 -0
  45. package/dist/config/skillEmbedding.neo4j.test.d.ts +2 -0
  46. package/dist/config/skillEmbedding.neo4j.test.d.ts.map +1 -0
  47. package/dist/config/stageConfig.d.ts +20 -0
  48. package/dist/config/stageConfig.d.ts.map +1 -1
  49. package/dist/constants/campaigns.d.ts +3 -0
  50. package/dist/constants/campaigns.d.ts.map +1 -0
  51. package/dist/didkit_wasm_bg.wasm +0 -0
  52. package/dist/helpers/bedrock-embedding.helpers.d.ts +40 -0
  53. package/dist/helpers/bedrock-embedding.helpers.d.ts.map +1 -0
  54. package/dist/helpers/bedrock-embedding.helpers.test.d.ts +2 -0
  55. package/dist/helpers/bedrock-embedding.helpers.test.d.ts.map +1 -0
  56. package/dist/helpers/bedrock-embedding.smoke.test.d.ts +2 -0
  57. package/dist/helpers/bedrock-embedding.smoke.test.d.ts.map +1 -0
  58. package/dist/helpers/boost.helpers.d.ts +1 -0
  59. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  60. package/dist/helpers/skill-embedding.helpers.d.ts +65 -7
  61. package/dist/helpers/skill-embedding.helpers.d.ts.map +1 -1
  62. package/dist/helpers/skill-embedding.helpers.test.d.ts +2 -0
  63. package/dist/helpers/skill-embedding.helpers.test.d.ts.map +1 -0
  64. package/dist/index.js +2 -2
  65. package/dist/models/Campaign.d.ts +10 -0
  66. package/dist/models/Campaign.d.ts.map +1 -0
  67. package/dist/models/CredentialSharePreview.d.ts +14 -0
  68. package/dist/models/CredentialSharePreview.d.ts.map +1 -0
  69. package/dist/models/Integration.d.ts +1 -1
  70. package/dist/models/Profile.d.ts.map +1 -1
  71. package/dist/models/campaign-constraints.d.ts +2 -0
  72. package/dist/models/campaign-constraints.d.ts.map +1 -0
  73. package/dist/models/index.d.ts +2 -0
  74. package/dist/models/index.d.ts.map +1 -1
  75. package/dist/publish-metafile.json +1 -1
  76. package/dist/routes/app-store.d.ts +1 -1
  77. package/dist/routes/boosts.d.ts +24 -4
  78. package/dist/routes/boosts.d.ts.map +1 -1
  79. package/dist/routes/campaigns.d.ts +1411 -0
  80. package/dist/routes/campaigns.d.ts.map +1 -0
  81. package/dist/routes/contracts.d.ts +19 -6
  82. package/dist/routes/contracts.d.ts.map +1 -1
  83. package/dist/routes/credential-share-previews.d.ts +39 -0
  84. package/dist/routes/credential-share-previews.d.ts.map +1 -0
  85. package/dist/routes/credentials.d.ts +4 -1
  86. package/dist/routes/credentials.d.ts.map +1 -1
  87. package/dist/routes/inbox.d.ts +5 -5
  88. package/dist/routes/index.d.ts +4 -0
  89. package/dist/routes/index.d.ts.map +1 -1
  90. package/dist/routes/integrations.d.ts +3 -3
  91. package/dist/routes/profile-manager.d.ts +4 -0
  92. package/dist/routes/profile-manager.d.ts.map +1 -1
  93. package/dist/routes/profiles.d.ts +44 -0
  94. package/dist/routes/profiles.d.ts.map +1 -1
  95. package/dist/routes/skill-frameworks.d.ts +1 -0
  96. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  97. package/dist/routes/skills-semantic-search.test.d.ts +2 -0
  98. package/dist/routes/skills-semantic-search.test.d.ts.map +1 -0
  99. package/dist/routes/skills.d.ts.map +1 -1
  100. package/dist/routes/test.d.ts +3 -1
  101. package/dist/routes/test.d.ts.map +1 -1
  102. package/dist/routes/workflows.d.ts.map +1 -1
  103. package/dist/seed/seedSkillFrameworks.d.ts +1 -0
  104. package/dist/seed/seedSkillFrameworks.d.ts.map +1 -1
  105. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts +2 -0
  106. package/dist/seed/seedSkillFrameworks.embeddings.test.d.ts.map +1 -0
  107. package/dist/types/app-manifest.d.ts +2 -2
  108. package/dist/types/campaign.d.ts +19 -0
  109. package/dist/types/campaign.d.ts.map +1 -0
  110. package/dist/types/consentflowcontract.d.ts +1 -1
  111. package/dist/types/integration.d.ts +3 -3
  112. package/dist/types/profile.d.ts +9 -0
  113. package/dist/types/profile.d.ts.map +1 -1
  114. package/dist/types/skill.d.ts.map +1 -1
  115. package/esbuildPlugins.cjs +9 -6
  116. package/package.json +20 -17
  117. package/project.json +14 -0
  118. package/scripts/skill-embeddings-backfill.ts +161 -0
  119. package/serverless-local.yml +4 -0
  120. package/serverless.function-env.cjs +3 -0
  121. package/serverless.local.yml +4 -0
  122. package/serverless.yml +12 -1
  123. package/src/accesslayer/campaign/README.md +174 -0
  124. package/src/accesslayer/campaign/audience.ts +152 -0
  125. package/src/accesslayer/campaign/create.ts +47 -0
  126. package/src/accesslayer/campaign/delete.ts +18 -0
  127. package/src/accesslayer/campaign/read.ts +125 -0
  128. package/src/accesslayer/campaign/shared.ts +92 -0
  129. package/src/accesslayer/campaign/state.ts +80 -0
  130. package/src/accesslayer/campaign/update.ts +50 -0
  131. package/src/accesslayer/consentflowcontract/relationships/create.ts +3 -1
  132. package/src/accesslayer/consentflowcontract/relationships/update.ts +5 -1
  133. package/src/accesslayer/credential-share-previews.ts +76 -0
  134. package/src/accesslayer/inbox-batch/schema.test.ts +91 -0
  135. package/src/accesslayer/inbox-batch/schema.ts +54 -0
  136. package/src/accesslayer/inbox-batch/store.ts +2 -38
  137. package/src/accesslayer/profile/delete.ts +6 -0
  138. package/src/accesslayer/skill/read.ts +56 -16
  139. package/src/accesslayer/skill/update.ts +43 -1
  140. package/src/app.ts +9 -0
  141. package/src/config/environment.test.ts +208 -0
  142. package/src/config/environment.ts +166 -11
  143. package/src/config/skillEmbedding.neo4j.test.ts +138 -0
  144. package/src/config/skillEmbedding.ts +202 -0
  145. package/src/constants/campaigns.ts +9 -0
  146. package/src/helpers/bedrock-embedding.helpers.test.ts +208 -0
  147. package/src/helpers/bedrock-embedding.helpers.ts +243 -0
  148. package/src/helpers/bedrock-embedding.smoke.test.ts +53 -0
  149. package/src/helpers/boost-storage.helpers.test.ts +155 -1
  150. package/src/helpers/boost.helpers.ts +52 -10
  151. package/src/helpers/skill-embedding.helpers.test.ts +1044 -0
  152. package/src/helpers/skill-embedding.helpers.ts +611 -123
  153. package/src/models/Campaign.ts +32 -0
  154. package/src/models/CredentialSharePreview.ts +30 -0
  155. package/src/models/Profile.ts +1 -0
  156. package/src/models/campaign-constraints.ts +22 -0
  157. package/src/models/index.ts +29 -1
  158. package/src/routes/campaigns.ts +179 -0
  159. package/src/routes/credential-share-previews.ts +74 -0
  160. package/src/routes/profiles.ts +7 -0
  161. package/src/routes/skills-semantic-search.test.ts +168 -0
  162. package/src/routes/skills.ts +42 -6
  163. package/src/routes/workflows.ts +8 -1
  164. package/src/seed/seedSkillFrameworks.embeddings.test.ts +82 -0
  165. package/src/seed/seedSkillFrameworks.test.ts +6 -0
  166. package/src/seed/seedSkillFrameworks.ts +83 -18
  167. package/src/types/campaign.ts +17 -0
  168. package/src/types/skill.ts +6 -0
  169. package/test/campaigns.spec.ts +789 -0
  170. package/test/consentflow.spec.ts +101 -0
  171. package/test/credential-share-previews.spec.ts +97 -0
  172. package/test/inbox-batch.spec.ts +189 -1
  173. package/test/verification/campaigns.ts +49 -0
  174. package/vitest.campaigns.config.ts +21 -0
  175. package/vitest.config.ts +11 -0
  176. 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
- let readiness: Promise<void> | undefined;
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?: string
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('skill_embedding_vector_idx', $limit, $embedding)
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 ?? {}) as Record<string, unknown>;
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 const getSkillsMissingEmbedding = async (
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<FlatSkillType>> => {
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.embedding IS NULL
498
- AND ($cursorId IS NULL OR s.id > $cursorId)
499
- RETURN s AS s
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
- const node = record.get('s') as { properties?: Record<string, unknown> } | undefined;
507
- const props = (node?.properties ?? {}) as Record<string, unknown>;
508
- return {
509
- ...props,
510
- type: (props.type as string | undefined) ?? 'skill',
511
- } as FlatSkillType;
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 const updateSkillEmbedding = async (
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
  });