@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/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