@ontologie/mock-server 0.1.0-preview.1
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/README.md +74 -0
- package/dist/fixtures.d.ts +267 -0
- package/dist/fixtures.d.ts.map +1 -0
- package/dist/fixtures.js +351 -0
- package/dist/fixtures.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +56 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1282 -0
- package/dist/server.js.map +1 -0
- package/package.json +36 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MockServer — in-memory DataForge API mock.
|
|
3
|
+
*
|
|
4
|
+
* Covers M1-M7 SDK features: ontology, knowledge, agent, dashboard,
|
|
5
|
+
* calendar CRUD, audit export, forms, scenarios, decision-compare.
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import { defaultFixtures } from './fixtures.js';
|
|
9
|
+
export class MockServer {
|
|
10
|
+
objects;
|
|
11
|
+
edges;
|
|
12
|
+
latencyMs;
|
|
13
|
+
idCounter = 1000;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
// Bug 33 fix: Deep-clone fixtures to prevent mutation of defaults
|
|
16
|
+
this.objects = MockServer.cloneObjectMap(options?.fixtures?.objects ?? defaultFixtures.objects);
|
|
17
|
+
this.edges = MockServer.cloneEdges(options?.fixtures?.edges ?? defaultFixtures.edges);
|
|
18
|
+
this.latencyMs = options?.latencyMs ?? 0;
|
|
19
|
+
}
|
|
20
|
+
static cloneObjectMap(source) {
|
|
21
|
+
const cloned = new Map();
|
|
22
|
+
for (const [key, arr] of source) {
|
|
23
|
+
cloned.set(key, arr.map(obj => ({ ...obj })));
|
|
24
|
+
}
|
|
25
|
+
return cloned;
|
|
26
|
+
}
|
|
27
|
+
static cloneEdges(source) {
|
|
28
|
+
return source.map(e => ({ ...e }));
|
|
29
|
+
}
|
|
30
|
+
get url() {
|
|
31
|
+
return 'http://mock.dataforge.local';
|
|
32
|
+
}
|
|
33
|
+
/** Create a fetch function that routes to this mock server. */
|
|
34
|
+
createFetch() {
|
|
35
|
+
return async (input, init) => {
|
|
36
|
+
if (this.latencyMs > 0) {
|
|
37
|
+
await new Promise(r => setTimeout(r, this.latencyMs));
|
|
38
|
+
}
|
|
39
|
+
// Bug 34 fix: Handle Request objects (input instanceof Request)
|
|
40
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
41
|
+
const method = (init?.method ?? (input instanceof Request ? input.method : undefined) ?? 'GET').toUpperCase();
|
|
42
|
+
// Extract path using URL parsing — avoids host-dependent string replace
|
|
43
|
+
let path;
|
|
44
|
+
try {
|
|
45
|
+
const parsed = new URL(url);
|
|
46
|
+
path = parsed.pathname;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
path = url.replace(this.url, '');
|
|
50
|
+
}
|
|
51
|
+
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
52
|
+
return this.route(method, path, body);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async listen(options = {}) {
|
|
56
|
+
const host = options.host ?? '127.0.0.1';
|
|
57
|
+
const requestedPort = options.port ?? 8787;
|
|
58
|
+
const fetchFn = this.createFetch();
|
|
59
|
+
const httpServer = createServer((req, res) => {
|
|
60
|
+
void this.handleHttpRequest(req, res, host, requestedPort, fetchFn);
|
|
61
|
+
});
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
httpServer.once('error', reject);
|
|
64
|
+
httpServer.listen(requestedPort, host, () => {
|
|
65
|
+
httpServer.off('error', reject);
|
|
66
|
+
resolve();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
const address = httpServer.address();
|
|
70
|
+
const port = typeof address === 'object' && address ? address.port : requestedPort;
|
|
71
|
+
const url = `http://${host}:${port}`;
|
|
72
|
+
return {
|
|
73
|
+
url,
|
|
74
|
+
host,
|
|
75
|
+
port,
|
|
76
|
+
server: httpServer,
|
|
77
|
+
close: () => new Promise((resolve, reject) => {
|
|
78
|
+
httpServer.close((err) => err ? reject(err) : resolve());
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async handleHttpRequest(req, res, host, fallbackPort, fetchFn) {
|
|
83
|
+
try {
|
|
84
|
+
const chunks = [];
|
|
85
|
+
for await (const chunk of req) {
|
|
86
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
87
|
+
}
|
|
88
|
+
const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : undefined;
|
|
89
|
+
const requestHost = req.headers.host ?? `${host}:${fallbackPort}`;
|
|
90
|
+
const response = await fetchFn(`http://${requestHost}${req.url ?? '/'}`, {
|
|
91
|
+
method: req.method ?? 'GET',
|
|
92
|
+
headers: req.headers,
|
|
93
|
+
...(body ? { body } : {}),
|
|
94
|
+
});
|
|
95
|
+
res.statusCode = response.status;
|
|
96
|
+
response.headers.forEach((value, key) => {
|
|
97
|
+
res.setHeader(key, value);
|
|
98
|
+
});
|
|
99
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
100
|
+
res.end(buffer);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
res.statusCode = 500;
|
|
104
|
+
res.setHeader('Content-Type', 'application/json');
|
|
105
|
+
res.end(JSON.stringify({
|
|
106
|
+
error: {
|
|
107
|
+
code: 'MOCK_HTTP_SERVER_ERROR',
|
|
108
|
+
message: error instanceof Error ? error.message : 'Mock HTTP server error',
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
route(method, path, body) {
|
|
114
|
+
// Health
|
|
115
|
+
if (path === '/api/v1/admin/health' || path === '/health') {
|
|
116
|
+
return this.json({ status: 'healthy', version: 'mock-1.0.0' });
|
|
117
|
+
}
|
|
118
|
+
// 6B: Query objects with filter/order/limit support
|
|
119
|
+
if (method === 'POST' && path === '/api/v1/ontology/query') {
|
|
120
|
+
const objectType = body?.objectType;
|
|
121
|
+
let objects = [...(this.objects.get(objectType) ?? [])];
|
|
122
|
+
// Apply filters — accept both body.filters (SDK sends this) and body.where (legacy)
|
|
123
|
+
const filterObj = body?.filters ?? body?.where;
|
|
124
|
+
if (filterObj && typeof filterObj === 'object') {
|
|
125
|
+
for (const [field, condition] of Object.entries(filterObj)) {
|
|
126
|
+
if (condition && typeof condition === 'object') {
|
|
127
|
+
if ('eq' in condition)
|
|
128
|
+
objects = objects.filter(o => o[field] === condition.eq);
|
|
129
|
+
if ('neq' in condition)
|
|
130
|
+
objects = objects.filter(o => o[field] !== condition.neq);
|
|
131
|
+
if ('gt' in condition)
|
|
132
|
+
objects = objects.filter(o => o[field] > condition.gt);
|
|
133
|
+
if ('gte' in condition)
|
|
134
|
+
objects = objects.filter(o => o[field] >= condition.gte);
|
|
135
|
+
if ('lt' in condition)
|
|
136
|
+
objects = objects.filter(o => o[field] < condition.lt);
|
|
137
|
+
if ('lte' in condition)
|
|
138
|
+
objects = objects.filter(o => o[field] <= condition.lte);
|
|
139
|
+
if ('contains' in condition)
|
|
140
|
+
objects = objects.filter(o => String(o[field] ?? '').includes(String(condition.contains)));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Direct equality shorthand
|
|
144
|
+
objects = objects.filter(o => o[field] === condition);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Apply orderBy — handles both object { field: 'asc'|'desc' } and array [{ field, direction }]
|
|
149
|
+
const orderBy = body?.orderBy;
|
|
150
|
+
if (orderBy) {
|
|
151
|
+
let field;
|
|
152
|
+
let dir;
|
|
153
|
+
if (Array.isArray(orderBy) && orderBy.length > 0) {
|
|
154
|
+
field = orderBy[0].field;
|
|
155
|
+
dir = orderBy[0].direction === 'desc' ? -1 : 1;
|
|
156
|
+
}
|
|
157
|
+
else if (typeof orderBy === 'object') {
|
|
158
|
+
field = Object.keys(orderBy)[0];
|
|
159
|
+
dir = orderBy[field] === 'desc' ? -1 : 1;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
field = '';
|
|
163
|
+
dir = 1;
|
|
164
|
+
}
|
|
165
|
+
if (field) {
|
|
166
|
+
objects.sort((a, b) => {
|
|
167
|
+
const va = a[field], vb = b[field];
|
|
168
|
+
if (va == null && vb == null)
|
|
169
|
+
return 0;
|
|
170
|
+
if (va == null)
|
|
171
|
+
return dir;
|
|
172
|
+
if (vb == null)
|
|
173
|
+
return -dir;
|
|
174
|
+
return va < vb ? -dir : va > vb ? dir : 0;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const total = objects.length;
|
|
179
|
+
const offset = body?.offset ?? 0;
|
|
180
|
+
// Apply limit/offset
|
|
181
|
+
if (body?.limit)
|
|
182
|
+
objects = objects.slice(offset, offset + body.limit);
|
|
183
|
+
else if (body?.pageSize)
|
|
184
|
+
objects = objects.slice(0, body.pageSize);
|
|
185
|
+
// hasMore: are there remaining items beyond this page?
|
|
186
|
+
return this.json({ data: objects, total, hasMore: (offset + objects.length) < total });
|
|
187
|
+
}
|
|
188
|
+
// Bug 35 fix: GET /api/v1/object-types/:apiName/instances — list instances
|
|
189
|
+
const instanceListMatch = path.match(/^\/api\/v1\/object-types\/([^/]+)\/instances$/);
|
|
190
|
+
if (method === 'GET' && instanceListMatch) {
|
|
191
|
+
const [, objectType] = instanceListMatch;
|
|
192
|
+
const objects = this.objects.get(objectType) ?? [];
|
|
193
|
+
return this.json({ success: true, data: objects, total: objects.length, hasMore: false });
|
|
194
|
+
}
|
|
195
|
+
// Bug 35 fix: GET /api/v1/instances/:id — get single instance
|
|
196
|
+
const instanceGetMatch = path.match(/^\/api\/v1\/instances\/([^/]+)$/);
|
|
197
|
+
if (method === 'GET' && instanceGetMatch) {
|
|
198
|
+
const [, instanceId] = instanceGetMatch;
|
|
199
|
+
for (const [objectType, list] of this.objects) {
|
|
200
|
+
const obj = list.find(o => o.$primaryKey === instanceId);
|
|
201
|
+
if (obj) {
|
|
202
|
+
return this.json({
|
|
203
|
+
success: true,
|
|
204
|
+
data: {
|
|
205
|
+
id: instanceId,
|
|
206
|
+
object_type_id: objectType,
|
|
207
|
+
data: { ...obj },
|
|
208
|
+
version: obj.$version,
|
|
209
|
+
status: 'active',
|
|
210
|
+
source_type: 'manual',
|
|
211
|
+
external_id: null,
|
|
212
|
+
created_at: new Date().toISOString(),
|
|
213
|
+
updated_at: new Date().toISOString(),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return this.json({ success: false, error: { code: 'INSTANCE_NOT_FOUND', message: `Instance '${instanceGetMatch[1]}' not found` } }, 404);
|
|
219
|
+
}
|
|
220
|
+
// Get object by ID (legacy ontology route)
|
|
221
|
+
const getMatch = path.match(/^\/api\/v1\/ontology\/(\w+)\/(.+)$/);
|
|
222
|
+
if (method === 'GET' && getMatch) {
|
|
223
|
+
const [, objectType, id] = getMatch;
|
|
224
|
+
const objects = this.objects.get(objectType) ?? [];
|
|
225
|
+
const obj = objects.find(o => o.$primaryKey === id);
|
|
226
|
+
if (!obj)
|
|
227
|
+
return this.json({ message: 'Not found' }, 404);
|
|
228
|
+
return this.json(obj);
|
|
229
|
+
}
|
|
230
|
+
// Create object
|
|
231
|
+
const createMatch = path.match(/^\/api\/v1\/ontology\/(\w+)$/);
|
|
232
|
+
if (method === 'POST' && createMatch && body) {
|
|
233
|
+
const [, objectType] = createMatch;
|
|
234
|
+
const newObj = {
|
|
235
|
+
$primaryKey: `mock-${++this.idCounter}`,
|
|
236
|
+
$objectType: objectType,
|
|
237
|
+
$version: 1,
|
|
238
|
+
...body,
|
|
239
|
+
};
|
|
240
|
+
if (!this.objects.has(objectType))
|
|
241
|
+
this.objects.set(objectType, []);
|
|
242
|
+
this.objects.get(objectType).push(newObj);
|
|
243
|
+
return this.json(newObj, 201);
|
|
244
|
+
}
|
|
245
|
+
// 6A: GET /api/v1/ontology/manifest — full manifest
|
|
246
|
+
if (method === 'GET' && path === '/api/v1/ontology/manifest') {
|
|
247
|
+
return this.json({
|
|
248
|
+
version: '1.0',
|
|
249
|
+
generatedAt: new Date().toISOString(),
|
|
250
|
+
workspaceId: 'mock-workspace',
|
|
251
|
+
espaceId: '',
|
|
252
|
+
objectTypes: defaultFixtures.objectTypes.map((t) => ({
|
|
253
|
+
id: `mock-${t.apiName}`,
|
|
254
|
+
apiName: t.apiName,
|
|
255
|
+
displayName: t.displayName,
|
|
256
|
+
description: t.description ?? '',
|
|
257
|
+
status: 'active',
|
|
258
|
+
properties: t.properties ?? [],
|
|
259
|
+
})),
|
|
260
|
+
linkTypes: defaultFixtures.linkTypes.map((l) => ({
|
|
261
|
+
id: `mock-link-${l.apiName}`,
|
|
262
|
+
apiName: l.apiName,
|
|
263
|
+
sourceTypeApiName: l.sourceTypeApiName,
|
|
264
|
+
targetTypeApiName: l.targetTypeApiName,
|
|
265
|
+
cardinality: l.cardinality,
|
|
266
|
+
relationshipType: l.relationshipType ?? l.apiName,
|
|
267
|
+
label: l.displayName,
|
|
268
|
+
inverseName: l.inverseName,
|
|
269
|
+
properties: [],
|
|
270
|
+
})),
|
|
271
|
+
interfaces: [],
|
|
272
|
+
actions: [],
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// 6A: GET /api/v1/ontology/manifest/version — version hash
|
|
276
|
+
if (method === 'GET' && path === '/api/v1/ontology/manifest/version') {
|
|
277
|
+
return this.json({ version: 'mock-v1-hash' });
|
|
278
|
+
}
|
|
279
|
+
// List object types (manifest)
|
|
280
|
+
if (method === 'GET' && path === '/api/v1/ontology/types') {
|
|
281
|
+
return this.json({ data: defaultFixtures.objectTypes });
|
|
282
|
+
}
|
|
283
|
+
// List link types
|
|
284
|
+
if (method === 'GET' && path === '/api/v1/ontology/link-types') {
|
|
285
|
+
return this.json({ data: defaultFixtures.linkTypes });
|
|
286
|
+
}
|
|
287
|
+
// Raw nodes endpoint — used by `dataforge schema export` and `schema pull`
|
|
288
|
+
// (`sdk/packages/cli/src/commands/schema.ts:82,150`). Returns ObjectType
|
|
289
|
+
// and LinkType nodes in the shape the CLI normalizes into a manifest.
|
|
290
|
+
// Locked by `tests/structural/sdk-cli-parity.test.ts` C4 parity sample.
|
|
291
|
+
//
|
|
292
|
+
// Important: ObjectType.properties and LinkType.properties have
|
|
293
|
+
// **different shapes** by design (mirrors staging behaviour):
|
|
294
|
+
// - ObjectType.properties = array of property descriptors
|
|
295
|
+
// (`[{ apiName, dataType, required, ... }]`).
|
|
296
|
+
// - LinkType.properties = metadata object describing the relation
|
|
297
|
+
// (`{ sourceTypeApiName, targetTypeApiName, cardinality,
|
|
298
|
+
// inverseName }`).
|
|
299
|
+
// The CLI normalizer (`commands/schema.ts:103-114`) branches on
|
|
300
|
+
// `node.type === 'LinkType'` before touching `properties`, so the
|
|
301
|
+
// divergence is intentional. Don't unify here without first updating
|
|
302
|
+
// the CLI normalizer (review feedback #992 minor b).
|
|
303
|
+
if (method === 'GET' && path.startsWith('/api/v2/queries/nodes')) {
|
|
304
|
+
const objectTypeNodes = defaultFixtures.objectTypes.map((t) => ({
|
|
305
|
+
id: `mock-${t.apiName}`,
|
|
306
|
+
name: t.apiName,
|
|
307
|
+
displayName: t.displayName,
|
|
308
|
+
description: t.description ?? '',
|
|
309
|
+
type: 'ObjectType',
|
|
310
|
+
properties: t.properties ?? [],
|
|
311
|
+
groups: [],
|
|
312
|
+
interfaces: [],
|
|
313
|
+
primaryKey: t.properties?.[0]?.apiName ?? null,
|
|
314
|
+
status: 'active',
|
|
315
|
+
api_name: t.apiName,
|
|
316
|
+
rid: `rid-mock-${t.apiName}`,
|
|
317
|
+
}));
|
|
318
|
+
const linkTypeNodes = defaultFixtures.linkTypes.map((l) => ({
|
|
319
|
+
id: `mock-link-${l.apiName}`,
|
|
320
|
+
name: l.apiName,
|
|
321
|
+
displayName: l.displayName ?? l.apiName,
|
|
322
|
+
description: '',
|
|
323
|
+
type: 'LinkType',
|
|
324
|
+
properties: {
|
|
325
|
+
sourceTypeApiName: l.sourceTypeApiName,
|
|
326
|
+
targetTypeApiName: l.targetTypeApiName,
|
|
327
|
+
cardinality: l.cardinality ?? 'N:1',
|
|
328
|
+
inverseName: l.inverseName,
|
|
329
|
+
},
|
|
330
|
+
groups: [],
|
|
331
|
+
interfaces: [],
|
|
332
|
+
primaryKey: null,
|
|
333
|
+
status: 'active',
|
|
334
|
+
api_name: l.apiName,
|
|
335
|
+
rid: `rid-mock-link-${l.apiName}`,
|
|
336
|
+
sourceId: l.sourceTypeApiName,
|
|
337
|
+
targetId: l.targetTypeApiName,
|
|
338
|
+
}));
|
|
339
|
+
const data = [...objectTypeNodes, ...linkTypeNodes];
|
|
340
|
+
return this.json({ success: true, data, count: data.length });
|
|
341
|
+
}
|
|
342
|
+
// Instance graph — traverse edges from a source
|
|
343
|
+
const graphTraverseMatch = path.match(/^\/api\/v1\/instance-graph\/([^/]+)\/traverse$/);
|
|
344
|
+
if (method === 'POST' && graphTraverseMatch) {
|
|
345
|
+
const sourceKey = graphTraverseMatch[1];
|
|
346
|
+
const linkType = body?.linkType;
|
|
347
|
+
const matching = this.edges.filter(e => e.sourceKey === sourceKey && (!linkType || e.linkType === linkType));
|
|
348
|
+
const targetKeys = matching.map(e => e.targetKey);
|
|
349
|
+
const targets = [];
|
|
350
|
+
for (const [, objs] of this.objects) {
|
|
351
|
+
for (const obj of objs) {
|
|
352
|
+
if (targetKeys.includes(obj.$primaryKey))
|
|
353
|
+
targets.push(obj);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return this.json({ data: targets, total: targets.length, hasMore: false });
|
|
357
|
+
}
|
|
358
|
+
// Instance graph — neighbors (both directions)
|
|
359
|
+
const graphNeighborsMatch = path.match(/^\/api\/v1\/instance-graph\/([^/]+)\/neighbors$/);
|
|
360
|
+
if (method === 'GET' && graphNeighborsMatch) {
|
|
361
|
+
const key = graphNeighborsMatch[1];
|
|
362
|
+
const neighborKeys = new Set();
|
|
363
|
+
for (const e of this.edges) {
|
|
364
|
+
if (e.sourceKey === key)
|
|
365
|
+
neighborKeys.add(e.targetKey);
|
|
366
|
+
if (e.targetKey === key)
|
|
367
|
+
neighborKeys.add(e.sourceKey);
|
|
368
|
+
}
|
|
369
|
+
const neighbors = [];
|
|
370
|
+
for (const [, objs] of this.objects) {
|
|
371
|
+
for (const obj of objs) {
|
|
372
|
+
if (neighborKeys.has(obj.$primaryKey))
|
|
373
|
+
neighbors.push(obj);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return this.json({ data: neighbors, total: neighbors.length, hasMore: false });
|
|
377
|
+
}
|
|
378
|
+
// ── V1 Graph SOT (PR #1003 — GraphReadService) ────────────────────
|
|
379
|
+
//
|
|
380
|
+
// These three routes mirror the real backend's GraphReadService envelope
|
|
381
|
+
// (`{ data, meta }` per `buildV1Envelope`) so SDK clients calling
|
|
382
|
+
// `GraphOperations.{traverse,neighbors,shortestPath}` can hit either
|
|
383
|
+
// staging or this mock with identical behaviour.
|
|
384
|
+
// Data shapes match `GraphTraversalResult`, `GraphNeighborsResult`,
|
|
385
|
+
// `ShortestPathResult` in `@dataforge/sdk-types/graph.ts`.
|
|
386
|
+
// POST /api/v1/graph/traverse — bounded BFS from startNodeId
|
|
387
|
+
if (method === 'POST' && path === '/api/v1/graph/traverse') {
|
|
388
|
+
const startNodeId = body?.startNodeId ?? body?.startNode ?? '';
|
|
389
|
+
const requestedDepth = Number(body?.maxDepth ?? 2);
|
|
390
|
+
const direction = body?.direction ?? 'any';
|
|
391
|
+
const traversal = this.traverseGraph(startNodeId, requestedDepth, direction);
|
|
392
|
+
return this.json(this.v1Envelope(traversal, {
|
|
393
|
+
operation: 'traverse',
|
|
394
|
+
requestedDepth,
|
|
395
|
+
effectiveDepth: traversal.totalDepth,
|
|
396
|
+
truncated: traversal.truncated,
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
// GET /api/v1/graph/neighbors/:id — direct neighbours (bounded)
|
|
400
|
+
const v1NeighborsMatch = path.match(/^\/api\/v1\/graph\/neighbors\/([^/?]+)(?:\?.*)?$/);
|
|
401
|
+
if (method === 'GET' && v1NeighborsMatch) {
|
|
402
|
+
const nodeId = decodeURIComponent(v1NeighborsMatch[1]);
|
|
403
|
+
const result = this.neighborsOf(nodeId);
|
|
404
|
+
return this.json(this.v1Envelope(result, {
|
|
405
|
+
operation: 'neighbors',
|
|
406
|
+
truncated: false,
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
// POST /api/v1/graph/paths — shortest path (BFS over in-memory edges)
|
|
410
|
+
if (method === 'POST' && path === '/api/v1/graph/paths') {
|
|
411
|
+
const sourceId = body?.sourceId ?? '';
|
|
412
|
+
const targetId = body?.targetId ?? '';
|
|
413
|
+
const maxHops = Number(body?.maxHops ?? body?.maxDepth ?? 5);
|
|
414
|
+
const result = this.shortestPathBetween(sourceId, targetId, maxHops);
|
|
415
|
+
return this.json(this.v1Envelope(result, {
|
|
416
|
+
operation: 'shortestPath',
|
|
417
|
+
requestedHops: maxHops,
|
|
418
|
+
effectiveHops: result.path?.length ? result.path.length - 1 : 0,
|
|
419
|
+
truncated: false,
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
// ── V1 Action SOT (action SOT wedge 2026-04-29) ───────────────────
|
|
423
|
+
//
|
|
424
|
+
// Mirrors the real backend's ActionReadService envelope (`{ data: { action,
|
|
425
|
+
// diagnostics }, meta }` per `buildV1Envelope`) so SDK clients calling the
|
|
426
|
+
// V1 describe endpoint can hit either staging or this mock with identical
|
|
427
|
+
// behaviour. Locked by ratchet `ACTION-ENVELOPE-V1-01`.
|
|
428
|
+
// GET /api/v1/actions/:action_key/describe — single action descriptor
|
|
429
|
+
const actionDescribeMatch = path.match(/^\/api\/v1\/actions\/([^/?]+)\/describe(?:\?.*)?$/);
|
|
430
|
+
if (method === 'GET' && actionDescribeMatch) {
|
|
431
|
+
const actionKey = decodeURIComponent(actionDescribeMatch[1]);
|
|
432
|
+
// Mock action descriptor — minimal but realistic shape matching the
|
|
433
|
+
// ActionDescriptor type in `services/actions/types.ts`.
|
|
434
|
+
const nowIso = new Date().toISOString();
|
|
435
|
+
const descriptor = {
|
|
436
|
+
action: {
|
|
437
|
+
id: `mock-action-${actionKey}`,
|
|
438
|
+
entityId: 'mock-entity-id',
|
|
439
|
+
espaceId: null,
|
|
440
|
+
apiName: actionKey,
|
|
441
|
+
name: actionKey,
|
|
442
|
+
displayName: `Mock action ${actionKey}`,
|
|
443
|
+
description: 'Mock action returned by @dataforge/mock-server',
|
|
444
|
+
actionType: 'TRANSFORM',
|
|
445
|
+
trigger: null,
|
|
446
|
+
implementationType: 'builtin',
|
|
447
|
+
status: 'ACTIVE',
|
|
448
|
+
objectType: 'MockObject',
|
|
449
|
+
parameters: [],
|
|
450
|
+
preconditions: [],
|
|
451
|
+
effects: [],
|
|
452
|
+
requiredScopes: ['actions.run'],
|
|
453
|
+
limits: {
|
|
454
|
+
maxObjectsTouched: 20,
|
|
455
|
+
timeoutMs: 30000,
|
|
456
|
+
externalIO: false,
|
|
457
|
+
},
|
|
458
|
+
inputMapping: null,
|
|
459
|
+
outputMapping: null,
|
|
460
|
+
retryConfig: null,
|
|
461
|
+
awaitCompletion: true,
|
|
462
|
+
workflowId: null,
|
|
463
|
+
agentId: null,
|
|
464
|
+
createdAt: nowIso,
|
|
465
|
+
updatedAt: nowIso,
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
return this.json(this.v1Envelope(descriptor, {
|
|
469
|
+
operation: 'describe',
|
|
470
|
+
truncated: false,
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
// ── V1 Core Loop handlers (D-SDK-ALIGN-1) ────────────────────────
|
|
474
|
+
// POST /api/v1/context/pack — context compiler (Discover verb)
|
|
475
|
+
if (method === 'POST' && path === '/api/v1/context/pack') {
|
|
476
|
+
const query = body?.query ?? '';
|
|
477
|
+
return this.json({
|
|
478
|
+
query,
|
|
479
|
+
budget: { requested: body?.budget ?? 4000, used: 1200 },
|
|
480
|
+
sections: {
|
|
481
|
+
ontology: { summary: 'Mock ontology context', entities: [], stats: {} },
|
|
482
|
+
},
|
|
483
|
+
timing_ms: 42,
|
|
484
|
+
request_id: `mock-ctx-${++this.idCounter}`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// GET /api/v1/search/global — federated search (Search verb)
|
|
488
|
+
if (method === 'GET' && path === '/api/v1/search/global') {
|
|
489
|
+
return this.json({
|
|
490
|
+
success: true,
|
|
491
|
+
data: [],
|
|
492
|
+
meta: { total: 0 },
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
// POST /api/v1/graph/constrained-search — graph constrained search
|
|
496
|
+
if (method === 'POST' && path === '/api/v1/graph/constrained-search') {
|
|
497
|
+
return this.json(this.v1Envelope({ results: [], totalCount: 0 }, {
|
|
498
|
+
operation: 'traverse',
|
|
499
|
+
truncated: false,
|
|
500
|
+
}));
|
|
501
|
+
}
|
|
502
|
+
// GET /api/v1/plans — list plans (Inspect verb)
|
|
503
|
+
if (method === 'GET' && path === '/api/v1/plans') {
|
|
504
|
+
return this.json({ success: true, data: [] });
|
|
505
|
+
}
|
|
506
|
+
// GET /api/v1/plans/:id — get plan by ID
|
|
507
|
+
const planGetMatch = path.match(/^\/api\/v1\/plans\/([^/]+)$/);
|
|
508
|
+
if (method === 'GET' && planGetMatch) {
|
|
509
|
+
const planId = decodeURIComponent(planGetMatch[1]);
|
|
510
|
+
return this.json({
|
|
511
|
+
success: true,
|
|
512
|
+
data: {
|
|
513
|
+
artifactVersion: '1',
|
|
514
|
+
planId,
|
|
515
|
+
planHash: `mock-hash-${planId}`,
|
|
516
|
+
signature: { algorithm: 'mock', keyId: 'mock-key', signed: 'mock', value: 'mock', trustLevel: 'local_mock' },
|
|
517
|
+
body: {
|
|
518
|
+
planSchemaVersion: '1', planId, createdAt: new Date().toISOString(),
|
|
519
|
+
expiresAt: new Date(Date.now() + 300_000).toISOString(), ttlSeconds: 300,
|
|
520
|
+
workspace: { workspaceId: 'mock-ws' }, operation: { kind: 'action' },
|
|
521
|
+
actor: { initiator: { principalType: 'user', principalId: 'mock-user' }, credential: 'mock', applyBinding: 'mock' },
|
|
522
|
+
versions: {}, authorization: { requiredScopes: [], grantedScopes: [] }, inputs: {},
|
|
523
|
+
dependencies: { targetVersions: {}, readVersions: {}, expectedAbsent: [] },
|
|
524
|
+
effects: [], policyChecks: [], risk: { level: 'low', requiresConfirmation: false, reasonCodes: [] },
|
|
525
|
+
applyConstraints: {},
|
|
526
|
+
},
|
|
527
|
+
state: { status: 'ready' },
|
|
528
|
+
redaction: { mode: 'none', redactedPaths: [] },
|
|
529
|
+
meta: {},
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
// POST /api/v1/plans/:id/verify — verify plan
|
|
534
|
+
const planVerifyMatch = path.match(/^\/api\/v1\/plans\/([^/]+)\/verify$/);
|
|
535
|
+
if (method === 'POST' && planVerifyMatch) {
|
|
536
|
+
const planId = decodeURIComponent(planVerifyMatch[1]);
|
|
537
|
+
return this.json({
|
|
538
|
+
success: true,
|
|
539
|
+
data: {
|
|
540
|
+
planId,
|
|
541
|
+
planHash: `mock-hash-${planId}`,
|
|
542
|
+
signatureValid: true,
|
|
543
|
+
storedBodyHashValid: true,
|
|
544
|
+
state: { status: 'ready' },
|
|
545
|
+
computedStatus: 'ready',
|
|
546
|
+
canApply: true,
|
|
547
|
+
checks: [{ name: 'signature', status: 'passed' }],
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
// GET /api/v1/usage/me — workspace usage (Measure verb)
|
|
552
|
+
if (method === 'GET' && path === '/api/v1/usage/me') {
|
|
553
|
+
return this.json({
|
|
554
|
+
plan: 'free',
|
|
555
|
+
costUnitsUsed: 42,
|
|
556
|
+
costUnitsLimit: 1000,
|
|
557
|
+
remainingBudget: 958,
|
|
558
|
+
periodStart: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString(),
|
|
559
|
+
periodEnd: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString(),
|
|
560
|
+
rateLimits: { source: 'plan_config', readPerMinute: 60, writePerMinute: 30, burstPerSecond: 10 },
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
// GET /api/v1/usage/forecast — usage forecast
|
|
564
|
+
if (method === 'GET' && path === '/api/v1/usage/forecast') {
|
|
565
|
+
return this.json({
|
|
566
|
+
consumed: 42,
|
|
567
|
+
projected: 126,
|
|
568
|
+
limit: 1000,
|
|
569
|
+
remainingBudget: 958,
|
|
570
|
+
percentOfBudget: 13,
|
|
571
|
+
daysElapsed: 10,
|
|
572
|
+
daysInMonth: 30,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
// Knowledge search
|
|
576
|
+
if (method === 'POST' && path === '/api/v1/knowledge/search') {
|
|
577
|
+
return this.json({ data: [], total: 0 });
|
|
578
|
+
}
|
|
579
|
+
// Agent invoke
|
|
580
|
+
if (method === 'POST' && path === '/api/v1/agent/invoke') {
|
|
581
|
+
return this.json({
|
|
582
|
+
answer: `Mock response to: ${body?.query}`,
|
|
583
|
+
confidence: 0.85,
|
|
584
|
+
conversationId: 'mock-conv-1',
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
// ── Dashboard ──────────────────────────────────────────────────────
|
|
588
|
+
// Dashboard KPIs
|
|
589
|
+
if (path === '/api/dashboard/summary') {
|
|
590
|
+
// D-SDK-ROUTES-FIX R8: return summary shape that matches backend
|
|
591
|
+
return this.json({
|
|
592
|
+
ontology: { objectTypes: 5, linkTypes: 3, instances: 42 },
|
|
593
|
+
workflow: { active: 2, completed: 10 },
|
|
594
|
+
agent: { sessions: 3, invocations: 25 },
|
|
595
|
+
apiManager: { apis: 1 },
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
// Dashboard trends
|
|
599
|
+
if (method === 'GET' && path === '/api/dashboard/trends') {
|
|
600
|
+
return this.json(defaultFixtures.dashboardTimeseries);
|
|
601
|
+
}
|
|
602
|
+
// Dashboard analytics (ontology summary)
|
|
603
|
+
if (method === 'GET' && path === '/api/dashboard/ontology-summary') {
|
|
604
|
+
return this.json(defaultFixtures.dashboardStats);
|
|
605
|
+
}
|
|
606
|
+
// ── Calendar CRUD ──────────────────────────────────────────────────
|
|
607
|
+
// List calendar events
|
|
608
|
+
if (method === 'GET' && path === '/api/v1/calendar/events') {
|
|
609
|
+
return this.json({ data: defaultFixtures.calendarEvents, cursor: undefined, hasMore: false });
|
|
610
|
+
}
|
|
611
|
+
// Get calendar event by ID
|
|
612
|
+
const calGetMatch = path.match(/^\/api\/v1\/calendar\/events\/([^/]+)$/);
|
|
613
|
+
if (method === 'GET' && calGetMatch) {
|
|
614
|
+
return this.json(defaultFixtures.calendarEvents[0]);
|
|
615
|
+
}
|
|
616
|
+
// Create calendar event
|
|
617
|
+
if (method === 'POST' && path === '/api/v1/calendar/events') {
|
|
618
|
+
const now = new Date().toISOString();
|
|
619
|
+
return this.json({
|
|
620
|
+
id: `evt-${++this.idCounter}`,
|
|
621
|
+
calendarId: 'cal-001',
|
|
622
|
+
title: body?.title ?? 'New Event',
|
|
623
|
+
startTime: body?.startTime ?? now,
|
|
624
|
+
endTime: body?.endTime ?? now,
|
|
625
|
+
allDay: body?.allDay ?? false,
|
|
626
|
+
recurrence: body?.recurrence ?? null,
|
|
627
|
+
createdAt: now,
|
|
628
|
+
}, 201);
|
|
629
|
+
}
|
|
630
|
+
// Update calendar event
|
|
631
|
+
const calPutMatch = path.match(/^\/api\/v1\/calendar\/events\/([^/]+)$/);
|
|
632
|
+
if (method === 'PUT' && calPutMatch) {
|
|
633
|
+
return this.json({
|
|
634
|
+
...defaultFixtures.calendarEvents[0],
|
|
635
|
+
id: calPutMatch[1],
|
|
636
|
+
...body,
|
|
637
|
+
updatedAt: new Date().toISOString(),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
// Delete calendar event
|
|
641
|
+
const calDelMatch = path.match(/^\/api\/v1\/calendar\/events\/([^/]+)$/);
|
|
642
|
+
if (method === 'DELETE' && calDelMatch) {
|
|
643
|
+
return this.json(null, 204);
|
|
644
|
+
}
|
|
645
|
+
// ── Audit ──────────────────────────────────────────────────────────
|
|
646
|
+
// Audit events list
|
|
647
|
+
if (method === 'GET' && path === '/api/v1/audit/events') {
|
|
648
|
+
return this.json({ data: defaultFixtures.auditEvents, cursor: undefined, hasMore: false });
|
|
649
|
+
}
|
|
650
|
+
// Audit event by ID
|
|
651
|
+
const auditGetMatch = path.match(/^\/api\/v1\/audit\/events\/([^/]+)$/);
|
|
652
|
+
if (method === 'GET' && auditGetMatch) {
|
|
653
|
+
return this.json(defaultFixtures.auditEvents[0]);
|
|
654
|
+
}
|
|
655
|
+
// Audit entity history
|
|
656
|
+
const auditHistMatch = path.match(/^\/api\/v1\/audit\/entities\/([^/]+)\/([^/]+)\/history$/);
|
|
657
|
+
if (method === 'GET' && auditHistMatch) {
|
|
658
|
+
return this.json({ data: defaultFixtures.auditEvents, cursor: undefined, hasMore: false });
|
|
659
|
+
}
|
|
660
|
+
// Audit export
|
|
661
|
+
if (method === 'POST' && path === '/api/v1/audit/export') {
|
|
662
|
+
return this.json({
|
|
663
|
+
downloadUrl: 'https://storage.dataforge.local/exports/audit-export-001.csv',
|
|
664
|
+
expiresAt: '2026-04-20T00:00:00Z',
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
// ── Forms ──────────────────────────────────────────────────────────
|
|
668
|
+
// List forms
|
|
669
|
+
if (method === 'GET' && path === '/api/v1/forms') {
|
|
670
|
+
return this.json({ data: defaultFixtures.forms });
|
|
671
|
+
}
|
|
672
|
+
// Get form by ID
|
|
673
|
+
const formGetMatch = path.match(/^\/api\/v1\/forms\/([^/]+)$/);
|
|
674
|
+
if (method === 'GET' && formGetMatch) {
|
|
675
|
+
return this.json({ data: defaultFixtures.forms[0] });
|
|
676
|
+
}
|
|
677
|
+
// Create form
|
|
678
|
+
if (method === 'POST' && path === '/api/v1/forms') {
|
|
679
|
+
const now = new Date().toISOString();
|
|
680
|
+
return this.json({ data: {
|
|
681
|
+
id: `form-${++this.idCounter}`,
|
|
682
|
+
name: body?.name ?? 'New Form',
|
|
683
|
+
description: body?.description ?? '',
|
|
684
|
+
status: 'draft',
|
|
685
|
+
fields: body?.fields ?? [],
|
|
686
|
+
submitCount: 0,
|
|
687
|
+
createdAt: now,
|
|
688
|
+
updatedAt: now,
|
|
689
|
+
} }, 201);
|
|
690
|
+
}
|
|
691
|
+
// Submit to form
|
|
692
|
+
const formSubmitMatch = path.match(/^\/api\/v1\/forms\/([^/]+)\/submit$/);
|
|
693
|
+
if (method === 'POST' && formSubmitMatch) {
|
|
694
|
+
return this.json({
|
|
695
|
+
id: `sub-${++this.idCounter}`,
|
|
696
|
+
formId: formSubmitMatch[1],
|
|
697
|
+
data: body?.data ?? {},
|
|
698
|
+
submittedAt: new Date().toISOString(),
|
|
699
|
+
}, 201);
|
|
700
|
+
}
|
|
701
|
+
// List form submissions
|
|
702
|
+
const formSubsMatch = path.match(/^\/api\/v1\/forms\/([^/]+)\/submissions$/);
|
|
703
|
+
if (method === 'GET' && formSubsMatch) {
|
|
704
|
+
return this.json({ data: defaultFixtures.formSubmissions, total: defaultFixtures.formSubmissions.length });
|
|
705
|
+
}
|
|
706
|
+
// Update form
|
|
707
|
+
const formPutMatch = path.match(/^\/api\/v1\/forms\/([^/]+)$/);
|
|
708
|
+
if (method === 'PUT' && formPutMatch) {
|
|
709
|
+
return this.json({ data: { ...defaultFixtures.forms[0], id: formPutMatch[1], ...body, updatedAt: new Date().toISOString() } });
|
|
710
|
+
}
|
|
711
|
+
// Delete form
|
|
712
|
+
const formDelMatch = path.match(/^\/api\/v1\/forms\/([^/]+)$/);
|
|
713
|
+
if (method === 'DELETE' && formDelMatch) {
|
|
714
|
+
return this.json({ success: true });
|
|
715
|
+
}
|
|
716
|
+
// ── Scenarios ──────────────────────────────────────────────────────
|
|
717
|
+
// List scenarios
|
|
718
|
+
if (method === 'GET' && path === '/api/v1/scenarios') {
|
|
719
|
+
return this.json({ data: defaultFixtures.scenarios });
|
|
720
|
+
}
|
|
721
|
+
// Create scenario
|
|
722
|
+
if (method === 'POST' && path === '/api/v1/scenarios') {
|
|
723
|
+
const now = new Date().toISOString();
|
|
724
|
+
return this.json({ data: {
|
|
725
|
+
id: `scen-${++this.idCounter}`,
|
|
726
|
+
name: body?.name ?? 'New Scenario',
|
|
727
|
+
description: body?.description,
|
|
728
|
+
status: 'draft',
|
|
729
|
+
baseVersion: 1,
|
|
730
|
+
results: {},
|
|
731
|
+
createdAt: now,
|
|
732
|
+
updatedAt: now,
|
|
733
|
+
} }, 201);
|
|
734
|
+
}
|
|
735
|
+
// Get scenario by ID
|
|
736
|
+
const scenGetMatch = path.match(/^\/api\/v1\/scenarios\/([^/]+)$/);
|
|
737
|
+
if (method === 'GET' && scenGetMatch) {
|
|
738
|
+
return this.json({ data: defaultFixtures.scenarios[0] });
|
|
739
|
+
}
|
|
740
|
+
// Scenario mutations
|
|
741
|
+
const scenMutMatch = path.match(/^\/api\/v1\/scenarios\/([^/]+)\/mutations$/);
|
|
742
|
+
if (method === 'POST' && scenMutMatch) {
|
|
743
|
+
return this.json(null, 204);
|
|
744
|
+
}
|
|
745
|
+
// Scenario view
|
|
746
|
+
const scenViewMatch = path.match(/^\/api\/v1\/scenarios\/([^/]+)\/view/);
|
|
747
|
+
if (method === 'GET' && scenViewMatch) {
|
|
748
|
+
return this.json({ data: { instances: [], mutations: [], affectedCount: 0 } });
|
|
749
|
+
}
|
|
750
|
+
// Scenario impact
|
|
751
|
+
const scenImpactMatch = path.match(/^\/api\/v1\/scenarios\/([^/]+)\/impact$/);
|
|
752
|
+
if (method === 'POST' && scenImpactMatch) {
|
|
753
|
+
return this.json({ data: { affectedInstances: 3, affectedEdges: 1, riskLevel: 'low' } });
|
|
754
|
+
}
|
|
755
|
+
// Scenario apply
|
|
756
|
+
const scenApplyMatch = path.match(/^\/api\/v1\/scenarios\/([^/]+)\/apply$/);
|
|
757
|
+
if (method === 'POST' && scenApplyMatch) {
|
|
758
|
+
return this.json({ data: { ...defaultFixtures.scenarios[0], status: 'applied', appliedAt: new Date().toISOString() } });
|
|
759
|
+
}
|
|
760
|
+
// Scenario discard
|
|
761
|
+
const scenDiscardMatch = path.match(/^\/api\/v1\/scenarios\/([^/]+)\/discard$/);
|
|
762
|
+
if (method === 'POST' && scenDiscardMatch) {
|
|
763
|
+
return this.json({ data: { ...defaultFixtures.scenarios[0], status: 'discarded' } });
|
|
764
|
+
}
|
|
765
|
+
// ── Decision Compare ───────────────────────────────────────────────
|
|
766
|
+
// Estimate
|
|
767
|
+
if (method === 'POST' && path === '/api/v1/decision-compares/estimate') {
|
|
768
|
+
return this.json(defaultFixtures.decisionCompareEstimate);
|
|
769
|
+
}
|
|
770
|
+
// Submit
|
|
771
|
+
if (method === 'POST' && path === '/api/v1/decision-compares') {
|
|
772
|
+
return this.json(defaultFixtures.decisionCompareSubmit, 202);
|
|
773
|
+
}
|
|
774
|
+
// Report
|
|
775
|
+
const dcReportMatch = path.match(/^\/api\/v1\/decision-compares\/([^/]+)\/report$/);
|
|
776
|
+
if (method === 'GET' && dcReportMatch) {
|
|
777
|
+
return this.json(defaultFixtures.decisionCompareReport);
|
|
778
|
+
}
|
|
779
|
+
// Evidence
|
|
780
|
+
const dcEvidenceMatch = path.match(/^\/api\/v1\/decision-compares\/([^/]+)\/evidence$/);
|
|
781
|
+
if (method === 'GET' && dcEvidenceMatch) {
|
|
782
|
+
return this.json(defaultFixtures.decisionCompareEvidence);
|
|
783
|
+
}
|
|
784
|
+
// Override
|
|
785
|
+
const dcOverrideMatch = path.match(/^\/api\/v1\/decision-compares\/([^/]+)\/override$/);
|
|
786
|
+
if (method === 'POST' && dcOverrideMatch) {
|
|
787
|
+
return this.json({
|
|
788
|
+
runId: dcOverrideMatch[1],
|
|
789
|
+
overrideAccepted: true,
|
|
790
|
+
previousDecision: 'option-a',
|
|
791
|
+
clientDecision: body?.clientDecision ?? 'option-b',
|
|
792
|
+
recordedAt: new Date().toISOString(),
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
// Poll status (must come after /report, /evidence, /override)
|
|
796
|
+
const dcPollMatch = path.match(/^\/api\/v1\/decision-compares\/([^/]+)$/);
|
|
797
|
+
if (method === 'GET' && dcPollMatch) {
|
|
798
|
+
return this.json({
|
|
799
|
+
runId: dcPollMatch[1],
|
|
800
|
+
status: 'succeeded',
|
|
801
|
+
progress: 100,
|
|
802
|
+
createdAt: '2026-04-15T10:00:00Z',
|
|
803
|
+
updatedAt: '2026-04-15T10:05:00Z',
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
// Cancel
|
|
807
|
+
const dcCancelMatch = path.match(/^\/api\/v1\/decision-compares\/([^/]+)$/);
|
|
808
|
+
if (method === 'DELETE' && dcCancelMatch) {
|
|
809
|
+
return this.json({
|
|
810
|
+
runId: dcCancelMatch[1],
|
|
811
|
+
status: 'cancelled',
|
|
812
|
+
updatedAt: new Date().toISOString(),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
// ─── Phase 5 of epic #1077 — V1 REST instance CRUD handlers ───────────
|
|
816
|
+
// Closes #1076. After Phase 4 the SDK uses these REST routes (not /api/
|
|
817
|
+
// commands/execute) so the mock-server now handles writes natively.
|
|
818
|
+
// POST /api/v1/object-types/:apiName/instances — create instance
|
|
819
|
+
const instanceCreateMatch = path.match(/^\/api\/v1\/object-types\/([^/]+)\/instances$/);
|
|
820
|
+
if (method === 'POST' && instanceCreateMatch && body) {
|
|
821
|
+
const [, objectType] = instanceCreateMatch;
|
|
822
|
+
const data = (body.data ?? {});
|
|
823
|
+
const externalId = (body.externalId ?? body.external_id);
|
|
824
|
+
const instanceId = `mock-inst-${++this.idCounter}`;
|
|
825
|
+
const now = new Date().toISOString();
|
|
826
|
+
const instance = {
|
|
827
|
+
id: instanceId,
|
|
828
|
+
object_type_id: objectType,
|
|
829
|
+
data,
|
|
830
|
+
title_display: (data.name ?? data.title ?? null),
|
|
831
|
+
version: 1,
|
|
832
|
+
status: 'active',
|
|
833
|
+
source_type: 'manual',
|
|
834
|
+
external_id: externalId ?? null,
|
|
835
|
+
created_at: now,
|
|
836
|
+
updated_at: now,
|
|
837
|
+
created_by: 'mock-user',
|
|
838
|
+
};
|
|
839
|
+
const stored = {
|
|
840
|
+
$primaryKey: instanceId,
|
|
841
|
+
$objectType: objectType,
|
|
842
|
+
$version: 1,
|
|
843
|
+
...data,
|
|
844
|
+
};
|
|
845
|
+
if (!this.objects.has(objectType))
|
|
846
|
+
this.objects.set(objectType, []);
|
|
847
|
+
this.objects.get(objectType).push(stored);
|
|
848
|
+
return this.json({ success: true, data: instance }, 201);
|
|
849
|
+
}
|
|
850
|
+
// POST /api/v1/object-types/:apiName/instances/batch — batch ops
|
|
851
|
+
const instanceBatchMatch = path.match(/^\/api\/v1\/object-types\/([^/]+)\/instances\/batch$/);
|
|
852
|
+
if (method === 'POST' && instanceBatchMatch && body) {
|
|
853
|
+
const [, objectType] = instanceBatchMatch;
|
|
854
|
+
const operations = Array.isArray(body.updates) ? body.updates : [];
|
|
855
|
+
let succeeded = 0;
|
|
856
|
+
const results = [];
|
|
857
|
+
for (const op of operations) {
|
|
858
|
+
if (op.type === 'create' && op.data) {
|
|
859
|
+
const id = `mock-inst-${++this.idCounter}`;
|
|
860
|
+
if (!this.objects.has(objectType))
|
|
861
|
+
this.objects.set(objectType, []);
|
|
862
|
+
this.objects.get(objectType).push({
|
|
863
|
+
$primaryKey: id, $objectType: objectType, $version: 1, ...op.data,
|
|
864
|
+
});
|
|
865
|
+
results.push({ success: true, id });
|
|
866
|
+
succeeded++;
|
|
867
|
+
}
|
|
868
|
+
else if (op.type === 'update' && op.id && op.data) {
|
|
869
|
+
const list = this.objects.get(objectType) ?? [];
|
|
870
|
+
const idx = list.findIndex(o => o.$primaryKey === op.id);
|
|
871
|
+
if (idx >= 0) {
|
|
872
|
+
list[idx] = { ...list[idx], ...op.data, $version: list[idx].$version + 1 };
|
|
873
|
+
results.push({ success: true, id: op.id });
|
|
874
|
+
succeeded++;
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
results.push({ success: false, id: op.id, error: 'NOT_FOUND' });
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
else if (op.type === 'delete' && op.id) {
|
|
881
|
+
const list = this.objects.get(objectType) ?? [];
|
|
882
|
+
const idx = list.findIndex(o => o.$primaryKey === op.id);
|
|
883
|
+
if (idx >= 0) {
|
|
884
|
+
list.splice(idx, 1);
|
|
885
|
+
results.push({ success: true, id: op.id });
|
|
886
|
+
succeeded++;
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
results.push({ success: false, id: op.id, error: 'NOT_FOUND' });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
// Unknown op shape (e.g. type='create' missing data, unknown type) — keep
|
|
894
|
+
// results aligned with operations[] so callers can index by position.
|
|
895
|
+
results.push({ success: false, id: op.id ?? null, error: 'UNKNOWN_OP' });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return this.json({
|
|
899
|
+
success: true,
|
|
900
|
+
data: { succeeded, failed: operations.length - succeeded, results },
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
// PUT/DELETE /api/v1/instances/:id — single regex shared by both verbs
|
|
904
|
+
const instanceSingleMatch = path.match(/^\/api\/v1\/instances\/([^/]+)$/);
|
|
905
|
+
// PUT /api/v1/instances/:id — update instance (6C: OCC validation)
|
|
906
|
+
if (method === 'PUT' && instanceSingleMatch && body) {
|
|
907
|
+
const instanceUpdateMatch = instanceSingleMatch;
|
|
908
|
+
const [, instanceId] = instanceUpdateMatch;
|
|
909
|
+
const data = (body.data ?? {});
|
|
910
|
+
// Find across all object types (cross-type instance lookup)
|
|
911
|
+
for (const [objectType, list] of this.objects) {
|
|
912
|
+
const idx = list.findIndex(o => o.$primaryKey === instanceId);
|
|
913
|
+
if (idx >= 0) {
|
|
914
|
+
// 6C: Validate expectedVersion if provided (OCC)
|
|
915
|
+
if (body.expectedVersion !== undefined && body.expectedVersion !== list[idx].$version) {
|
|
916
|
+
return this.json({
|
|
917
|
+
success: false,
|
|
918
|
+
error: {
|
|
919
|
+
code: 'VERSION_CONFLICT',
|
|
920
|
+
message: `Expected version ${body.expectedVersion} but current is ${list[idx].$version}`,
|
|
921
|
+
statusCode: 409,
|
|
922
|
+
},
|
|
923
|
+
}, 409);
|
|
924
|
+
}
|
|
925
|
+
const updated = {
|
|
926
|
+
...list[idx],
|
|
927
|
+
...data,
|
|
928
|
+
$version: list[idx].$version + 1,
|
|
929
|
+
};
|
|
930
|
+
list[idx] = updated;
|
|
931
|
+
const now = new Date().toISOString();
|
|
932
|
+
return this.json({
|
|
933
|
+
success: true,
|
|
934
|
+
data: {
|
|
935
|
+
id: instanceId,
|
|
936
|
+
object_type_id: objectType,
|
|
937
|
+
data: { ...updated },
|
|
938
|
+
version: updated.$version,
|
|
939
|
+
status: 'active',
|
|
940
|
+
source_type: 'manual',
|
|
941
|
+
external_id: null,
|
|
942
|
+
created_at: list[idx].$createdAt ?? now,
|
|
943
|
+
updated_at: now,
|
|
944
|
+
created_by: null,
|
|
945
|
+
title_display: null,
|
|
946
|
+
},
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return this.json({ success: false, error: { code: 'INSTANCE_NOT_FOUND', message: `Instance '${instanceId}' not found` } }, 404);
|
|
951
|
+
}
|
|
952
|
+
// DELETE /api/v1/instances/:id — delete (soft) instance (reuses instanceSingleMatch)
|
|
953
|
+
if (method === 'DELETE' && instanceSingleMatch) {
|
|
954
|
+
const [, instanceId] = instanceSingleMatch;
|
|
955
|
+
for (const [, list] of this.objects) {
|
|
956
|
+
const idx = list.findIndex(o => o.$primaryKey === instanceId);
|
|
957
|
+
if (idx >= 0) {
|
|
958
|
+
list.splice(idx, 1);
|
|
959
|
+
return this.json({ success: true, data: { id: instanceId, status: 'deleted' } });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return this.json({ success: false, error: { code: 'INSTANCE_NOT_FOUND', message: `Instance '${instanceId}' not found` } }, 404);
|
|
963
|
+
}
|
|
964
|
+
// ─── Action V1 handlers (ActionBuilder dry-run + invoke) ───────────────
|
|
965
|
+
// POST /api/v1/actions/:key/dry-run — return PlanArtifactV1 stub
|
|
966
|
+
const actionDryRunMatch = path.match(/^\/api\/v1\/actions\/([^/]+)\/dry-run$/);
|
|
967
|
+
if (method === 'POST' && actionDryRunMatch) {
|
|
968
|
+
const [, actionKey] = actionDryRunMatch;
|
|
969
|
+
const planId = `mock-plan-${++this.idCounter}`;
|
|
970
|
+
const now = Date.now();
|
|
971
|
+
const expiresAt = new Date(now + 5 * 60_000).toISOString();
|
|
972
|
+
// PlanArtifactV1 stub — matches the canonical shape in
|
|
973
|
+
// `sdk/packages/client/src/plan-operations.ts:8-87`. All required
|
|
974
|
+
// fields are present with correct types (strings for versions, nested
|
|
975
|
+
// objects for workspace/operation/actor/risk/dependencies). Backend
|
|
976
|
+
// route `actions.routes.ts:444-450` spreads `canApply` into the
|
|
977
|
+
// artifact, so we mirror that behavior at the top level.
|
|
978
|
+
return this.json({
|
|
979
|
+
success: true,
|
|
980
|
+
data: {
|
|
981
|
+
artifactVersion: '1',
|
|
982
|
+
planId,
|
|
983
|
+
planHash: `mock-hash-${planId}`,
|
|
984
|
+
signature: {
|
|
985
|
+
algorithm: 'mock',
|
|
986
|
+
keyId: 'mock-key',
|
|
987
|
+
signed: 'mock-signed',
|
|
988
|
+
value: 'mock-signature-value',
|
|
989
|
+
trustLevel: 'local_mock',
|
|
990
|
+
},
|
|
991
|
+
body: {
|
|
992
|
+
planSchemaVersion: '1',
|
|
993
|
+
planId,
|
|
994
|
+
createdAt: new Date(now).toISOString(),
|
|
995
|
+
expiresAt,
|
|
996
|
+
ttlSeconds: 300,
|
|
997
|
+
workspace: { workspaceId: 'mock-ws' },
|
|
998
|
+
operation: { kind: 'action', actionKey },
|
|
999
|
+
actor: {
|
|
1000
|
+
initiator: {
|
|
1001
|
+
principalType: 'user',
|
|
1002
|
+
principalId: 'mock-user',
|
|
1003
|
+
displayName: 'Mock User',
|
|
1004
|
+
},
|
|
1005
|
+
credential: 'mock-credential',
|
|
1006
|
+
applyBinding: 'mock-binding',
|
|
1007
|
+
},
|
|
1008
|
+
versions: {},
|
|
1009
|
+
authorization: { requiredScopes: [], grantedScopes: [] },
|
|
1010
|
+
inputs: (body ?? {}),
|
|
1011
|
+
dependencies: { targetVersions: {}, readVersions: {}, expectedAbsent: [] },
|
|
1012
|
+
effects: [],
|
|
1013
|
+
policyChecks: [],
|
|
1014
|
+
risk: { level: 'low', requiresConfirmation: false, reasonCodes: [] },
|
|
1015
|
+
applyConstraints: {},
|
|
1016
|
+
},
|
|
1017
|
+
state: { status: 'ready' },
|
|
1018
|
+
redaction: { mode: 'none', redactedPaths: [] },
|
|
1019
|
+
meta: {},
|
|
1020
|
+
// Backend route adds `canApply` at the top level (not part of
|
|
1021
|
+
// PlanArtifactV1 type but spread into the artifact response).
|
|
1022
|
+
canApply: true,
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
// POST /api/v1/actions/:key/invoke — return ActionResult stub
|
|
1027
|
+
const actionInvokeMatch = path.match(/^\/api\/v1\/actions\/([^/]+)\/invoke$/);
|
|
1028
|
+
if (method === 'POST' && actionInvokeMatch) {
|
|
1029
|
+
const [, actionKey] = actionInvokeMatch;
|
|
1030
|
+
const runId = `mock-run-${++this.idCounter}`;
|
|
1031
|
+
return this.json({
|
|
1032
|
+
success: true,
|
|
1033
|
+
data: {
|
|
1034
|
+
// ActionResult minimal shape (matches sdk/packages/client/src/action-builder.ts)
|
|
1035
|
+
runId,
|
|
1036
|
+
actionKey,
|
|
1037
|
+
editedRefs: [],
|
|
1038
|
+
summary: `Mock execution of action '${actionKey}'`,
|
|
1039
|
+
blockedSideEffects: [],
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
// ─── Legacy CQRS write paths — return actionable 501 (mitigation) ──────
|
|
1044
|
+
// Pre-Phase-4 the SDK used /api/commands/execute. After Phase 4 (PR #1099)
|
|
1045
|
+
// the SDK uses the REST routes above. The 501 mitigation is kept as a
|
|
1046
|
+
// defensive guard for any legacy code that still hits the old paths.
|
|
1047
|
+
//
|
|
1048
|
+
// Method gate: scoped to write verbs so a future GET /api/commands/history
|
|
1049
|
+
// (read of command_log) wouldn't be 501'd here.
|
|
1050
|
+
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
|
1051
|
+
const isLegacyCqrsWrite = isWriteMethod && path.startsWith('/api/commands/');
|
|
1052
|
+
if (isLegacyCqrsWrite) {
|
|
1053
|
+
return this.json({
|
|
1054
|
+
success: false,
|
|
1055
|
+
error: {
|
|
1056
|
+
code: 'MOCK_LEGACY_CQRS_DEPRECATED',
|
|
1057
|
+
message: `Mock-server: ${method} ${path} is the legacy CQRS write path. ` +
|
|
1058
|
+
`Phase 4 of epic #1077 migrated the SDK to REST routes ` +
|
|
1059
|
+
`(POST /api/v1/object-types/:apiName/instances, PUT/DELETE /api/v1/instances/:id). ` +
|
|
1060
|
+
`Use the new SDK if you're seeing this.`,
|
|
1061
|
+
epic: 'https://github.com/growthsystemes/dataforge/issues/1077',
|
|
1062
|
+
},
|
|
1063
|
+
}, 501);
|
|
1064
|
+
}
|
|
1065
|
+
return this.json({ message: `Not implemented: ${method} ${path}` }, 501);
|
|
1066
|
+
}
|
|
1067
|
+
json(data, status = 200) {
|
|
1068
|
+
return new Response(JSON.stringify(data), {
|
|
1069
|
+
status,
|
|
1070
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
// ── V1 envelope + graph helpers ────────────────────────────────────
|
|
1074
|
+
// Mirror `backend/src/routes/v1/_envelope.ts` shape so SDKs targeting
|
|
1075
|
+
// mock vs real backend see identical fields.
|
|
1076
|
+
v1Envelope(data, diagnostics) {
|
|
1077
|
+
return {
|
|
1078
|
+
data: {
|
|
1079
|
+
...data,
|
|
1080
|
+
diagnostics: {
|
|
1081
|
+
workspaceId: 'ws_mock',
|
|
1082
|
+
...diagnostics,
|
|
1083
|
+
},
|
|
1084
|
+
},
|
|
1085
|
+
meta: {
|
|
1086
|
+
requestId: `mock_req_${this.idCounter++}`,
|
|
1087
|
+
workspaceId: 'ws_mock',
|
|
1088
|
+
costUnits: 1,
|
|
1089
|
+
estimatedCostUnits: 1,
|
|
1090
|
+
quotaRemaining: null,
|
|
1091
|
+
warnings: [],
|
|
1092
|
+
},
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
findObjectByKey(key) {
|
|
1096
|
+
for (const [, objs] of this.objects) {
|
|
1097
|
+
for (const obj of objs) {
|
|
1098
|
+
if (obj.$primaryKey === key)
|
|
1099
|
+
return obj;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
vertexFromObject(obj) {
|
|
1105
|
+
return {
|
|
1106
|
+
id: obj.$primaryKey,
|
|
1107
|
+
name: typeof obj.name === 'string' ? obj.name : obj.$primaryKey,
|
|
1108
|
+
type: obj.$objectType,
|
|
1109
|
+
properties: { ...obj },
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
/** Bounded BFS — traversal of in-memory edges from `startNodeId`. */
|
|
1113
|
+
traverseGraph(startNodeId, maxDepth, direction) {
|
|
1114
|
+
const visited = new Set();
|
|
1115
|
+
const vertices = [];
|
|
1116
|
+
const edges = [];
|
|
1117
|
+
const queue = [{ key: startNodeId, depth: 0 }];
|
|
1118
|
+
let maxReached = 0;
|
|
1119
|
+
let truncated = false;
|
|
1120
|
+
const HARD_CAP = 200;
|
|
1121
|
+
while (queue.length > 0) {
|
|
1122
|
+
const { key, depth } = queue.shift();
|
|
1123
|
+
if (visited.has(key))
|
|
1124
|
+
continue;
|
|
1125
|
+
visited.add(key);
|
|
1126
|
+
const obj = this.findObjectByKey(key);
|
|
1127
|
+
if (obj)
|
|
1128
|
+
vertices.push(this.vertexFromObject(obj));
|
|
1129
|
+
maxReached = Math.max(maxReached, depth);
|
|
1130
|
+
if (depth >= maxDepth)
|
|
1131
|
+
continue;
|
|
1132
|
+
if (vertices.length >= HARD_CAP) {
|
|
1133
|
+
truncated = true;
|
|
1134
|
+
break;
|
|
1135
|
+
}
|
|
1136
|
+
for (const e of this.edges) {
|
|
1137
|
+
const outbound = e.sourceKey === key;
|
|
1138
|
+
const inbound = e.targetKey === key;
|
|
1139
|
+
if (direction === 'outbound' && !outbound)
|
|
1140
|
+
continue;
|
|
1141
|
+
if (direction === 'inbound' && !inbound)
|
|
1142
|
+
continue;
|
|
1143
|
+
if (!outbound && !inbound)
|
|
1144
|
+
continue;
|
|
1145
|
+
const next = outbound ? e.targetKey : e.sourceKey;
|
|
1146
|
+
if (visited.has(next))
|
|
1147
|
+
continue;
|
|
1148
|
+
edges.push({ from: e.sourceKey, to: e.targetKey, type: e.linkType });
|
|
1149
|
+
queue.push({ key: next, depth: depth + 1 });
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return {
|
|
1153
|
+
vertices,
|
|
1154
|
+
edges,
|
|
1155
|
+
paths: [],
|
|
1156
|
+
vertexCount: vertices.length,
|
|
1157
|
+
edgeCount: edges.length,
|
|
1158
|
+
pathCount: 0,
|
|
1159
|
+
totalDepth: maxReached,
|
|
1160
|
+
truncated,
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
/** Direct neighbours (depth=1) shaped to `GraphNeighborsResult`. */
|
|
1164
|
+
neighborsOf(nodeId) {
|
|
1165
|
+
const centerObj = this.findObjectByKey(nodeId);
|
|
1166
|
+
const center = centerObj ? this.vertexFromObject(centerObj) : null;
|
|
1167
|
+
const vertices = [];
|
|
1168
|
+
const edges = [];
|
|
1169
|
+
const neighbors = [];
|
|
1170
|
+
for (const e of this.edges) {
|
|
1171
|
+
const outbound = e.sourceKey === nodeId;
|
|
1172
|
+
const inbound = e.targetKey === nodeId;
|
|
1173
|
+
if (!outbound && !inbound)
|
|
1174
|
+
continue;
|
|
1175
|
+
const otherKey = outbound ? e.targetKey : e.sourceKey;
|
|
1176
|
+
const obj = this.findObjectByKey(otherKey);
|
|
1177
|
+
if (!obj)
|
|
1178
|
+
continue;
|
|
1179
|
+
const vertex = this.vertexFromObject(obj);
|
|
1180
|
+
vertices.push(vertex);
|
|
1181
|
+
edges.push({ from: e.sourceKey, to: e.targetKey, type: e.linkType });
|
|
1182
|
+
neighbors.push({
|
|
1183
|
+
id: vertex.id,
|
|
1184
|
+
name: vertex.name,
|
|
1185
|
+
type: vertex.type,
|
|
1186
|
+
edgeId: `${e.sourceKey}->${e.targetKey}:${e.linkType}`,
|
|
1187
|
+
edgeType: e.linkType,
|
|
1188
|
+
direction: outbound ? 'outbound' : 'inbound',
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
return { center, vertices, edges, neighbors };
|
|
1192
|
+
}
|
|
1193
|
+
/** BFS shortest path (undirected for mock) shaped to `ShortestPathResult`. */
|
|
1194
|
+
shortestPathBetween(sourceId, targetId, maxHops) {
|
|
1195
|
+
if (sourceId === targetId) {
|
|
1196
|
+
const obj = this.findObjectByKey(sourceId);
|
|
1197
|
+
const v = obj ? this.vertexFromObject(obj) : null;
|
|
1198
|
+
return {
|
|
1199
|
+
vertices: v ? [v] : [],
|
|
1200
|
+
edges: [],
|
|
1201
|
+
paths: v ? [{ vertices: [v], edges: [] }] : [],
|
|
1202
|
+
pathCount: v ? 1 : 0,
|
|
1203
|
+
found: !!v,
|
|
1204
|
+
distance: 0,
|
|
1205
|
+
path: v ? [{ id: v.id, name: v.name, type: v.type }] : [],
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
const prev = new Map();
|
|
1209
|
+
const visited = new Set([sourceId]);
|
|
1210
|
+
const queue = [{ key: sourceId, depth: 0 }];
|
|
1211
|
+
let found = false;
|
|
1212
|
+
while (queue.length > 0) {
|
|
1213
|
+
const { key, depth } = queue.shift();
|
|
1214
|
+
if (depth >= maxHops)
|
|
1215
|
+
continue;
|
|
1216
|
+
for (const e of this.edges) {
|
|
1217
|
+
const outbound = e.sourceKey === key;
|
|
1218
|
+
const inbound = e.targetKey === key;
|
|
1219
|
+
if (!outbound && !inbound)
|
|
1220
|
+
continue;
|
|
1221
|
+
const next = outbound ? e.targetKey : e.sourceKey;
|
|
1222
|
+
if (visited.has(next))
|
|
1223
|
+
continue;
|
|
1224
|
+
visited.add(next);
|
|
1225
|
+
prev.set(next, key);
|
|
1226
|
+
if (next === targetId) {
|
|
1227
|
+
found = true;
|
|
1228
|
+
queue.length = 0;
|
|
1229
|
+
break;
|
|
1230
|
+
}
|
|
1231
|
+
queue.push({ key: next, depth: depth + 1 });
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (!found) {
|
|
1235
|
+
return { vertices: [], edges: [], paths: [], pathCount: 0, found: false, distance: 0, path: [] };
|
|
1236
|
+
}
|
|
1237
|
+
const keyChain = [targetId];
|
|
1238
|
+
let cur = targetId;
|
|
1239
|
+
while (cur && cur !== sourceId) {
|
|
1240
|
+
cur = prev.get(cur);
|
|
1241
|
+
if (cur)
|
|
1242
|
+
keyChain.unshift(cur);
|
|
1243
|
+
}
|
|
1244
|
+
const path = [];
|
|
1245
|
+
const vertices = [];
|
|
1246
|
+
for (const key of keyChain) {
|
|
1247
|
+
const obj = this.findObjectByKey(key);
|
|
1248
|
+
if (obj) {
|
|
1249
|
+
const v = this.vertexFromObject(obj);
|
|
1250
|
+
vertices.push(v);
|
|
1251
|
+
path.push({ id: v.id, name: v.name, type: v.type });
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const edges = [];
|
|
1255
|
+
for (let i = 0; i < keyChain.length - 1; i++) {
|
|
1256
|
+
const from = keyChain[i];
|
|
1257
|
+
const to = keyChain[i + 1];
|
|
1258
|
+
const edge = this.edges.find((e) => (e.sourceKey === from && e.targetKey === to) || (e.sourceKey === to && e.targetKey === from));
|
|
1259
|
+
if (edge)
|
|
1260
|
+
edges.push({ from: edge.sourceKey, to: edge.targetKey, type: edge.linkType });
|
|
1261
|
+
}
|
|
1262
|
+
return {
|
|
1263
|
+
vertices,
|
|
1264
|
+
edges,
|
|
1265
|
+
paths: [{ vertices, edges }],
|
|
1266
|
+
pathCount: 1,
|
|
1267
|
+
found: true,
|
|
1268
|
+
distance: keyChain.length - 1,
|
|
1269
|
+
path,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
/** Reset all fixtures to default state. */
|
|
1273
|
+
reset(fixtures) {
|
|
1274
|
+
// Bug 33 fix: Deep-clone on reset too
|
|
1275
|
+
this.objects = MockServer.cloneObjectMap(fixtures?.objects ?? defaultFixtures.objects);
|
|
1276
|
+
this.edges = MockServer.cloneEdges(fixtures?.edges ?? defaultFixtures.edges);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
export function createMockServer(options) {
|
|
1280
|
+
return new MockServer(options);
|
|
1281
|
+
}
|
|
1282
|
+
//# sourceMappingURL=server.js.map
|