@x12i/static-memorix-explorer-api 1.0.0 → 1.1.0
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 +103 -12
- package/dist/engine/identity.js +11 -9
- package/dist/engine/write.js +48 -4
- package/dist/routes/index.js +57 -7
- package/dist/server.js +7 -2
- package/guides/demo-app.md +524 -0
- package/guides/managing-json-files.md +310 -0
- package/guides/running-the-service.md +136 -0
- package/guides/using-the-api.md +265 -0
- package/mocks/data/admin/snapshots.json +52 -0
- package/mocks/data/assets/analysis.json +14 -0
- package/mocks/data/assets/decisions.json +14 -0
- package/mocks/data/assets/events.json +4 -0
- package/mocks/data/assets/snapshots.json +49 -0
- package/mocks/data/findings/analysis.json +4 -0
- package/mocks/data/findings/decisions.json +4 -0
- package/mocks/data/findings/events.json +3 -0
- package/mocks/data/findings/snapshots.json +21 -0
- package/mocks/data/marketing/snapshots.json +75 -0
- package/mocks/data/memory/memory.json +31 -0
- package/mocks/data/product/snapshots.json +84 -0
- package/mocks/data/system/inventory.json +8 -0
- package/mocks/data/users/snapshots.json +5 -0
- package/mocks/demo.html +828 -0
- package/mocks/metadata/agents.json +7 -0
- package/mocks/metadata/lists/assets-default.json +22 -0
- package/mocks/metadata/lists/backlog.json +10 -0
- package/mocks/metadata/lists/findings-default.json +9 -0
- package/mocks/metadata/lists/my-plate.json +10 -0
- package/mocks/metadata/lists/this-week.json +10 -0
- package/mocks/metadata/narratives/admin.json +7 -0
- package/mocks/metadata/narratives/assets.json +17 -0
- package/mocks/metadata/narratives/findings.json +7 -0
- package/mocks/metadata/narratives/marketing.json +17 -0
- package/mocks/metadata/narratives/product.json +12 -0
- package/mocks/metadata/object-types/admin.json +6 -0
- package/mocks/metadata/object-types/assets.json +9 -0
- package/mocks/metadata/object-types/findings.json +8 -0
- package/mocks/metadata/object-types/marketing.json +6 -0
- package/mocks/metadata/object-types/memory.json +6 -0
- package/mocks/metadata/object-types/product.json +6 -0
- package/mocks/metadata/object-types/users.json +13 -0
- package/mocks/metadata/write-descriptors/admin-task-write.json +20 -0
- package/mocks/metadata/write-descriptors/assets-analysis-write.json +14 -0
- package/mocks/metadata/write-descriptors/marketing-task-write.json +20 -0
- package/mocks/metadata/write-descriptors/memory-write.json +14 -0
- package/mocks/metadata/write-descriptors/product-task-write.json +20 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
# @x12i/
|
|
1
|
+
# @x12i/static-memorix-explorer-api
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
@@ -11,6 +15,9 @@ npm install
|
|
|
11
15
|
npm run build
|
|
12
16
|
npm start # listens on :4300 (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:4300/demo
|
|
14
21
|
```
|
|
15
22
|
|
|
16
23
|
Health:
|
|
@@ -23,15 +30,15 @@ curl "localhost:4300/api/explorer/health?includeInventory=1"
|
|
|
23
30
|
## Architecture
|
|
24
31
|
|
|
25
32
|
```
|
|
26
|
-
Fastify Router (/api/explorer/* + /health + Authix/M2M shim)
|
|
33
|
+
Fastify Router (/api/explorer/* + /health + Authix/M2M shim + /demo)
|
|
27
34
|
│
|
|
28
35
|
Query & Interceptor Engine (mingo + narrative virtual filters)
|
|
29
|
-
|
|
36
|
+
┌────────────┬──────────────────┬──────────────┐
|
|
30
37
|
Inventory/Lenses Snapshots/Aliases Records Writer
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
└────────────┴──────────────────┴──────────────┘
|
|
39
|
+
InMemoryStore (state matrix)
|
|
40
|
+
│ debounced disk flush
|
|
41
|
+
./mocks/metadata & ./mocks/data
|
|
35
42
|
```
|
|
36
43
|
|
|
37
44
|
## Routes
|
|
@@ -41,13 +48,14 @@ Inventory/Lenses Snapshots/Aliases Records Writer
|
|
|
41
48
|
| Health | `GET /health`, `GET /api/explorer/health?includeInventory=1` |
|
|
42
49
|
| Inventory | `/inventory/collections`, `/summary`, `/issues`, `/graph` (query `sourceLens=db-first\|catalox-first`) |
|
|
43
50
|
| 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` |
|
|
51
|
+
| Records | `/records/collection`, `/full`, `/item`, `/content`, `/raw-collection`, `/raw-item`, `/workspace`, `POST /records/write`, `DELETE /records/item` |
|
|
45
52
|
| Lists | `/lists`, `/lists/:listId`, `/lists/:listId/records`, `/lists/suggest`, mutations `POST/PATCH/PUT/DELETE` |
|
|
46
53
|
| Narratives | `/narratives`, `/:entity`, `/:entity/raw`, `/:entity/:key`, `/:entity/:key/records`, mutations `POST/PATCH/DELETE` |
|
|
47
54
|
| Object Types | `/object-types`, `/:name`, `/:name/root-property-catalog`, `POST .../compute` |
|
|
48
|
-
| Write | `POST /records/write` (validate / dryRun / upsert / patch / replace) |
|
|
55
|
+
| Write | `POST /records/write` (validate / dryRun / add / upsert / patch / replace / **delete**) |
|
|
49
56
|
| Agents | `GET /agents` |
|
|
50
57
|
| Auth shim | `POST /api/explorer/auth/token` |
|
|
58
|
+
| Demo | `GET /demo` — serves `mocks/demo.html` |
|
|
51
59
|
|
|
52
60
|
## Query conventions
|
|
53
61
|
|
|
@@ -79,6 +87,54 @@ Client `associated.data|discovery|analysis|{custom}` maps to storage
|
|
|
79
87
|
associated{Custom}`. Raw `associated*` fields are stripped from the returned
|
|
80
88
|
document and recomposed into a normalized `associated` bucket.
|
|
81
89
|
|
|
90
|
+
## Write engine
|
|
91
|
+
|
|
92
|
+
`POST /api/explorer/records/write` accepts an `operation` and routes the
|
|
93
|
+
payload through the write descriptor's JSON schema.
|
|
94
|
+
|
|
95
|
+
### Operations
|
|
96
|
+
|
|
97
|
+
| Operation | Behavior |
|
|
98
|
+
|-----------|----------|
|
|
99
|
+
| `add` | Append. Auto-generates an identity if missing. |
|
|
100
|
+
| `upsert` | Update existing by identity key, or append. |
|
|
101
|
+
| `patch` | Update existing by identity key. No-op if missing. |
|
|
102
|
+
| `replace` | Replace the entire collection (use with caution). |
|
|
103
|
+
| `delete` | Remove every record matching the supplied identity keys. Returns the removed records. |
|
|
104
|
+
|
|
105
|
+
### Required-field semantics
|
|
106
|
+
|
|
107
|
+
The `schema.required` array is enforced **fully** for `add` and `replace`.
|
|
108
|
+
For `upsert`, `patch`, and `delete`, only the identity field (`recordId`,
|
|
109
|
+
or `memoryId` on the memory tier) is required. This allows partial updates
|
|
110
|
+
like `{recordId, status}` without resupplying every required field — the
|
|
111
|
+
pattern the demo UI uses for every inline edit.
|
|
112
|
+
|
|
113
|
+
### REST-style delete
|
|
114
|
+
|
|
115
|
+
For ergonomics, you can also delete a single record with:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
DELETE /api/explorer/records/item?entityName=product&recordId=t1&writeDescriptorId=product-task-write
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Returns `{ ok: true, count: <n>, removed: [<record>…] }` and flushes immediately.
|
|
122
|
+
|
|
123
|
+
## Demo app
|
|
124
|
+
|
|
125
|
+
The `mocks/demo.html` reference UI (served at `GET /demo`) is a complete task
|
|
126
|
+
manager that uses the Explorer API for every read and mutation. It demonstrates:
|
|
127
|
+
|
|
128
|
+
- Multi-collection reads (3 parallel `records/collection` calls + a `users` collection)
|
|
129
|
+
- Filter / search / sort via query params
|
|
130
|
+
- **Create** via modal → `POST /records/write op=add`
|
|
131
|
+
- **Edit** via inline pills/dropdowns → `POST /records/write op=upsert` (partial updates)
|
|
132
|
+
- **Delete** via button → `DELETE /records/item`
|
|
133
|
+
- Cross-collection **move** (delete on old area + add on new area)
|
|
134
|
+
- Optimistic UI with rollback on failure
|
|
135
|
+
|
|
136
|
+
Full implementation walkthrough: [guides/demo-app.md](./guides/demo-app.md).
|
|
137
|
+
|
|
82
138
|
## Environment
|
|
83
139
|
|
|
84
140
|
| Var | Default | Notes |
|
|
@@ -99,7 +155,7 @@ document and recomposed into a normalized `associated` bucket.
|
|
|
99
155
|
The mock server does not connect to MongoDB, but it **tricks the frontend** into
|
|
100
156
|
thinking it is talking to the routed tenant databases.
|
|
101
157
|
|
|
102
|
-
- The `GET /api/explorer/health` response
|
|
158
|
+
- The `GET /api/explorer/health` response includes a dynamic `memorixDb`
|
|
103
159
|
field constructed as `<orgId>-memorix-entities + <orgId>-memorix-events`.
|
|
104
160
|
- The agent-scoped catalox DB is reflected in the `cataloxDb` field as
|
|
105
161
|
`<agentId>-memorix-catalox`.
|
|
@@ -118,6 +174,9 @@ The mock server enforces the following feature-flag gates with error responses:
|
|
|
118
174
|
- Registry writes (if implemented) require
|
|
119
175
|
`MEMORIX_EXPLORER_ENABLE_REGISTRY_WRITES=true`.
|
|
120
176
|
|
|
177
|
+
Record writes (`POST /records/write`, `DELETE /records/item`) are **not**
|
|
178
|
+
feature-flagged — they're always on, gated only by the write descriptor contract.
|
|
179
|
+
|
|
121
180
|
When a flag is disabled or unset, the mock returns `{ ok: false, error: "..." }`
|
|
122
181
|
matching the production Fastify error shape (typically 403/404).
|
|
123
182
|
|
|
@@ -134,4 +193,36 @@ and `knowledgeId`.
|
|
|
134
193
|
- Records engine accepts `target=memory` and supports `memoryId` for fetching.
|
|
135
194
|
- Write engine validates and persists `memoryId` on memory-tier records.
|
|
136
195
|
- Startup validation (`MemorixRecordArraySchema` mock) allows `memoryId` without
|
|
137
|
-
throwing schema errors.
|
|
196
|
+
throwing schema errors.
|
|
197
|
+
|
|
198
|
+
## Documentation
|
|
199
|
+
|
|
200
|
+
- [guides/running-the-service.md](./guides/running-the-service.md) — installation, configuration, env vars
|
|
201
|
+
- [guides/using-the-api.md](./guides/using-the-api.md) — endpoint reference, query language, write operations
|
|
202
|
+
- [guides/managing-json-files.md](./guides/managing-json-files.md) — fixture structure, schemas, how the store works
|
|
203
|
+
- [guides/demo-app.md](./guides/demo-app.md) — FlowState reference UI walkthrough
|
|
204
|
+
|
|
205
|
+
## Release history
|
|
206
|
+
|
|
207
|
+
### v1.1.0
|
|
208
|
+
- **Added**: `delete` operation to the write engine
|
|
209
|
+
(`POST /records/write` body `operation:"delete"`).
|
|
210
|
+
- **Added**: REST-style `DELETE /api/explorer/records/item` endpoint.
|
|
211
|
+
- **Added**: Operation-aware schema validation — `upsert`/`patch`/`delete`
|
|
212
|
+
only require the identity field, not every `required` field.
|
|
213
|
+
- **Added**: Complete demo application (`mocks/demo.html`) wired entirely
|
|
214
|
+
through the Explorer API — Create/Edit/Delete/Move with optimistic UI.
|
|
215
|
+
- **Added**: `users` object type + collection.
|
|
216
|
+
- **Added**: Per-area task write descriptors
|
|
217
|
+
(`product/marketing/admin-task-write`).
|
|
218
|
+
- **Added**: Per-area narratives and enriched task snapshots
|
|
219
|
+
(`notes`, `chat`, `assigneeId`).
|
|
220
|
+
- **Removed**: obsolete `/api/state` flow and `mocks/db.json`
|
|
221
|
+
(replaced by Explorer API writes).
|
|
222
|
+
- **Docs**: full demo walkthrough (`guides/demo-app.md`) and refreshed
|
|
223
|
+
endpoint tables.
|
|
224
|
+
|
|
225
|
+
### v1.0.0
|
|
226
|
+
- Initial implementation of mock Memorix Explorer API.
|
|
227
|
+
- Health, inventory, snapshots, records, lists, narratives, object types,
|
|
228
|
+
write engine (add/upsert/patch/replace), agents, auth shim.
|
package/dist/engine/identity.js
CHANGED
|
@@ -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
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/dist/engine/write.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
144
|
+
if (persist)
|
|
145
|
+
store.setCollection(objectType, ct, collection);
|
|
102
146
|
return payload;
|
|
103
147
|
}
|
|
104
148
|
function getNextId(collection, idField = "recordId") {
|
package/dist/routes/index.js
CHANGED
|
@@ -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
|
|
308
|
-
|
|
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
|
@@ -7,10 +7,15 @@ async function main() {
|
|
|
7
7
|
const app = Fastify({ logger: false });
|
|
8
8
|
// Global error handler matching predictable error shapes.
|
|
9
9
|
app.setErrorHandler((error, _request, reply) => {
|
|
10
|
-
const
|
|
10
|
+
const normalized = error instanceof Error
|
|
11
|
+
? error
|
|
12
|
+
: new Error("Internal Server Error");
|
|
13
|
+
const status = normalized.statusCode && normalized.statusCode >= 400
|
|
14
|
+
? normalized.statusCode
|
|
15
|
+
: 500;
|
|
11
16
|
reply.status(status).send({
|
|
12
17
|
ok: false,
|
|
13
|
-
error:
|
|
18
|
+
error: normalized.message || "Internal Server Error",
|
|
14
19
|
});
|
|
15
20
|
});
|
|
16
21
|
await store.load();
|