@x12i/static-memorix-explorer-api 1.0.0 → 1.1.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.
Files changed (49) hide show
  1. package/README.md +150 -16
  2. package/dist/config.js +1 -1
  3. package/dist/engine/identity.js +11 -9
  4. package/dist/engine/write.js +48 -4
  5. package/dist/routes/index.js +57 -7
  6. package/dist/server.js +42 -20
  7. package/guides/demo-app.md +524 -0
  8. package/guides/managing-json-files.md +310 -0
  9. package/guides/running-the-service.md +171 -0
  10. package/guides/using-the-api.md +265 -0
  11. package/mocks/data/admin/snapshots.json +52 -0
  12. package/mocks/data/assets/analysis.json +14 -0
  13. package/mocks/data/assets/decisions.json +14 -0
  14. package/mocks/data/assets/events.json +4 -0
  15. package/mocks/data/assets/snapshots.json +49 -0
  16. package/mocks/data/findings/analysis.json +4 -0
  17. package/mocks/data/findings/decisions.json +4 -0
  18. package/mocks/data/findings/events.json +3 -0
  19. package/mocks/data/findings/snapshots.json +21 -0
  20. package/mocks/data/marketing/snapshots.json +75 -0
  21. package/mocks/data/memory/memory.json +31 -0
  22. package/mocks/data/product/snapshots.json +84 -0
  23. package/mocks/data/system/inventory.json +8 -0
  24. package/mocks/data/users/snapshots.json +5 -0
  25. package/mocks/demo.html +828 -0
  26. package/mocks/metadata/agents.json +7 -0
  27. package/mocks/metadata/lists/assets-default.json +22 -0
  28. package/mocks/metadata/lists/backlog.json +10 -0
  29. package/mocks/metadata/lists/findings-default.json +9 -0
  30. package/mocks/metadata/lists/my-plate.json +10 -0
  31. package/mocks/metadata/lists/this-week.json +10 -0
  32. package/mocks/metadata/narratives/admin.json +7 -0
  33. package/mocks/metadata/narratives/assets.json +17 -0
  34. package/mocks/metadata/narratives/findings.json +7 -0
  35. package/mocks/metadata/narratives/marketing.json +17 -0
  36. package/mocks/metadata/narratives/product.json +12 -0
  37. package/mocks/metadata/object-types/admin.json +6 -0
  38. package/mocks/metadata/object-types/assets.json +9 -0
  39. package/mocks/metadata/object-types/findings.json +8 -0
  40. package/mocks/metadata/object-types/marketing.json +6 -0
  41. package/mocks/metadata/object-types/memory.json +6 -0
  42. package/mocks/metadata/object-types/product.json +6 -0
  43. package/mocks/metadata/object-types/users.json +13 -0
  44. package/mocks/metadata/write-descriptors/admin-task-write.json +20 -0
  45. package/mocks/metadata/write-descriptors/assets-analysis-write.json +14 -0
  46. package/mocks/metadata/write-descriptors/marketing-task-write.json +20 -0
  47. package/mocks/metadata/write-descriptors/memory-write.json +14 -0
  48. package/mocks/metadata/write-descriptors/product-task-write.json +20 -0
  49. package/package.json +5 -2
package/README.md CHANGED
@@ -1,37 +1,81 @@
1
- # @x12i/mock-memorix-explorer-api
1
+ # @x12i/static-memorix-explorer-api
2
2
 
3
- In-memory mock server that provides **full API parity** for the Memorix Explorer
3
+ Static mock server that provides **full API parity** for the Memorix Explorer
4
4
  Fastify API. No MongoDB, Catalox, or Redis required — state lives in
5
5
  `./mocks/**/*.json` and is flushed to disk (debounced) on mutation.
6
6
 
7
+ Ships with a **complete reference demo app** (FlowState — a small task
8
+ manager) at `GET /demo` that exercises every read/write path in the API.
9
+ See [guides/demo-app.md](./guides/demo-app.md) for the full walkthrough.
10
+
7
11
  ## Quick start
