Skip to content

Commit 6452511

Browse files
committed
feat(awareness-service): Neo4j backfill and catch-all seeding
Add the one-time migration path: - backfill-neo4j.ts reads MetaEnvelopes directly from evault-core's Neo4j (same node), reconstructs each packet's data payload and upserts into the packets table. Seeds history only - no deliveries are queued. - SeedService ensures every platform in the registry has an approved consumer and a catch-all subscription, so existing webhook receivers keep working unchanged. Runs on every API launch and via the seed:catchall script.
1 parent e45f34d commit 6452511

4 files changed

Lines changed: 231 additions & 0 deletions

File tree

services/awareness-service/api/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { queryRouter } from "./controllers/QueryController";
1111
import { subscriptionRouter } from "./controllers/SubscriptionController";
1212
import { AppDataSource } from "./database/data-source";
1313
import { DeliveryEngine } from "./services/DeliveryEngine";
14+
import { SeedService } from "./services/SeedService";
1415

1516
async function start(): Promise<void> {
1617
await AppDataSource.initialize();
@@ -32,6 +33,10 @@ async function start(): Promise<void> {
3233
app.use(applicationRouter());
3334
app.use(adminRouter());
3435

36+
// Backward compat: keep every currently-registered platform receiving
37+
// everything, the same way evault-core's old fanout did.
38+
await new SeedService().seedCatchAll();
39+
3540
const deliveryEngine = new DeliveryEngine();
3641
deliveryEngine.start();
3742

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import "reflect-metadata";
2+
import neo4j from "neo4j-driver";
3+
import { AppDataSource } from "../database/data-source";
4+
import { Packet } from "../database/entities/Packet";
5+
6+
/**
7+
* One-time backfill. AaaS runs on the same physical node as evault-core's Neo4j,
8+
* so this script reads MetaEnvelopes straight from the graph and seeds the
9+
* `packets` table. It is idempotent (upsert keyed on packet id) and re-runnable.
10+
*
11+
* It seeds the packet store ONLY - it deliberately does not create deliveries,
12+
* which would spam subscribers with the entire history on go-live.
13+
*/
14+
15+
const BATCH = 500;
16+
17+
/** Mirrors evault-core's deserializeValue for backfilled envelope values. */
18+
function deserialize(value: unknown, type: string): unknown {
19+
if (type === "object" && typeof value === "string") {
20+
try {
21+
return JSON.parse(value);
22+
} catch {
23+
return value;
24+
}
25+
}
26+
if (type === "array" && typeof value === "string") {
27+
try {
28+
return JSON.parse(value);
29+
} catch {
30+
return value;
31+
}
32+
}
33+
return value;
34+
}
35+
36+
async function main(): Promise<void> {
37+
const uri = process.env.AWARENESS_NEO4J_URI ?? "bolt://localhost:7687";
38+
const user = process.env.AWARENESS_NEO4J_USER ?? "neo4j";
39+
const password = process.env.AWARENESS_NEO4J_PASSWORD ?? "neo4j";
40+
const evaultPublicKey = process.env.EVAULT_PUBLIC_KEY ?? null;
41+
42+
const driver = neo4j.driver(uri, neo4j.auth.basic(user, password));
43+
await AppDataSource.initialize();
44+
const packetRepo = AppDataSource.getRepository(Packet);
45+
const backfillTs = new Date();
46+
47+
let skip = 0;
48+
let total = 0;
49+
50+
try {
51+
for (;;) {
52+
const session = driver.session();
53+
let rows: any[];
54+
try {
55+
const result = await session.run(
56+
`MATCH (m:MetaEnvelope)-[:LINKS_TO]->(e:Envelope)
57+
RETURN m.id AS id, m.ontology AS ontology, m.eName AS eName,
58+
collect({ontology: e.ontology, value: e.value, valueType: e.valueType}) AS envelopes
59+
SKIP $skip LIMIT $batch`,
60+
{ skip: neo4j.int(skip), batch: neo4j.int(BATCH) },
61+
);
62+
rows = result.records.map((r) => ({
63+
id: r.get("id"),
64+
ontology: r.get("ontology"),
65+
eName: r.get("eName"),
66+
envelopes: r.get("envelopes"),
67+
}));
68+
} finally {
69+
await session.close();
70+
}
71+
72+
if (rows.length === 0) break;
73+
74+
const packets = rows
75+
.filter((row) => row.id && row.ontology)
76+
.map((row) => {
77+
const data: Record<string, unknown> = {};
78+
for (const env of row.envelopes ?? []) {
79+
if (env?.ontology) {
80+
data[env.ontology] = deserialize(
81+
env.value,
82+
env.valueType,
83+
);
84+
}
85+
}
86+
return packetRepo.create({
87+
id: row.id,
88+
ontology: row.ontology,
89+
w3id: row.eName ?? null,
90+
evaultPublicKey,
91+
data,
92+
operation: "create" as const,
93+
receivedAt: backfillTs,
94+
});
95+
});
96+
97+
if (packets.length > 0) {
98+
await packetRepo.upsert(packets, ["id"]);
99+
total += packets.length;
100+
}
101+
console.log(`[backfill] processed ${total} packets...`);
102+
skip += BATCH;
103+
}
104+
105+
console.log(`[backfill] complete: ${total} packets seeded`);
106+
} finally {
107+
await driver.close();
108+
await AppDataSource.destroy();
109+
}
110+
}
111+
112+
main().catch((err) => {
113+
console.error("[backfill] failed:", err);
114+
process.exit(1);
115+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import "reflect-metadata";
2+
import { AppDataSource } from "../database/data-source";
3+
import { SeedService } from "../services/SeedService";
4+
5+
/**
6+
* Standalone runner for catch-all subscription seeding. The same logic also
7+
* runs automatically on every API launch; this script is for manual re-runs
8+
* (e.g. after new platforms register with the registry).
9+
*/
10+
async function main(): Promise<void> {
11+
await AppDataSource.initialize();
12+
const result = await new SeedService().seedCatchAll();
13+
console.log(
14+
`[seed-catchall] complete: ${result.seeded} new / ${result.total} platforms`,
15+
);
16+
await AppDataSource.destroy();
17+
}
18+
19+
main().catch((err) => {
20+
console.error("[seed-catchall] failed:", err);
21+
process.exit(1);
22+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import axios from "axios";
2+
import { AppDataSource } from "../database/data-source";
3+
import { Consumer } from "../database/entities/Consumer";
4+
import { Subscription } from "../database/entities/Subscription";
5+
import { config } from "../config";
6+
7+
/**
8+
* Backward-compat seeding. Before AaaS, evault-core fanned out every webhook to
9+
* every registered platform. To preserve that behaviour, on launch we ensure
10+
* each platform currently in the registry has an approved consumer and a
11+
* catch-all subscription (empty filters) pointing at `<platform>/api/webhook`.
12+
*
13+
* Idempotent: existing catch-all subscriptions are left untouched.
14+
*/
15+
export class SeedService {
16+
async seedCatchAll(): Promise<{ seeded: number; total: number }> {
17+
if (!config.registryUrl) {
18+
console.warn("[seed] PUBLIC_REGISTRY_URL not set, skipping");
19+
return { seeded: 0, total: 0 };
20+
}
21+
22+
let platforms: string[] = [];
23+
try {
24+
const response = await axios.get(
25+
new URL("/platforms", config.registryUrl).toString(),
26+
{ timeout: 10000 },
27+
);
28+
platforms = Array.isArray(response.data) ? response.data : [];
29+
} catch (err) {
30+
console.error("[seed] failed to fetch registry platforms:", err);
31+
return { seeded: 0, total: 0 };
32+
}
33+
34+
const consumerRepo = AppDataSource.getRepository(Consumer);
35+
const subRepo = AppDataSource.getRepository(Subscription);
36+
let seeded = 0;
37+
38+
for (const platformUrl of platforms) {
39+
let host: string;
40+
let targetUrl: string;
41+
try {
42+
host = new URL(platformUrl).host;
43+
targetUrl = new URL("/api/webhook", platformUrl).toString();
44+
} catch {
45+
console.warn(`[seed] skipping invalid platform: ${platformUrl}`);
46+
continue;
47+
}
48+
49+
const ename = `catchall:${host}`;
50+
let consumer = await consumerRepo.findOne({ where: { ename } });
51+
if (!consumer) {
52+
consumer = consumerRepo.create({
53+
ename,
54+
name: host,
55+
status: "approved",
56+
webhookBaseUrl: platformUrl,
57+
approvedAt: new Date(),
58+
});
59+
await consumerRepo.save(consumer);
60+
}
61+
62+
const existing = await subRepo.findOne({
63+
where: {
64+
consumerId: consumer.id,
65+
isCatchAll: true,
66+
targetUrl,
67+
},
68+
});
69+
if (!existing) {
70+
await subRepo.save(
71+
subRepo.create({
72+
consumerId: consumer.id,
73+
targetUrl,
74+
ontologyFilter: [],
75+
evaultFilter: [],
76+
isCatchAll: true,
77+
active: true,
78+
}),
79+
);
80+
seeded += 1;
81+
}
82+
}
83+
84+
console.log(
85+
`[seed] catch-all seeding done: ${seeded} new of ${platforms.length} platforms`,
86+
);
87+
return { seeded, total: platforms.length };
88+
}
89+
}

0 commit comments

Comments
 (0)