Skip to content

Commit 60b04fc

Browse files
authored
Fix/emover graphql api (#770)
* fix: use idiomatic api * fix: add missing files * fix: throw error on api var missing * fix: tests * fix: docs * fix: code rabbit suggestions
1 parent 14450ca commit 60b04fc

9 files changed

Lines changed: 1430 additions & 568 deletions

File tree

docs/docs/Infrastructure/eVault.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,79 @@ mutation {
264264
}
265265
```
266266

267+
#### bulkCreateMetaEnvelopes
268+
269+
Create multiple MetaEnvelopes in a single operation. This is optimized for bulk data import and migration scenarios. Returns a structured payload with per-item results and aggregated success/error counts.
270+
271+
**Mutation**:
272+
```graphql
273+
mutation {
274+
bulkCreateMetaEnvelopes(
275+
inputs: [
276+
{
277+
id: "custom-id-1" # Optional: preserve specific IDs during migration
278+
ontology: "550e8400-e29b-41d4-a716-446655440001"
279+
payload: {
280+
content: "First item"
281+
authorId: "..."
282+
createdAt: "2025-02-04T10:00:00Z"
283+
}
284+
acl: ["*"]
285+
}
286+
{
287+
# id omitted: will generate a new ID
288+
ontology: "550e8400-e29b-41d4-a716-446655440001"
289+
payload: {
290+
content: "Second item"
291+
authorId: "..."
292+
createdAt: "2025-02-04T10:01:00Z"
293+
}
294+
acl: ["platform-a.w3id"]
295+
}
296+
]
297+
skipWebhooks: false # Optional: set to true to skip webhook delivery
298+
) {
299+
results {
300+
id # ID of the created envelope (or attempted ID if failed)
301+
success # Whether this individual item succeeded
302+
error # Error message if failed (null if succeeded)
303+
}
304+
successCount # Total number of successful creates
305+
errorCount # Total number of failed creates
306+
errors { # Global errors (usually empty)
307+
message
308+
code
309+
}
310+
}
311+
}
312+
```
313+
314+
**Features**:
315+
- **Batch Creation**: Create multiple MetaEnvelopes in a single request
316+
- **ID Preservation**: Optionally specify IDs for created envelopes (useful for migrations)
317+
- **Partial Success**: Returns individual results for each item, allowing some to succeed and others to fail
318+
- **Webhook Control**: `skipWebhooks` parameter can suppress webhook delivery (requires platform authorization)
319+
320+
**Use Cases**:
321+
- **Data Migration**: Import existing data from another system while preserving IDs
322+
- **Bulk Import**: Efficiently create many envelopes at once
323+
- **Initial Setup**: Populate an eVault with default or seed data
324+
325+
**Authentication**:
326+
This mutation requires a valid Bearer token in the `Authorization` header in addition to the `X-ENAME` header:
327+
328+
```http
329+
X-ENAME: @user-a.w3id
330+
Authorization: Bearer <jwt-token>
331+
```
332+
333+
**Webhook Suppression**:
334+
The `skipWebhooks` parameter only suppresses webhooks when:
335+
1. The parameter is set to `true`, AND
336+
2. The requesting platform is authorized for migrations (e.g., Emover)
337+
338+
For regular platform requests, webhooks are always delivered regardless of this parameter.
339+
267340
### Legacy API
268341

269342
The following queries and mutations are preserved for backward compatibility but are considered legacy. New integrations should use the idiomatic API above.

infrastructure/evault-core/src/core/db/db.service.ts

Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,87 @@ export class DbService {
140140
};
141141
}
142142

143+
/**
144+
* Store a meta-envelope with a specific ID (for migrations)
145+
* Similar to storeMetaEnvelope but allows preserving the original ID
146+
* @param meta - The meta-envelope data (without id)
147+
* @param acl - Access control list
148+
* @param eName - The eName identifier for multi-tenant isolation
149+
* @param id - Optional ID to use (if not provided, generates new one)
150+
* @returns The stored meta-envelope with its envelopes
151+
*/
152+
async storeMetaEnvelopeWithId<
153+
T extends Record<string, any> = Record<string, any>,
154+
>(
155+
meta: Omit<MetaEnvelope<T>, "id">,
156+
acl: string[],
157+
eName: string,
158+
id?: string,
159+
): Promise<StoreMetaEnvelopeResult<T>> {
160+
if (!eName) {
161+
throw new Error("eName is required for storing meta-envelopes");
162+
}
163+
164+
// Use provided ID or generate new one
165+
const metaId = id || (await new W3IDBuilder().build()).id;
166+
167+
const cypher: string[] = [
168+
"MERGE (m:MetaEnvelope { id: $metaId })",
169+
"ON CREATE SET m.ontology = $ontology, m.acl = $acl, m.eName = $eName",
170+
];
171+
172+
const envelopeParams: Record<string, any> = {
173+
metaId: metaId,
174+
ontology: meta.ontology,
175+
acl: acl,
176+
eName: eName,
177+
};
178+
179+
const createdEnvelopes: Envelope<T[keyof T]>[] = [];
180+
let counter = 0;
181+
182+
for (const [key, value] of Object.entries(meta.payload)) {
183+
const envW3id = await new W3IDBuilder().build();
184+
const envelopeId = envW3id.id;
185+
const alias = `e${counter}`;
186+
187+
const { value: storedValue, type: valueType } =
188+
serializeValue(value);
189+
190+
cypher.push(`
191+
MERGE (${alias}:Envelope { id: $${alias}_id })
192+
ON CREATE SET ${alias}.ontology = $${alias}_ontology, ${alias}.value = $${alias}_value, ${alias}.valueType = $${alias}_type
193+
WITH m, ${alias}
194+
MERGE (m)-[:LINKS_TO]->(${alias})
195+
`);
196+
197+
envelopeParams[`${alias}_id`] = envelopeId;
198+
envelopeParams[`${alias}_ontology`] = key;
199+
envelopeParams[`${alias}_value`] = storedValue;
200+
envelopeParams[`${alias}_type`] = valueType;
201+
202+
createdEnvelopes.push({
203+
id: envelopeId,
204+
ontology: key,
205+
value: value as T[keyof T],
206+
valueType,
207+
});
208+
209+
counter++;
210+
}
211+
212+
await this.runQueryInternal(cypher.join("\n"), envelopeParams);
213+
214+
return {
215+
metaEnvelope: {
216+
id: metaId,
217+
ontology: meta.ontology,
218+
acl: acl,
219+
},
220+
envelopes: createdEnvelopes,
221+
};
222+
}
223+
143224
/**
144225
* Finds meta-envelopes containing the search term in any of their envelopes.
145226
* Returns all envelopes from the matched meta-envelopes.
@@ -797,8 +878,10 @@ export class DbService {
797878
);
798879

799880
// Ensure value and valueType are explicitly null if undefined (Neo4j requires explicit null)
800-
const valueParam = storedValue !== undefined ? storedValue : null;
801-
const valueTypeParam = valueType !== undefined ? valueType : null;
881+
const valueParam =
882+
storedValue !== undefined ? storedValue : null;
883+
const valueTypeParam =
884+
valueType !== undefined ? valueType : null;
802885

803886
await targetDbService.runQuery(
804887
`
@@ -831,7 +914,11 @@ export class DbService {
831914

832915
if (userResult.records.length > 0) {
833916
const publicKeys = userResult.records[0].get("publicKeys");
834-
if (publicKeys && Array.isArray(publicKeys) && publicKeys.length > 0) {
917+
if (
918+
publicKeys &&
919+
Array.isArray(publicKeys) &&
920+
publicKeys.length > 0
921+
) {
835922
console.log(
836923
`[MIGRATION] Copying User node with public keys for eName: ${eName}`,
837924
);
@@ -972,15 +1059,15 @@ export class DbService {
9721059
options: { limit: number; cursor?: string | null },
9731060
): Promise<GetEnvelopeOperationLogsResult> {
9741061
if (!eName) {
975-
throw new Error("eName is required for getting envelope operation logs");
1062+
throw new Error(
1063+
"eName is required for getting envelope operation logs",
1064+
);
9761065
}
9771066
const limit = Math.min(Math.max(1, options.limit || 20), 100);
9781067
const cursor = options.cursor ?? null;
9791068

9801069
// Fetch limit+1 to know if there's a next page. Cursor format: "timestamp|id" (| avoids colons in ISO timestamp).
981-
const [cursorTs = "", cursorId = ""] = cursor
982-
? cursor.split("|")
983-
: [];
1070+
const [cursorTs = "", cursorId = ""] = cursor ? cursor.split("|") : [];
9841071
const result = await this.runQueryInternal(
9851072
cursor
9861073
? `
@@ -1039,9 +1126,7 @@ export class DbService {
10391126

10401127
const last = logs[logs.length - 1];
10411128
const nextCursor =
1042-
hasMore && last
1043-
? `${last.timestamp}|${last.id}`
1044-
: null;
1129+
hasMore && last ? `${last.timestamp}|${last.id}` : null;
10451130

10461131
return { logs, nextCursor, hasMore };
10471132
}
@@ -1074,17 +1159,18 @@ export class DbService {
10741159
}
10751160
// Reject mixed-direction cursor usage
10761161
if (first !== undefined && before !== undefined) {
1077-
throw new Error("Cannot use 'first' with 'before' - use 'first' with 'after' for forward pagination");
1162+
throw new Error(
1163+
"Cannot use 'first' with 'before' - use 'first' with 'after' for forward pagination",
1164+
);
10781165
}
10791166
if (last !== undefined && after !== undefined) {
1080-
throw new Error("Cannot use 'last' with 'after' - use 'last' with 'before' for backward pagination");
1167+
throw new Error(
1168+
"Cannot use 'last' with 'after' - use 'last' with 'before' for backward pagination",
1169+
);
10811170
}
10821171

10831172
// Default limit
1084-
const limit = Math.min(
1085-
Math.max(1, first ?? last ?? 20),
1086-
100,
1087-
);
1173+
const limit = Math.min(Math.max(1, first ?? last ?? 20), 100);
10881174
const isBackward = last !== undefined;
10891175

10901176
// Build WHERE conditions
@@ -1123,14 +1209,17 @@ export class DbService {
11231209
} else {
11241210
switch (mode) {
11251211
case "EXACT":
1126-
matchExpr = "toLower(toString(e.value)) = toLower($searchTerm)";
1212+
matchExpr =
1213+
"toLower(toString(e.value)) = toLower($searchTerm)";
11271214
break;
11281215
case "STARTS_WITH":
1129-
matchExpr = "toLower(toString(e.value)) STARTS WITH toLower($searchTerm)";
1216+
matchExpr =
1217+
"toLower(toString(e.value)) STARTS WITH toLower($searchTerm)";
11301218
break;
11311219
default:
11321220
// CONTAINS is the default mode
1133-
matchExpr = "toLower(toString(e.value)) CONTAINS toLower($searchTerm)";
1221+
matchExpr =
1222+
"toLower(toString(e.value)) CONTAINS toLower($searchTerm)";
11341223
break;
11351224
}
11361225
}
@@ -1181,8 +1270,10 @@ export class DbService {
11811270
RETURN count(m) AS total
11821271
`;
11831272
const countResult = await this.runQueryInternal(countQuery, params);
1184-
const totalCount = countResult.records[0]?.get("total")?.toNumber?.() ??
1185-
countResult.records[0]?.get("total") ?? 0;
1273+
const totalCount =
1274+
countResult.records[0]?.get("total")?.toNumber?.() ??
1275+
countResult.records[0]?.get("total") ??
1276+
0;
11861277

11871278
// Build main query with pagination
11881279
const orderDirection = isBackward ? "DESC" : "ASC";
@@ -1255,8 +1346,8 @@ export class DbService {
12551346

12561347
// Build pageInfo
12571348
const pageInfo: PageInfo = {
1258-
hasNextPage: isBackward ? (before !== undefined) : hasExtraRecord,
1259-
hasPreviousPage: isBackward ? hasExtraRecord : (after !== undefined),
1349+
hasNextPage: isBackward ? before !== undefined : hasExtraRecord,
1350+
hasPreviousPage: isBackward ? hasExtraRecord : after !== undefined,
12601351
startCursor: edges.length > 0 ? edges[0].cursor : null,
12611352
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
12621353
};

0 commit comments

Comments
 (0)