8
12
 
9
13
  ```bash
10
14
  npm install
11
15
  npm run build
12
- npm start # listens on :4300 (PORT / HOST / MOCKS_DIR env-overridable)
16
+ npm start # listens on :5030 (PORT / HOST / MOCKS_DIR env-overridable)
13
17
  # dev (no build): npm run dev
18
+
19
+ # open the demo UI in your browser
20
+ open http://localhost:5030/demo
14
21
  ```
15
22
 
16
23
  Health:
17
24
 
18
25
  ```bash
19
- curl localhost:4300/health
20
- curl "localhost:4300/api/explorer/health?includeInventory=1"
26
+ curl localhost:5030/health
27
+ curl "localhost:5030/api/explorer/health?includeInventory=1"
28
+ ```
29
+
30
+ ## Run from CLI or code
31
+
32
+ Installed CLI, using the default port `5030`:
33
+
34
+ ```bash
35
+ npx @x12i/static-memorix-explorer-api
36
+ ```
37
+
38
+ Override the port from the shell:
39
+
40
+ ```bash
41
+ PORT=8080 npx @x12i/static-memorix-explorer-api
21
42
  ```
22
43
 
44
+ Start it programmatically (explicit options take precedence over environment
45
+ defaults):
46
+
47
+ ```js
48
+ import { startServer } from "@x12i/static-memorix-explorer-api";
49
+
50
+ const app = await startServer({ port: 5030, host: "127.0.0.1" });
51
+ // await app.close();
52
+ ```
53
+
54
+ For code-level tests without binding a port:
55
+
56
+ ```js
57
+ import { buildServer } from "@x12i/static-memorix-explorer-api";
58
+
59
+ const app = await buildServer();
60
+ const response = await app.inject({ method: "GET", url: "/health" });
61
+ await app.close();
62
+ ```
63
+
64
+ See [guides/running-the-service.md](./guides/running-the-service.md) for all
65
+ CLI, environment, lifecycle, and programmatic examples.
66
+
23
67
  ## Architecture
24
68
 
25
69
  ```
26
- Fastify Router (/api/explorer/* + /health + Authix/M2M shim)
70
+ Fastify Router (/api/explorer/* + /health + Authix/M2M shim + /demo)
27
71
 
28
72
  Query & Interceptor Engine (mingo + narrative virtual filters)
29
- ┌────────────┬──────────────────┬──────────────┐
73
+ ┌────────────┬──────────────────┬──────────────┐
30
74
  Inventory/Lenses Snapshots/Aliases Records Writer
31
- └────────────┴──────────────────┴──────────────┘
32
- InMemoryStore (state matrix)
33
- │ debounced disk flush
34
- ./mocks/metadata & ./mocks/data
75
+ └────────────┴──────────────────┴──────────────┘
76
+ InMemoryStore (state matrix)
77
+ │ debounced disk flush
78
+ ./mocks/metadata & ./mocks/data
35
79
  ```
36
80
 
37
81
  ## Routes
@@ -41,13 +85,14 @@ Inventory/Lenses Snapshots/Aliases Records Writer
41
85
  | Health | `GET /health`, `GET /api/explorer/health?includeInventory=1` |
42
86
  | Inventory | `/inventory/collections`, `/summary`, `/issues`, `/graph` (query `sourceLens=db-first\|catalox-first`) |
43
87
  | Snapshots | `/snapshots/:objectType/:recordId[.../associated[/:propertyName]]`, `/snapshots/:objectType/associated-properties`, `/associations/plan\|apply\|verify` |
44
- | Records | `/records/collection`, `/full`, `/item`, `/content`, `/raw-collection`, `/raw-item`, `/workspace` |
88
+ | Records | `/records/collection`, `/full`, `/item`, `/content`, `/raw-collection`, `/raw-item`, `/workspace`, `POST /records/write`, `DELETE /records/item` |
45
89
  | Lists | `/lists`, `/lists/:listId`, `/lists/:listId/records`, `/lists/suggest`, mutations `POST/PATCH/PUT/DELETE` |
