Skip to content

Commit a34ee4f

Browse files
committed
feat: migrate neo4j
1 parent 078e5e3 commit a34ee4f

File tree

8 files changed

+1406
-75
lines changed

8 files changed

+1406
-75
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
version: '3.8'
2+
3+
services:
4+
neo4j:
5+
image: neo4j:5.15
6+
container_name: neo4j
7+
ports:
8+
- "7474:7474"
9+
- "7687:7687"
10+
environment:
11+
- NEO4J_AUTH=neo4j/testpass
12+
volumes:
13+
- neo4j_data:/data
14+
networks:
15+
- graphnet
16+
17+
volumes:
18+
neo4j_data:
19+
20+
networks:
21+
graphnet:
22+
driver: bridge
Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
{
2-
"name": "evault-core",
3-
"version": "1.0.0",
4-
"description": "",
5-
"main": "index.js",
6-
"scripts": {
7-
"test": "echo \"Error: no test specified\" && exit 1"
8-
},
9-
"keywords": [],
10-
"author": "",
11-
"license": "ISC"
2+
"name": "evault-core",
3+
"version": "0.1.0",
4+
"description": "",
5+
"main": "index.js",
6+
"scripts": {
7+
"test": "vitest --config vitest.config.ts"
8+
},
9+
"keywords": [],
10+
"author": "",
11+
"license": "ISC",
12+
"devDependencies": {
13+
"@types/node": "^22.13.10",
14+
"dotenv": "^16.5.0",
15+
"testcontainers": "^10.24.2",
16+
"typescript": "^5.8.3",
17+
"uuid": "^11.1.0",
18+
"vitest": "^3.0.9"
19+
},
20+
"dependencies": {
21+
"@testcontainers/neo4j": "^10.24.2",
22+
"neo4j-driver": "^5.28.1",
23+
"w3id": "workspace:*"
24+
}
1225
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import neo4j, { Driver } from "neo4j-driver";
2+
import { DbService } from "./db.service"; // adjust if needed
3+
import { it, describe, beforeAll, afterAll, expect } from "vitest";
4+
import { v4 as uuidv4 } from "uuid";
5+
import { Neo4jContainer, StartedNeo4jContainer } from "@testcontainers/neo4j";
6+
7+
describe("DbService (integration)", () => {
8+
let container: StartedNeo4jContainer;
9+
let service: DbService;
10+
let driver: Driver;
11+
12+
beforeAll(async () => {
13+
container = await new Neo4jContainer("neo4j:4.4.12").start();
14+
15+
const username = container.getUsername();
16+
const password = container.getPassword();
17+
const boltPort = container.getMappedPort(7687);
18+
const uri = `bolt://localhost:${boltPort}`;
19+
20+
driver = neo4j.driver(uri, neo4j.auth.basic(username, password));
21+
service = new DbService(uri, username, password);
22+
});
23+
24+
afterAll(async () => {
25+
await service.close();
26+
await driver.close();
27+
await container.stop();
28+
});
29+
30+
it("should store and retrieve a meta-envelope", async () => {
31+
const testId = uuidv4();
32+
const input = {
33+
id: testId,
34+
ontology: "SocialMediaPost",
35+
payload: {
36+
text: "hello world",
37+
dateCreated: "2025-04-10",
38+
likes: ["user1", "user2"],
39+
},
40+
};
41+
42+
const result = await service.storeMetaEnvelope(input, ["@test-user"]);
43+
const id = result.metaEnvelope.id;
44+
45+
const fetched = await service.findMetaEnvelopeById(id);
46+
expect(fetched).toBeDefined();
47+
expect(fetched.id).toBeDefined();
48+
expect(fetched.ontology).toBe("SocialMediaPost");
49+
expect(fetched.envelopes).toHaveLength(3);
50+
});
51+
52+
it("should find meta-envelopes containing the search term in any envelope value", async () => {
53+
const metaId = uuidv4();
54+
const input = {
55+
id: metaId,
56+
ontology: "SocialMediaPost",
57+
payload: {
58+
text: "This is a searchable tweet",
59+
image: "https://example.com/image.jpg",
60+
likes: ["user1", "user2"],
61+
},
62+
};
63+
64+
const metaEnv = await service.storeMetaEnvelope(input, [
65+
"@search-test-user",
66+
]);
67+
68+
const found = await service.findMetaEnvelopesBySearchTerm(
69+
"SocialMediaPost",
70+
"searchable",
71+
);
72+
73+
expect(Array.isArray(found)).toBe(true);
74+
const match = found.find((m) => m.id === metaEnv.metaEnvelope.id);
75+
expect(match).toBeDefined();
76+
if (!match) throw new Error();
77+
expect(match.envelopes.length).toBeGreaterThan(0);
78+
expect(
79+
match.envelopes.some((e) => e.value.includes("searchable")),
80+
).toBe(true);
81+
});
82+
83+
it("should return empty array if no values contain the search term", async () => {
84+
const found = await service.findMetaEnvelopesBySearchTerm(
85+
"SocialMediaPost",
86+
"notfoundterm",
87+
);
88+
expect(Array.isArray(found)).toBe(true);
89+
expect(found.length).toBe(0);
90+
});
91+
92+
it("should find meta-envelopes by ontology", async () => {
93+
const results =
94+
await service.findMetaEnvelopesByOntology("SocialMediaPost");
95+
expect(Array.isArray(results)).toBe(true);
96+
expect(results.length).toBeGreaterThan(0);
97+
});
98+
99+
it("should delete a meta-envelope and its envelopes", async () => {
100+
const tempId = uuidv4();
101+
const meta = {
102+
id: tempId,
103+
ontology: "TempPost",
104+
payload: {
105+
value: "to be deleted",
106+
},
107+
};
108+
109+
await service.storeMetaEnvelope(meta, ["@delete-user"]);
110+
await service.deleteMetaEnvelope(tempId);
111+
112+
const deleted = await service.findMetaEnvelopeById(tempId);
113+
expect(deleted).toBeNull();
114+
});
115+
116+
it("should update envelope value", async () => {
117+
const testId = uuidv4();
118+
const meta = {
119+
id: testId,
120+
ontology: "UpdateTest",
121+
payload: {
122+
value: "original",
123+
},
124+
};
125+
126+
const stored = await service.storeMetaEnvelope(meta, ["@updater"]);
127+
128+
const result = await service.findMetaEnvelopeById(
129+
stored.metaEnvelope.id,
130+
);
131+
const targetEnvelope = result.envelopes.find(
132+
(e: any) => e.properties.ontology === "value",
133+
);
134+
135+
await service.updateEnvelopeValue(
136+
targetEnvelope.properties.id,
137+
"updated",
138+
);
139+
140+
const updated = await service.findMetaEnvelopeById(
141+
stored.metaEnvelope.id,
142+
);
143+
const updatedValue = updated.envelopes.find(
144+
(e: any) => e.properties.id === targetEnvelope.properties.id,
145+
);
146+
expect(updatedValue.properties.value).toBe("updated");
147+
});
148+
});
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import neo4j, { Driver, Session } from "neo4j-driver";
2+
import { W3IDBuilder } from "w3id";
3+
4+
type MetaEnvelope = {
5+
id: string;
6+
ontology: string;
7+
payload: Record<string, any>;
8+
};
9+
10+
type Envelope = {
11+
id: string;
12+
value: any;
13+
ontology: string;
14+
acl: string[];
15+
};
16+
17+
export class DbService {
18+
private driver: Driver;
19+
20+
constructor(uri: string, user: string, password: string) {
21+
this.driver = neo4j.driver(uri, neo4j.auth.basic(user, password));
22+
}
23+
24+
private async runQuery(query: string, params: Record<string, any>) {
25+
const session = this.driver.session();
26+
try {
27+
return await session.run(query, params);
28+
} finally {
29+
await session.close();
30+
}
31+
}
32+
33+
async storeMetaEnvelope(meta: MetaEnvelope, acl: string[]) {
34+
const w3id = await new W3IDBuilder().build();
35+
36+
const cypher: string[] = [
37+
`CREATE (m:MetaEnvelope { id: $metaId, ontology: $ontology })`,
38+
];
39+
40+
const envelopeParams: Record<string, any> = {
41+
metaId: w3id.id,
42+
ontology: meta.ontology,
43+
acl,
44+
};
45+
46+
const createdEnvelopes: Envelope[] = [];
47+
let counter = 0;
48+
49+
for (const [key, value] of Object.entries(meta.payload)) {
50+
const envW3id = await new W3IDBuilder().build();
51+
const envelopeId = envW3id.id;
52+
const alias = `e${counter}`;
53+
54+
const storedValue =
55+
typeof value === "object" ? JSON.stringify(value) : value;
56+
57+
cypher.push(`
58+
CREATE (${alias}:Envelope {
59+
id: $${alias}_id,
60+
ontology: $${alias}_ontology,
61+
value: $${alias}_value,
62+
acl: $acl
63+
})
64+
WITH m, ${alias}
65+
MERGE (m)-[:LINKS_TO]->(${alias})
66+
`);
67+
68+
envelopeParams[`${alias}_id`] = envelopeId;
69+
envelopeParams[`${alias}_ontology`] = key;
70+
envelopeParams[`${alias}_value`] = storedValue;
71+
72+
createdEnvelopes.push({
73+
id: envelopeId,
74+
ontology: key,
75+
value: value,
76+
acl,
77+
});
78+
79+
counter++;
80+
}
81+
82+
await this.runQuery(cypher.join("\n"), envelopeParams);
83+
84+
return {
85+
metaEnvelope: {
86+
id: w3id.id,
87+
ontology: meta.ontology,
88+
},
89+
envelopes: createdEnvelopes,
90+
};
91+
}
92+
93+
async findMetaEnvelopesBySearchTerm(
94+
ontology: string,
95+
searchTerm: string,
96+
): Promise<{ id: string; envelopes: any[] }[]> {
97+
const result = await this.runQuery(
98+
`
99+
MATCH (m:MetaEnvelope { ontology: $ontology })-[:LINKS_TO]->(e:Envelope)
100+
WHERE toLower(e.value) CONTAINS toLower($term)
101+
RETURN m.id AS id, collect(e) AS envelopes
102+
`,
103+
{ ontology, term: searchTerm },
104+
);
105+
106+
return result.records.map((record) => ({
107+
id: record.get("id"),
108+
envelopes: record.get("envelopes").map((e: any) => e.properties),
109+
}));
110+
}
111+
112+
async findMetaEnvelopeById(id: string): Promise<any> {
113+
const result = await this.runQuery(
114+
`
115+
MATCH (m:MetaEnvelope { id: $id })-[:LINKS_TO]->(e:Envelope)
116+
RETURN m.id AS id, m.ontology AS ontology, collect(e) AS envelopes
117+
`,
118+
{ id },
119+
);
120+
121+
return result.records[0]?.toObject() ?? null;
122+
}
123+
124+
async findMetaEnvelopesByOntology(ontology: string): Promise<string[]> {
125+
const result = await this.runQuery(
126+
`
127+
MATCH (m:MetaEnvelope { ontology: $ontology })
128+
RETURN m.id AS id
129+
`,
130+
{ ontology },
131+
);
132+
133+
return result.records.map((r) => r.get("id"));
134+
}
135+
136+
async deleteMetaEnvelope(id: string): Promise<void> {
137+
await this.runQuery(
138+
`
139+
MATCH (m:MetaEnvelope { id: $id })-[:LINKS_TO]->(e:Envelope)
140+
DETACH DELETE m, e
141+
`,
142+
{ id },
143+
);
144+
}
145+
146+
async updateEnvelopeValue(
147+
envelopeId: string,
148+
newValue: any,
149+
): Promise<void> {
150+
await this.runQuery(
151+
`
152+
MATCH (e:Envelope { id: $envelopeId })
153+
SET e.value = $newValue
154+
`,
155+
{ envelopeId, newValue },
156+
);
157+
}
158+
159+
async getAllEnvelopes(): Promise<Envelope[]> {
160+
const result = await this.runQuery(`MATCH (e:Envelope) RETURN e`, {});
161+
return result.records.map((r) => r.get("e").properties as Envelope);
162+
}
163+
164+
async close(): Promise<void> {
165+
await this.driver.close();
166+
}
167+
}

infrastructure/evault-core/src/evault.ts

Whitespace-only changes.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2017",
4+
"module": "ESNext",
5+
"lib": ["ESNext", "DOM"],
6+
"declaration": true,
7+
"declarationDir": "./dist/types",
8+
"outDir": "./dist",
9+
"rootDir": "./src",
10+
"strict": true,
11+
"esModuleInterop": true,
12+
"forceConsistentCasingInFileNames": true,
13+
"moduleResolution": "Node",
14+
"skipLibCheck": true
15+
},
16+
"include": ["src/**/*"],
17+
"exclude": ["node_modules", "dist"]
18+
}

0 commit comments

Comments
 (0)