46
90
  | Narratives | `/narratives`, `/:entity`, `/:entity/raw`, `/:entity/:key`, `/:entity/:key/records`, mutations `POST/PATCH/DELETE` |
47
91
  | Object Types | `/object-types`, `/:name`, `/:name/root-property-catalog`, `POST .../compute` |
48
- | Write | `POST /records/write` (validate / dryRun / upsert / patch / replace) |
92
+ | Write | `POST /records/write` (validate / dryRun / add / upsert / patch / replace / **delete**) |
49
93
  | Agents | `GET /agents` |
50
94
  | Auth shim | `POST /api/explorer/auth/token` |
95
+ | Demo | `GET /demo` — serves `mocks/demo.html` |
51
96
 
52
97
  ## Query conventions
53
98
 
@@ -79,11 +124,59 @@ Client `associated.data|discovery|analysis|{custom}` maps to storage
79
124
  associated{Custom}`. Raw `associated*` fields are stripped from the returned
80
125
  document and recomposed into a normalized `associated` bucket.
81
126
 
127
+ ## Write engine
128
+
129
+ `POST /api/explorer/records/write` accepts an `operation` and routes the
130
+ payload through the write descriptor's JSON schema.
131
+
132
+ ### Operations
133
+
134
+ | Operation | Behavior |
135
+ |-----------|----------|
136
+ | `add` | Append. Auto-generates an identity if missing. |
137
+ | `upsert` | Update existing by identity key, or append. |
138
+ | `patch` | Update existing by identity key. No-op if missing. |
139
+ | `replace` | Replace the entire collection (use with caution). |
140
+ | `delete` | Remove every record matching the supplied identity keys. Returns the removed records. |
141
+
142
+ ### Required-field semantics
143
+
144
+ The `schema.required` array is enforced **fully** for `add` and `replace`.
145
+ For `upsert`, `patch`, and `delete`, only the identity field (`recordId`,
146
+ or `memoryId` on the memory tier) is required. This allows partial updates
147
+ like `{recordId, status}` without resupplying every required field — the
148
+ pattern the demo UI uses for every inline edit.
149
+
150
+ ### REST-style delete
151
+
152
+ For ergonomics, you can also delete a single record with:
153
+
154
+ ```
155
+ DELETE /api/explorer/records/item?entityName=product&recordId=t1&writeDescriptorId=product-task-write
156
+ ```
157
+
158
+ Returns `{ ok: true, count: <n>, removed: [<record>…] }` and flushes immediately.
159
+
160
+ ## Demo app
161
+
162
+ The `mocks/demo.html` reference UI (served at `GET /demo`) is a complete task
163
+ manager that uses the Explorer API for every read and mutation. It demonstrates:
164
+
165
+ - Multi-collection reads (3 parallel `records/collection` calls + a `users` collection)
166
+ - Filter / search / sort via query params
167
+ - **Create** via modal → `POST /records/write op=add`
168
+ - **Edit** via inline pills/dropdowns → `POST /records/write op=upsert` (partial updates)
169
+ - **Delete** via button → `DELETE /records/item`
170
+ - Cross-collection **move** (delete on old area + add on new area)
171
+ - Optimistic UI with rollback on failure
172
+
173
+ Full implementation walkthrough: [guides/demo-app.md](./guides/demo-app.md).
174
+
82
175
  ## Environment
83
176
 
84
177
  | Var | Default | Notes |
85
178
  |-----|---------|-------|
86
- | `PORT` | `4300` | Listen port |
179
+ | `PORT` | `5030` | Listen port |
87
180
  | `HOST` | `0.0.0.0` | Listen host |
88
181
  | `MOCKS_DIR` | `<project>/mocks` | Fixture root |
89
182
  | `DISK_FLUSH_DEBOUNCE_MS` | `300` | Debounce before disk flush |
@@ -99,7 +192,7 @@ document and recomposed into a normalized `associated` bucket.
99
192
  The mock server does not connect to MongoDB, but it **tricks the frontend** into
100
193
  thinking it is talking to the routed tenant databases.
101
194
 
102
- - The `GET /api/explorer/health` response now includes a dynamic `memorixDb`
195
+ - The `GET /api/explorer/health` response includes a dynamic `memorixDb`
103
196
  field constructed as `<orgId>-memorix-entities + <orgId>-memorix-events`.
104
197
  - The agent-scoped catalox DB is reflected in the `cataloxDb` field as
105
198
  `<agentId>-memorix-catalox`.
@@ -118,6 +211,9 @@ The mock server enforces the following feature-flag gates with error responses:
118
211
  - Registry writes (if implemented) require
119
212
  `MEMORIX_EXPLORER_ENABLE_REGISTRY_WRITES=true`.
120
213
 
214
+ Record writes (`POST /records/write`, `DELETE /records/item`) are **not**
215
+ feature-flagged — they're always on, gated only by the write descriptor contract.
216
+
121
217
  When a flag is disabled or unset, the mock returns `{ ok: false, error: "..." }`
122
218
  matching the production Fastify error shape (typically 403/404).
123
219
 
@@ -134,4 +230,42 @@ and `knowledgeId`.
134
230
  - Records engine accepts `target=memory` and supports `memoryId` for fetching.
135
231
  - Write engine validates and persists `memoryId` on memory-tier records.
136
232
  - Startup validation (`MemorixRecordArraySchema` mock) allows `memoryId` without
137
- throwing schema errors.
233
+ throwing schema errors.
234
+
235
+ ## Documentation
236
+
237
+ - [guides/running-the-service.md](./guides/running-the-service.md) — installation, configuration, env vars
238
+ - [guides/using-the-api.md](./guides/using-the-api.md) — endpoint reference, query language, write operations
239
+ - [guides/managing-json-files.md](./guides/managing-json-files.md) — fixture structure, schemas, how the store works
240
+ - [guides/demo-app.md](./guides/demo-app.md) — FlowState reference UI walkthrough
241
+
242
+ ## Release history
243
+
244
+ ### v1.1.1
245
+ - Changed the default listen port from `4300` to `5030`.
246
+ - Added safe programmatic APIs: `buildServer()` and `startServer(options)`.
247
+ - Importing the package no longer starts a listener as a side effect.
248
+ - Added CLI, environment override, programmatic startup, and injected-test examples.
249
+
250
+ ### v1.1.0
251
+ - **Added**: `delete` operation to the write engine
252
+ (`POST /records/write` body `operation:"delete"`).
253
+ - **Added**: REST-style `DELETE /api/explorer/records/item` endpoint.
254
+ - **Added**: Operation-aware schema validation — `upsert`/`patch`/`delete`
255
+ only require the identity field, not every `required` field.
256
+ - **Added**: Complete demo application (`mocks/demo.html`) wired entirely
257
+ through the Explorer API — Create/Edit/Delete/Move with optimistic UI.
258
+ - **Added**: `users` object type + collection.
259
+ - **Added**: Per-area task write descriptors
260
+ (`product/marketing/admin-task-write`).
261
+ - **Added**: Per-area narratives and enriched task snapshots
262
+ (`notes`, `chat`, `assigneeId`).
263
+ - **Removed**: obsolete `/api/state` flow and `mocks/db.json`
264
+ (replaced by Explorer API writes).
265
+ - **Docs**: full demo walkthrough (`guides/demo-app.md`) and refreshed
266
+ endpoint tables.
267
+
268
+ ### v1.0.0
269
+ - Initial implementation of mock Memorix Explorer API.
270
+ - Health, inventory, snapshots, records, lists, narratives, object types,
271
+ write engine (add/upsert/patch/replace), agents, auth shim.
package/dist/config.js CHANGED
@@ -8,7 +8,7 @@ export const MOCKS_DIR = process.env.MOCKS_DIR
8
8
  : path.resolve(ROOT_DIR, "..", "mocks");
9
9
  export const METADATA_DIR = path.join(MOCKS_DIR, "metadata");
10
10
  export const DATA_DIR = path.join(MOCKS_DIR, "data");
11
- export const PORT = Number(process.env.PORT || 4300);
11
+ export const PORT = Number(process.env.PORT || 5030);
12
12
  export const HOST = process.env.HOST || "0.0.0.0";
13
13
  export const DISK_FLUSH_DEBOUNCE_MS = Number(process.env.DISK_FLUSH_DEBOUNCE_MS || 300);
14
14
  // ---------------------------------------------------------------------------
@@ -6,34 +6,36 @@
6
6
  export function resolveIdField(ct, sample) {
7
7
  if (ct === "memory")
8
8
  return "memoryId";
9
- if (ct === "events")
10
- return "eventId";
11
9
  if (sample) {
10
+ if (ct === "events" && "eventId" in sample)
11
+ return "eventId";
12
12
  if ("recordId" in sample)
13
13
  return "recordId";
14
14
  if ("entityId" in sample)
15
15
  return "entityId";
16
+ if ("eventId" in sample)
17
+ return "eventId";
16
18
  if ("id" in sample)
17
19
  return "id";
18
20
  }
21
+ if (ct === "events")
22
+ return "eventId";
19
23
  return "recordId";
20
24
  }
21
25
  /**
22
26
  * Mock of the production `MemorixRecordArraySchema`. Validates that each record
23
- * in a collection array carries exactly one recognized identity key. Previously
24
- * this threw at boot if a collection used `memoryId`; it now accepts it.
27
+ * in a collection array carries the collection's canonical identity key.
28
+ * Additional identity-shaped fields are allowed because they can represent
29
+ * relationships (for example a memory has `memoryId` and may reference an
30
+ * `entityId` or `eventId`).
25
31
  */
26
32
  export function validateRecordArray(objectType, ct, records) {
27
33
  const errors = [];
28
34
  const expected = resolveIdField(ct, records[0]);
29
35
  for (const rec of records) {
30
- const present = ["recordId", "entityId", "eventId", "knowledgeId", "memoryId"].filter((k) => rec?.[k] !== undefined);
31
- if (present.length === 0) {
36
+ if (rec?.[expected] === undefined) {
32
37
  errors.push(`${objectType}/${ct}: record missing identity key (expected ${expected})`);
33
38
  }
34
- else if (present.length > 1) {
35
- errors.push(`${objectType}/${ct}: record has multiple identity keys [${present.join(", ")}]`);
36
- }
37
39
  }
38
40
  return { ok: errors.length === 0, errors };
39
41
  }
@@ -13,15 +13,31 @@ export function loadWriteDescriptor(writeDescriptorId) {
13
13
  }
14
14
  /**
15
15
  * Validate input against the write descriptor schema (lightweight AJV-free check).
16
+ *
17
+ * `operation` controls how `required` is enforced:
18
+ * - `add` / `replace`: every field in `schema.required` must be present.
19
+ * - `upsert` / `patch` / `delete`: only the collection's identity field
20
+ * (e.g. `recordId`, `memoryId`) is required; other `required` entries are
21
+ * ignored because partial updates are allowed.
22
+ *
23
+ * Type checks on whatever fields ARE present always run.
16
24
  */
17
- export function validateInputSchema(writeSpec, input) {
25
+ export function validateInputSchema(writeSpec, input, operation = "add") {
18
26
  const errors = [];
19
27
  const schema = (writeSpec.schema || {});
20
28
  const payload = Array.isArray(input) ? input : input ? [input] : [];
29
+ const partial = operation === "upsert" || operation === "patch" || operation === "delete";
21
30
  if (schema.required && Array.isArray(schema.required)) {
31
+ // Determine the identity field for this descriptor based on targetCollection.
32
+ const targetCollection = writeSpec.targetCollection || "";
33
+ const [, ctRaw] = targetCollection.split("/");
34
+ const idField = ctRaw === "memory" ? "memoryId" : "recordId";
22
35
  for (const req of schema.required) {
23
36
  for (const item of payload) {
24
37
  if (item?.[req] === undefined) {
38
+ // In partial modes, only the identity field is mandatory.
39
+ if (partial && req !== idField)
40
+ continue;
25
41
  errors.push(`Missing required field: ${req}`);
26
42
  }
27
43
  }
@@ -30,7 +46,7 @@ export function validateInputSchema(writeSpec, input) {
30
46
  if (schema.properties) {
31
47
  for (const item of payload) {
32
48
  for (const [field, def] of Object.entries(schema.properties)) {
33
- if (item[field] === undefined)
49
+ if (item[field] === undefined || item[field] === null)
34
50
  continue;
35
51
  if (def.type) {
36
52
  const actualType = Array.isArray(item[field]) ? "array" : typeof item[field];
@@ -58,8 +74,11 @@ export function parseTargetCollection(target) {
58
74
  }
59
75
  export function executeOperation(params) {
60
76
  const { objectType, ct } = parseTargetCollection(params.target);
61
- const collection = store.getCollection(objectType, ct);
77
+ // Work on a copy so dry runs cannot leak into the live in-memory store and
78
+ // later get persisted by an unrelated write.
79
+ const collection = [...store.getCollection(objectType, ct)];
62
80
  const payload = Array.isArray(params.data) ? params.data : [params.data];
81
+ const persist = params.persist !== false;
63
82
  // Resolve the identity field for this collection tier.
64
83
  // Memory tier uses memoryId; everything else defaults to recordId.
65
84
  const idField = ct === "memory" ? "memoryId" : "recordId";
@@ -95,10 +114,35 @@ export function executeOperation(params) {
95
114
  break;
96
115
  }
97
116
  case "replace": {
117
+ if (persist)
118
+ store.setCollection(objectType, ct, payload);
98
119
  return payload;
99
120
  }
121
+ case "delete": {
122
+ const removed = [];
123
+ for (const item of payload) {
124
+ const id = item[idField] ?? item.recordId ?? item.id;
125
+ if (id === undefined)
126
+ continue;
127
+ let i = collection.length;
128
+ while (i--) {
129
+ const candidate = collection[i];
130
+ if ((candidate[idField] ?? candidate.recordId ?? candidate.id) === id) {
131
+ removed.push(collection.splice(i, 1)[0]);
132
+ }
133
+ }
134
+ }
135
+ if (persist)
136
+ store.setCollection(objectType, ct, collection);
137
+ return removed;
138
+ }
139
+ default:
140
+ throw Object.assign(new Error(`Unsupported write operation: ${params.op}`), {
141
+ statusCode: 400,
142
+ });
100
143
  }
101
- store.setCollection(objectType, ct, collection);
144
+ if (persist)
145
+ store.setCollection(objectType, ct, collection);
102
146
  return payload;
103
147
  }
104
148
  function getNextId(collection, idField = "recordId") {
@@ -10,6 +10,9 @@ import { getMergedNarratives, getRawNarratives, queryNarrativeRecords, } from ".
10
10
  import { computeRootPropertyCatalog } from "../engine/objectTypes.js";
11
11
  import { loadWriteDescriptor, validateInputSchema, executeOperation, } from "../engine/write.js";
12
12
  import { planAssociations, applyAssociations, verifyAssociations, } from "../engine/associations.js";
13
+ import { MOCKS_DIR } from "../config.js";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
13
16
  export async function registerRoutes(app) {
14
17
  // ---------- A. Health & System Diagnostics ----------
15
18
  app.get("/health", async () => ({ ok: true }));
@@ -296,18 +299,24 @@ export async function registerRoutes(app) {
296
299
  return { objectType: name, computed: true, rootPropertyCatalog: entries };
297
300
  });
298
301
  // ---------- H. Record Write Engine ----------
299
- app.post("/api/explorer/records/write", async (request) => {
302
+ app.post("/api/explorer/records/write", async (request, reply) => {
300
303
  const { writeDescriptorId, operation = "add", input, records, dryRun = false, validateOnly = false, } = (request.body || {});
301
304
  if (!writeDescriptorId)
302
- return { error: "writeDescriptorId required", ok: false };
305
+ return reply.code(400).send({ error: "writeDescriptorId required", ok: false });
306
+ if (!["add", "upsert", "patch", "replace", "delete"].includes(operation)) {
307
+ return reply.code(400).send({ ok: false, error: `Unsupported operation: ${operation}` });
308
+ }
303
309
  const writeSpec = loadWriteDescriptor(writeDescriptorId);
304
310
  const targetCollection = writeSpec.targetCollection;
305
311
  if (!targetCollection)
306
- return { error: "writeDescriptor missing targetCollection", ok: false };
307
- const data = input || records;
308
- const validation = validateInputSchema(writeSpec, data);
312
+ return reply.code(400).send({ error: "writeDescriptor missing targetCollection", ok: false });
313
+ const data = input ?? records;
314
+ if (data === undefined || data === null) {
315
+ return reply.code(400).send({ ok: false, error: "input or records required" });
316
+ }
317
+ const validation = validateInputSchema(writeSpec, data, operation);
309
318
  if (!validation.ok) {
310
- return { ok: false, validated: false, errors: validation.errors };
319
+ return reply.code(400).send({ ok: false, validated: false, errors: validation.errors });
311
320
  }
312
321
  if (validateOnly)
313
322
  return { ok: true, validated: true };
@@ -315,11 +324,40 @@ export async function registerRoutes(app) {
315
324
  target: targetCollection,
316
325
  op: operation,
317
326
  data,
327
+ persist: !dryRun,
318
328
  });
319
329
  if (!dryRun) {
320
330
  await store.flushAll();
321
331
  }
322
- return { ok: true, count: results.length, dryRun };
332
+ return { ok: true, count: results.length, operation, dryRun, results };
333
+ });
334
+ app.delete("/api/explorer/records/item", async (request, reply) => {
335
+ const { entityName, recordId, writeDescriptorId } = (request.query || {});
336
+ if (!entityName || !recordId || !writeDescriptorId) {
337
+ return reply.code(400).send({
338
+ ok: false,
339
+ error: "entityName, recordId, and writeDescriptorId query params are required",
340
+ });
341
+ }
342
+ const writeSpec = loadWriteDescriptor(writeDescriptorId);
343
+ const expectedTarget = `${entityName}/snapshots`;
344
+ if (writeSpec.targetCollection !== expectedTarget) {
345
+ return reply.code(400).send({
346
+ ok: false,
347
+ error: `Write descriptor targets ${writeSpec.targetCollection}, not ${expectedTarget}`,
348
+ });
349
+ }
350
+ const validation = validateInputSchema(writeSpec, { recordId }, "delete");
351
+ if (!validation.ok) {
352
+ return reply.code(400).send({ ok: false, validated: false, errors: validation.errors });
353
+ }
354
+ const removed = executeOperation({
355
+ target: writeSpec.targetCollection,
356
+ op: "delete",
357
+ data: { recordId },
358
+ });
359
+ await store.flushAll();
360
+ return { ok: true, count: removed.length, removed };
323
361
  });
324
362
  app.get("/api/explorer/object-types/:name", async (request, reply) => {
325
363
  const { name } = request.params;
@@ -339,6 +377,18 @@ export async function registerRoutes(app) {
339
377
  token_type: "Bearer",
340
378
  expires_in: 3600,
341
379
  }));
380
+ // ---- Demo UI ----
381
+ // The demo app served at /demo uses the Explorer API exclusively for its
382
+ // CRUD (records/collection, records/write, DELETE records/item). No separate
383
+ // /api/state flow is needed.
384
+ app.get("/demo", async (_request, reply) => {
385
+ const demoPath = path.join(MOCKS_DIR, "demo.html");
386
+ if (!fs.existsSync(demoPath)) {
387
+ return reply.code(404).send({ ok: false, error: "demo.html not found" });
388
+ }
389
+ const html = fs.readFileSync(demoPath, "utf8");
390
+ return reply.type("text/html").send(html);
391
+ });
342
392
  }
343
393
  function send404(reply, message) {
344
394
  return reply.code(404).send({ ok: false, error: message });
package/dist/server.js CHANGED
@@ -1,36 +1,58 @@
1
1
  #!/usr/bin/env node
2
2
  import Fastify from "fastify";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
  import { store } from "./storage/InMemoryStore.js";
4
6
  import { registerRoutes } from "./routes/index.js";
5
7
  import { PORT, HOST, MOCKS_DIR } from "./config.js";
6
- async function main() {
7
- const app = Fastify({ logger: false });
8
+ /** Build a loaded Fastify instance without opening a network port. */
9
+ export async function buildServer(options = {}) {
10
+ const app = Fastify({ logger: options.logger ?? false });
8
11
  // Global error handler matching predictable error shapes.
9
12
  app.setErrorHandler((error, _request, reply) => {
10
- const status = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
13
+ const normalized = error instanceof Error
14
+ ? error
15
+ : new Error("Internal Server Error");
16
+ const status = normalized.statusCode && normalized.statusCode >= 400
17
+ ? normalized.statusCode
18
+ : 500;
11
19
  reply.status(status).send({
12
20
  ok: false,
13
- error: error.message || "Internal Server Error",
21
+ error: normalized.message || "Internal Server Error",
14
22
  });
15
23
  });
16
24
  await store.load();
17
25
  await registerRoutes(app);
18
- const port = Number(process.env.PORT || PORT);
19
- await app.listen({ port, host: HOST });
26
+ return app;
27
+ }
28
+ /** Build and listen. Options override PORT/HOST environment configuration. */
29
+ export async function startServer(options = {}) {
30
+ const app = await buildServer(options);
31
+ const port = options.port ?? PORT;
32
+ const host = options.host ?? HOST;
33
+ await app.listen({ port, host });
20
34
  // eslint-disable-next-line no-console
21
- console.log(`[mock-memorix-explorer-api] listening on ${HOST}:${port}`);
35
+ console.log(`[mock-memorix-explorer-api] listening on ${host}:${port}`);
22
36
  console.log(`[mock-memorix-explorer-api] mocks dir: ${MOCKS_DIR}`);
23
- const shutdown = async () => {
24
- console.log("\n[mock-memorix-explorer-api] flushing + shutting down");
25
- await store.flushAll();
26
- await app.close();
27
- process.exit(0);
28
- };
29
- process.on("SIGINT", shutdown);
30
- process.on("SIGTERM", shutdown);
37
+ if (options.installSignalHandlers ?? true) {
38
+ const shutdown = async () => {
39
+ console.log("\n[mock-memorix-explorer-api] flushing + shutting down");
40
+ await store.flushAll();
41
+ await app.close();
42
+ process.exit(0);
43
+ };
44
+ process.once("SIGINT", shutdown);
45
+ process.once("SIGTERM", shutdown);
46
+ }
47
+ return app;
48
+ }
49
+ const isCliEntry = process.argv[1]
50
+ ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
51
+ : false;
52
+ if (isCliEntry) {
53
+ startServer().catch((err) => {
54
+ // eslint-disable-next-line no-console
55
+ console.error(err);
56
+ process.exit(1);
57
+ });
31
58
  }
32
- main().catch((err) => {
33
- // eslint-disable-next-line no-console
34
- console.error(err);
35
- process.exit(1);
36
- });