@x12i/static-memorix-explorer-api 1.0.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/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/config.js +75 -0
- package/dist/engine/aliases.js +74 -0
- package/dist/engine/associations.js +60 -0
- package/dist/engine/identity.js +48 -0
- package/dist/engine/inventory.js +133 -0
- package/dist/engine/lists.js +66 -0
- package/dist/engine/narratives.js +77 -0
- package/dist/engine/objectTypes.js +71 -0
- package/dist/engine/query.js +191 -0
- package/dist/engine/records.js +152 -0
- package/dist/engine/snapshots.js +110 -0
- package/dist/engine/write.js +123 -0
- package/dist/routes/helpers.js +29 -0
- package/dist/routes/index.js +345 -0
- package/dist/server.js +36 -0
- package/dist/storage/InMemoryStore.js +188 -0
- package/dist/storage/fs.js +39 -0
- package/dist/types.js +37 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 x12i
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# @x12i/mock-memorix-explorer-api
|
|
2
|
+
|
|
3
|
+
In-memory mock server that provides **full API parity** for the Memorix Explorer
|
|
4
|
+
Fastify API. No MongoDB, Catalox, or Redis required — state lives in
|
|
5
|
+
`./mocks/**/*.json` and is flushed to disk (debounced) on mutation.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npm run build
|
|
12
|
+
npm start # listens on :4300 (PORT / HOST / MOCKS_DIR env-overridable)
|
|
13
|
+
# dev (no build): npm run dev
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Health:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
curl localhost:4300/health
|
|
20
|
+
curl "localhost:4300/api/explorer/health?includeInventory=1"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Fastify Router (/api/explorer/* + /health + Authix/M2M shim)
|
|
27
|
+
│
|
|
28
|
+
Query & Interceptor Engine (mingo + narrative virtual filters)
|
|
29
|
+
┌────────────┬──────────────────┬──────────────┐
|
|
30
|
+
Inventory/Lenses Snapshots/Aliases Records Writer
|
|
31
|
+
└────────────┴──────────────────┴──────────────┘
|
|
32
|
+
InMemoryStore (state matrix)
|
|
33
|
+
│ debounced disk flush
|
|
34
|
+
./mocks/metadata & ./mocks/data
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Routes
|
|
38
|
+
|
|
39
|
+
| Group | Endpoints |
|
|
40
|
+
|------|-----------|
|
|
41
|
+
| Health | `GET /health`, `GET /api/explorer/health?includeInventory=1` |
|
|
42
|
+
| Inventory | `/inventory/collections`, `/summary`, `/issues`, `/graph` (query `sourceLens=db-first\|catalox-first`) |
|
|
43
|
+
| 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` |
|
|
45
|
+
| Lists | `/lists`, `/lists/:listId`, `/lists/:listId/records`, `/lists/suggest`, mutations `POST/PATCH/PUT/DELETE` |
|
|
46
|
+
| Narratives | `/narratives`, `/:entity`, `/:entity/raw`, `/:entity/:key`, `/:entity/:key/records`, mutations `POST/PATCH/DELETE` |
|
|
47
|
+
| Object Types | `/object-types`, `/:name`, `/:name/root-property-catalog`, `POST .../compute` |
|
|
48
|
+
| Write | `POST /records/write` (validate / dryRun / upsert / patch / replace) |
|
|
49
|
+
| Agents | `GET /agents` |
|
|
50
|
+
| Auth shim | `POST /api/explorer/auth/token` |
|
|
51
|
+
|
|
52
|
+
## Query conventions
|
|
53
|
+
|
|
54
|
+
- `filter=prop:op:val` — op ∈ `eq,ne,gt,gte,lt,lte,in,nin,exists,regex`
|
|
55
|
+
(`in`/`nin` use `|` separators). Repeatable / `;`-separated.
|
|
56
|
+
- Virtual narrative filter: `filter=narrativeId:eq:has-vulns` rewrites the
|
|
57
|
+
query to evaluate `doc.narratives['has-vulns']`.
|
|
58
|
+
- `sort=severity:desc&sort=entityId:asc` (mingo sort).
|
|
59
|
+
- `searchText=` / `q=` full-text substring match.
|
|
60
|
+
- `limit` (max 500), `offset`/`skip`, `includeTotal=1`.
|
|
61
|
+
- `target=memory` to query the memory tier (fourth data tier alongside entities/events/knowledge).
|
|
62
|
+
|
|
63
|
+
## Identity keys
|
|
64
|
+
|
|
65
|
+
The mock server accepts the following identity keys across fetch/write engines:
|
|
66
|
+
- `recordId` — entities
|
|
67
|
+
- `entityId` — entities
|
|
68
|
+
- `eventId` — events
|
|
69
|
+
- `knowledgeId` — knowledge
|
|
70
|
+
- `memoryId` — memory
|
|
71
|
+
|
|
72
|
+
Endpoints like `/records/full` and `/records/content` require exactly one identity key
|
|
73
|
+
per request. Memory collections are keyed by `memoryId` and stored in `./mocks/data/memory/memory.json`.
|
|
74
|
+
|
|
75
|
+
## Snapshot alias translation
|
|
76
|
+
|
|
77
|
+
Client `associated.data|discovery|analysis|{custom}` maps to storage
|
|
78
|
+
`associatedData | associatedInferred/associatedDiscovery | associatedAnalysis |
|
|
79
|
+
associated{Custom}`. Raw `associated*` fields are stripped from the returned
|
|
80
|
+
document and recomposed into a normalized `associated` bucket.
|
|
81
|
+
|
|
82
|
+
## Environment
|
|
83
|
+
|
|
84
|
+
| Var | Default | Notes |
|
|
85
|
+
|-----|---------|-------|
|
|
86
|
+
| `PORT` | `4300` | Listen port |
|
|
87
|
+
| `HOST` | `0.0.0.0` | Listen host |
|
|
88
|
+
| `MOCKS_DIR` | `<project>/mocks` | Fixture root |
|
|
89
|
+
| `DISK_FLUSH_DEBOUNCE_MS` | `300` | Debounce before disk flush |
|
|
90
|
+
| `MEMORIX_ORG_ID` | `memorix` | Organization prefix for DB routing |
|
|
91
|
+
| `MEMORIX_AGENT_ID` | `default-agent` | Agent prefix for catalox DB routing |
|
|
92
|
+
| `MEMORIX_DEPLOYMENT_PROFILE` | `default` | Deployment profile; `ebook` forces org/agent to `ebooks` |
|
|
93
|
+
| `MEMORIX_EXPLORER_ENABLE_METADATA_WRITES` | `false` | Allow list & narrative mutations |
|
|
94
|
+
| `MEMORIX_EXPLORER_ENABLE_PIPELINE_WRITES` | `false` | Allow pipeline writes |
|
|
95
|
+
| `MEMORIX_EXPLORER_ENABLE_REGISTRY_WRITES` | `false` | Allow registry writes |
|
|
96
|
+
|
|
97
|
+
## DB Router Simulation
|
|
98
|
+
|
|
99
|
+
The mock server does not connect to MongoDB, but it **tricks the frontend** into
|
|
100
|
+
thinking it is talking to the routed tenant databases.
|
|
101
|
+
|
|
102
|
+
- The `GET /api/explorer/health` response now includes a dynamic `memorixDb`
|
|
103
|
+
field constructed as `<orgId>-memorix-entities + <orgId>-memorix-events`.
|
|
104
|
+
- The agent-scoped catalox DB is reflected in the `cataloxDb` field as
|
|
105
|
+
`<agentId>-memorix-catalox`.
|
|
106
|
+
|
|
107
|
+
These strings are derived from `MEMORIX_ORG_ID`, `MEMORIX_AGENT_ID`, and the
|
|
108
|
+
`MEMORIX_DEPLOYMENT_PROFILE` (which, when set to `ebook`, forces both IDs to
|
|
109
|
+
`ebooks`).
|
|
110
|
+
|
|
111
|
+
## Feature Flag Enforcement
|
|
112
|
+
|
|
113
|
+
The mock server enforces the following feature-flag gates with error responses:
|
|
114
|
+
- List/narrative mutations (`POST/PATCH/PUT/DELETE /lists`, `/narratives`) require
|
|
115
|
+
`MEMORIX_EXPLORER_ENABLE_METADATA_WRITES=true`.
|
|
116
|
+
- Pipeline writes (if implemented) require
|
|
117
|
+
`MEMORIX_EXPLORER_ENABLE_PIPELINE_WRITES=true`.
|
|
118
|
+
- Registry writes (if implemented) require
|
|
119
|
+
`MEMORIX_EXPLORER_ENABLE_REGISTRY_WRITES=true`.
|
|
120
|
+
|
|
121
|
+
When a flag is disabled or unset, the mock returns `{ ok: false, error: "..." }`
|
|
122
|
+
matching the production Fastify error shape (typically 403/404).
|
|
123
|
+
|
|
124
|
+
## The "memory" Target
|
|
125
|
+
|
|
126
|
+
The API has expanded its domain model to include a fourth tier of data: `memory`.
|
|
127
|
+
The mock's querying and writing engines now support `target=memory` and
|
|
128
|
+
`memoryId` as a valid primary key alongside `recordId`, `entityId`, `eventId`,
|
|
129
|
+
and `knowledgeId`.
|
|
130
|
+
|
|
131
|
+
- Folder structure: `./mocks/data/memory/memory.json` (seeded with example memories).
|
|
132
|
+
- Inventory engine accepts `target=memory` and includes memory collections in
|
|
133
|
+
the simulated `inventorySummary`.
|
|
134
|
+
- Records engine accepts `target=memory` and supports `memoryId` for fetching.
|
|
135
|
+
- Write engine validates and persists `memoryId` on memory-tier records.
|
|
136
|
+
- Startup validation (`MemorixRecordArraySchema` mock) allows `memoryId` without
|
|
137
|
+
throwing schema errors.
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = path.dirname(__filename);
|
|
5
|
+
export const ROOT_DIR = path.resolve(__dirname);
|
|
6
|
+
export const MOCKS_DIR = process.env.MOCKS_DIR
|
|
7
|
+
? path.resolve(process.env.MOCKS_DIR)
|
|
8
|
+
: path.resolve(ROOT_DIR, "..", "mocks");
|
|
9
|
+
export const METADATA_DIR = path.join(MOCKS_DIR, "metadata");
|
|
10
|
+
export const DATA_DIR = path.join(MOCKS_DIR, "data");
|
|
11
|
+
export const PORT = Number(process.env.PORT || 4300);
|
|
12
|
+
export const HOST = process.env.HOST || "0.0.0.0";
|
|
13
|
+
export const DISK_FLUSH_DEBOUNCE_MS = Number(process.env.DISK_FLUSH_DEBOUNCE_MS || 300);
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// DB Router Simulation (@x12i/memorix-db-router)
|
|
16
|
+
// The mock does not connect to MongoDB, but it must convince the frontend that
|
|
17
|
+
// it is talking to the routed tenant databases.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
function resolveOrgId() {
|
|
20
|
+
if ((process.env.MEMORIX_DEPLOYMENT_PROFILE || "").toLowerCase() === "ebook") {
|
|
21
|
+
return "ebooks";
|
|
22
|
+
}
|
|
23
|
+
return process.env.MEMORIX_ORG_ID || "memorix";
|
|
24
|
+
}
|
|
25
|
+
function resolveAgentId() {
|
|
26
|
+
if ((process.env.MEMORIX_DEPLOYMENT_PROFILE || "").toLowerCase() === "ebook") {
|
|
27
|
+
return "ebooks";
|
|
28
|
+
}
|
|
29
|
+
return process.env.MEMORIX_AGENT_ID || "default-agent";
|
|
30
|
+
}
|
|
31
|
+
export const MEMORIX_ORG_ID = resolveOrgId();
|
|
32
|
+
export const MEMORIX_AGENT_ID = resolveAgentId();
|
|
33
|
+
export const MEMORIX_DEPLOYMENT_PROFILE = process.env.MEMORIX_DEPLOYMENT_PROFILE || "default";
|
|
34
|
+
/**
|
|
35
|
+
* Construct the router string the frontend expects.
|
|
36
|
+
* e.g. "neo-memorix-entities + neo-memorix-events" for MEMORIX_ORG_ID=neo
|
|
37
|
+
*/
|
|
38
|
+
export function buildMemorixDbString() {
|
|
39
|
+
const org = MEMORIX_ORG_ID;
|
|
40
|
+
return `${org}-memorix-entities + ${org}-memorix-events`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Construct the agent-scoped Catalox DB name.
|
|
44
|
+
* e.g. "ebooks-memorix-catalox"
|
|
45
|
+
*/
|
|
46
|
+
export function buildCataloxDbString() {
|
|
47
|
+
return `${MEMORIX_AGENT_ID}-memorix-catalox`;
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Feature Flag Enforcement
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
function flagEnabled(name) {
|
|
53
|
+
const v = process.env[name];
|
|
54
|
+
return v === "1" || v === "true";
|
|
55
|
+
}
|
|
56
|
+
export const FLAGS = {
|
|
57
|
+
METADATA_WRITES: flagEnabled("MEMORIX_EXPLORER_ENABLE_METADATA_WRITES"),
|
|
58
|
+
PIPELINE_WRITES: flagEnabled("MEMORIX_EXPLORER_ENABLE_PIPELINE_WRITES"),
|
|
59
|
+
REGISTRY_WRITES: flagEnabled("MEMORIX_EXPLORER_ENABLE_REGISTRY_WRITES"),
|
|
60
|
+
};
|
|
61
|
+
export function metadataWritesEnabled() {
|
|
62
|
+
return FLAGS.METADATA_WRITES;
|
|
63
|
+
}
|
|
64
|
+
export function pipelineWritesEnabled() {
|
|
65
|
+
return FLAGS.PIPELINE_WRITES;
|
|
66
|
+
}
|
|
67
|
+
export function registryWritesEnabled() {
|
|
68
|
+
return FLAGS.REGISTRY_WRITES;
|
|
69
|
+
}
|
|
70
|
+
export function metadataPath(...segments) {
|
|
71
|
+
return path.join(METADATA_DIR, ...segments);
|
|
72
|
+
}
|
|
73
|
+
export function dataPath(...segments) {
|
|
74
|
+
return path.join(DATA_DIR, ...segments);
|
|
75
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export const STORAGE_ALIASES = {
|
|
2
|
+
data: {
|
|
3
|
+
clientKey: "data",
|
|
4
|
+
storageProp: "doc.associatedData",
|
|
5
|
+
fallbacks: ["associatedData"],
|
|
6
|
+
},
|
|
7
|
+
discovery: {
|
|
8
|
+
clientKey: "discovery",
|
|
9
|
+
storageProp: "doc.associatedInferred or doc.associatedDiscovery",
|
|
10
|
+
fallbacks: ["associatedInferred", "associatedDiscovery"],
|
|
11
|
+
},
|
|
12
|
+
analysis: {
|
|
13
|
+
clientKey: "analysis",
|
|
14
|
+
storageProp: "doc.associatedAnalysis",
|
|
15
|
+
fallbacks: ["associatedAnalysis"],
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Resolve a normalized associated bucket from a raw document.
|
|
20
|
+
* Strips raw `associated*` fields and reconstructs the `associated` bucket
|
|
21
|
+
* using the alias map. Custom associated properties follow the pattern
|
|
22
|
+
* associated{Custom}.
|
|
23
|
+
*/
|
|
24
|
+
export function buildAssociatedBucket(rawDoc) {
|
|
25
|
+
const associated = {};
|
|
26
|
+
const properties = [];
|
|
27
|
+
const strippedDoc = {};
|
|
28
|
+
for (const [k, v] of Object.entries(rawDoc)) {
|
|
29
|
+
if (k.startsWith("associated")) {
|
|
30
|
+
// Map known aliases: associatedData -> data, associatedInferred -> discovery, etc.
|
|
31
|
+
const clientKey = aliasStorageToClient(k);
|
|
32
|
+
associated[clientKey] = v;
|
|
33
|
+
properties.push({
|
|
34
|
+
propertyName: clientKey,
|
|
35
|
+
source: k,
|
|
36
|
+
count: Array.isArray(v) ? v.length : v != null ? 1 : 0,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
strippedDoc[k] = v;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { associated, strippedDoc, properties };
|
|
44
|
+
}
|
|
45
|
+
export function aliasStorageToClient(storageKey) {
|
|
46
|
+
switch (storageKey) {
|
|
47
|
+
case "associatedData":
|
|
48
|
+
return "data";
|
|
49
|
+
case "associatedInferred":
|
|
50
|
+
case "associatedDiscovery":
|
|
51
|
+
return "discovery";
|
|
52
|
+
case "associatedAnalysis":
|
|
53
|
+
return "analysis";
|
|
54
|
+
default:
|
|
55
|
+
// associated{Custom} -> {custom}
|
|
56
|
+
return storageKey.replace(/^associated/, "").replace(/^./, (c) => c.toLowerCase());
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function aliasClientToStorage(clientKey) {
|
|
60
|
+
const entry = STORAGE_ALIASES[clientKey];
|
|
61
|
+
if (entry) {
|
|
62
|
+
return { primary: entry.fallbacks[0], fallbacks: entry.fallbacks };
|
|
63
|
+
}
|
|
64
|
+
// custom -> associated{Custom}
|
|
65
|
+
const cap = clientKey.replace(/^./, (c) => c.toUpperCase());
|
|
66
|
+
const storage = `associated${cap}`;
|
|
67
|
+
return { primary: storage, fallbacks: [storage] };
|
|
68
|
+
}
|
|
69
|
+
export function resolveInclude(include) {
|
|
70
|
+
if (!include)
|
|
71
|
+
return null;
|
|
72
|
+
const list = Array.isArray(include) ? include : include.split(",");
|
|
73
|
+
return new Set(list.map((s) => s.trim()).filter(Boolean));
|
|
74
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { store } from "../storage/InMemoryStore.js";
|
|
2
|
+
import { CONTENT_TYPES } from "../types.js";
|
|
3
|
+
import { computeAssociationFingerprint } from "../engine/snapshots.js";
|
|
4
|
+
import { listDataObjectTypes } from "../engine/inventory.js";
|
|
5
|
+
export function planAssociations(objectType) {
|
|
6
|
+
const types = objectType ? [objectType] : listDataObjectTypes();
|
|
7
|
+
const plans = [];
|
|
8
|
+
for (const ot of types) {
|
|
9
|
+
const fingerprint = computeAssociationFingerprint(ot);
|
|
10
|
+
const mutations = [];
|
|
11
|
+
for (const ct of CONTENT_TYPES) {
|
|
12
|
+
const records = store.getCollection(ot, ct);
|
|
13
|
+
mutations.push({
|
|
14
|
+
recordId: `${ot}/${ct}`,
|
|
15
|
+
targetArray: `${ot}/${ct}`,
|
|
16
|
+
op: "noop",
|
|
17
|
+
count: records.length,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
plans.push({
|
|
21
|
+
objectType: ot,
|
|
22
|
+
expectedPlanFingerprint: fingerprint,
|
|
23
|
+
proposedMutations: mutations,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return plans;
|
|
27
|
+
}
|
|
28
|
+
export function applyAssociations(expectedPlanFingerprint, objectType) {
|
|
29
|
+
const types = objectType ? [objectType] : listDataObjectTypes();
|
|
30
|
+
const results = [];
|
|
31
|
+
for (const ot of types) {
|
|
32
|
+
const actual = computeAssociationFingerprint(ot);
|
|
33
|
+
const mismatch = actual !== expectedPlanFingerprint;
|
|
34
|
+
results.push({
|
|
35
|
+
objectType: ot,
|
|
36
|
+
applied: !mismatch,
|
|
37
|
+
expectedPlanFingerprint,
|
|
38
|
+
actualFingerprint: actual,
|
|
39
|
+
mismatch,
|
|
40
|
+
count: store.getCollection(ot, "snapshots").length,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// Trigger flush
|
|
44
|
+
for (const ot of types) {
|
|
45
|
+
store.flushCollectionNow(`${ot}/snapshots`);
|
|
46
|
+
}
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
export function verifyAssociations(expectedPlanFingerprint, objectType) {
|
|
50
|
+
const types = objectType ? [objectType] : listDataObjectTypes();
|
|
51
|
+
return types.map((ot) => {
|
|
52
|
+
const actual = computeAssociationFingerprint(ot);
|
|
53
|
+
return {
|
|
54
|
+
objectType: ot,
|
|
55
|
+
expectedPlanFingerprint,
|
|
56
|
+
actualFingerprint: actual,
|
|
57
|
+
verified: actual === expectedPlanFingerprint,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the primary identity field for a given object type / content type.
|
|
3
|
+
* `memory` collections are keyed by `memoryId`; everything else falls back to
|
|
4
|
+
* the per-document heuristic used elsewhere.
|
|
5
|
+
*/
|
|
6
|
+
export function resolveIdField(ct, sample) {
|
|
7
|
+
if (ct === "memory")
|
|
8
|
+
return "memoryId";
|
|
9
|
+
if (ct === "events")
|
|
10
|
+
return "eventId";
|
|
11
|
+
if (sample) {
|
|
12
|
+
if ("recordId" in sample)
|
|
13
|
+
return "recordId";
|
|
14
|
+
if ("entityId" in sample)
|
|
15
|
+
return "entityId";
|
|
16
|
+
if ("id" in sample)
|
|
17
|
+
return "id";
|
|
18
|
+
}
|
|
19
|
+
return "recordId";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 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.
|
|
25
|
+
*/
|
|
26
|
+
export function validateRecordArray(objectType, ct, records) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
const expected = resolveIdField(ct, records[0]);
|
|
29
|
+
for (const rec of records) {
|
|
30
|
+
const present = ["recordId", "entityId", "eventId", "knowledgeId", "memoryId"].filter((k) => rec?.[k] !== undefined);
|
|
31
|
+
if (present.length === 0) {
|
|
32
|
+
errors.push(`${objectType}/${ct}: record missing identity key (expected ${expected})`);
|
|
33
|
+
}
|
|
34
|
+
else if (present.length > 1) {
|
|
35
|
+
errors.push(`${objectType}/${ct}: record has multiple identity keys [${present.join(", ")}]`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ok: errors.length === 0, errors };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Find a record by any of the supported identity keys.
|
|
42
|
+
*/
|
|
43
|
+
export function findByIdentity(records, identity) {
|
|
44
|
+
const entries = Object.entries(identity).filter(([, v]) => v != null);
|
|
45
|
+
if (entries.length === 0)
|
|
46
|
+
return undefined;
|
|
47
|
+
return records.find((d) => entries.every(([k, v]) => String(d[k]) === String(v)));
|
|
48
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { store } from "../storage/InMemoryStore.js";
|
|
3
|
+
import { CONTENT_TYPES } from "../types.js";
|
|
4
|
+
import { listJsonFiles, basenameNoExt } from "../storage/fs.js";
|
|
5
|
+
import { METADATA_DIR, DATA_DIR } from "../config.js";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { isDir } from "../storage/fs.js";
|
|
8
|
+
export function listObjectTypes() {
|
|
9
|
+
const dir = path.join(METADATA_DIR, "object-types");
|
|
10
|
+
return listJsonFiles(dir).map((f) => basenameNoExt(f));
|
|
11
|
+
}
|
|
12
|
+
export function listDataObjectTypes() {
|
|
13
|
+
if (!isDir(DATA_DIR))
|
|
14
|
+
return [];
|
|
15
|
+
return fs
|
|
16
|
+
.readdirSync(DATA_DIR)
|
|
17
|
+
.filter((d) => isDir(path.join(DATA_DIR, d)) && d !== "system");
|
|
18
|
+
}
|
|
19
|
+
export function computeInventory(params) {
|
|
20
|
+
const sourceLens = params.sourceLens || "catalox-first";
|
|
21
|
+
const target = params.target;
|
|
22
|
+
const rows = [];
|
|
23
|
+
const catalogTypes = new Set(listObjectTypes());
|
|
24
|
+
const dataTypes = new Set(listDataObjectTypes());
|
|
25
|
+
// Determine which content types to scan. `target=memory` restricts to the
|
|
26
|
+
// memory tier; otherwise include all tiers (entities/events/knowledge/memory).
|
|
27
|
+
const contentTypes = target === "memory"
|
|
28
|
+
? ["memory"]
|
|
29
|
+
: CONTENT_TYPES;
|
|
30
|
+
if (sourceLens === "catalox-first") {
|
|
31
|
+
const objectTypes = target === "memory"
|
|
32
|
+
? ["memory"]
|
|
33
|
+
: [...catalogTypes, ...dataTypes];
|
|
34
|
+
for (const ot of objectTypes) {
|
|
35
|
+
for (const ct of contentTypes) {
|
|
36
|
+
const data = store.getCollection(ot, ct);
|
|
37
|
+
const isCatalog = catalogTypes.has(ot);
|
|
38
|
+
rows.push({
|
|
39
|
+
objectType: ot,
|
|
40
|
+
contentType: ct,
|
|
41
|
+
collection: `${ot}/${ct}`,
|
|
42
|
+
count: data.length,
|
|
43
|
+
status: data.length > 0 ? "populated" : "empty",
|
|
44
|
+
provenance: { source: isCatalog || ot === "memory" ? "catalog" : "orphan" },
|
|
45
|
+
missingCollections: !isCatalog && ot !== "memory",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// db-first: scan all array keys in store.data
|
|
52
|
+
const keys = store.getCollectionKeys().filter((k) => !k.startsWith("system/"));
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
const [ot, ct] = key.split("/");
|
|
55
|
+
if (target === "memory" && ct !== "memory")
|
|
56
|
+
continue;
|
|
57
|
+
const data = store.getCollection(ot, ct);
|
|
58
|
+
const mapped = catalogTypes.has(ot);
|
|
59
|
+
rows.push({
|
|
60
|
+
objectType: ot,
|
|
61
|
+
contentType: ct,
|
|
62
|
+
collection: key,
|
|
63
|
+
count: data.length,
|
|
64
|
+
status: data.length > 0 ? "populated" : "empty",
|
|
65
|
+
provenance: { source: mapped ? "catalog" : "orphan" },
|
|
66
|
+
missingCollections: !mapped,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
export function computeInventorySummary(rows, params) {
|
|
73
|
+
const declared = rows.length;
|
|
74
|
+
const populated = rows.filter((r) => r.status === "populated").length;
|
|
75
|
+
const empty = rows.filter((r) => r.status === "empty").length;
|
|
76
|
+
const orphans = rows.filter((r) => r.provenance.source === "orphan").length;
|
|
77
|
+
const summary = {
|
|
78
|
+
declaredCollections: declared,
|
|
79
|
+
populatedCollections: populated,
|
|
80
|
+
emptyCollections: empty,
|
|
81
|
+
orphanCollections: orphans,
|
|
82
|
+
};
|
|
83
|
+
if (params.includeExactCounts) {
|
|
84
|
+
summary.perObjectType = rows.reduce((acc, r) => {
|
|
85
|
+
acc[r.objectType] = acc[r.objectType] || {
|
|
86
|
+
populated: 0,
|
|
87
|
+
empty: 0,
|
|
88
|
+
total: 0,
|
|
89
|
+
};
|
|
90
|
+
acc[r.objectType].total++;
|
|
91
|
+
if (r.status === "populated")
|
|
92
|
+
acc[r.objectType].populated++;
|
|
93
|
+
else
|
|
94
|
+
acc[r.objectType].empty++;
|
|
95
|
+
return acc;
|
|
96
|
+
}, {});
|
|
97
|
+
}
|
|
98
|
+
return summary;
|
|
99
|
+
}
|
|
100
|
+
export function computeInventoryIssues(rows) {
|
|
101
|
+
return rows.filter((r) => r.provenance.source === "orphan" || r.missingCollections === true);
|
|
102
|
+
}
|
|
103
|
+
export function computeGraph(rows) {
|
|
104
|
+
const nodes = [];
|
|
105
|
+
const edges = [];
|
|
106
|
+
const nodeSet = new Set();
|
|
107
|
+
for (const r of rows) {
|
|
108
|
+
if (r.status !== "populated")
|
|
109
|
+
continue;
|
|
110
|
+
const nodeId = r.objectType;
|
|
111
|
+
if (!nodeSet.has(nodeId)) {
|
|
112
|
+
nodeSet.add(nodeId);
|
|
113
|
+
nodes.push({ id: nodeId, objectType: nodeId, populated: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const ot of nodeSet) {
|
|
117
|
+
const meta = store.getMeta(`object-types/${ot}`);
|
|
118
|
+
if (!meta)
|
|
119
|
+
continue;
|
|
120
|
+
const relations = meta.catalogRelations || [];
|
|
121
|
+
for (const rel of relations) {
|
|
122
|
+
const target = rel.targetObject;
|
|
123
|
+
if (!target || !nodeSet.has(target))
|
|
124
|
+
continue;
|
|
125
|
+
edges.push({
|
|
126
|
+
from: ot,
|
|
127
|
+
to: target,
|
|
128
|
+
relationType: rel.relationType || "related",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { nodes, edges };
|
|
133
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { store } from "../storage/InMemoryStore.js";
|
|
3
|
+
import { CONTENT_TYPES } from "../types.js";
|
|
4
|
+
import { listJsonFiles, basenameNoExt } from "../storage/fs.js";
|
|
5
|
+
import { METADATA_DIR } from "../config.js";
|
|
6
|
+
import { queryMockCollection } from "../engine/query.js";
|
|
7
|
+
export function listListDescriptors(entityName) {
|
|
8
|
+
const dir = path.join(METADATA_DIR, "lists");
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const file of listJsonFiles(dir)) {
|
|
11
|
+
const desc = store.getMeta(`lists/${basenameNoExt(file)}`);
|
|
12
|
+
if (desc && (!entityName || desc.entity === entityName || desc.entityName === entityName)) {
|
|
13
|
+
out.push(desc);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
export function getListDescriptor(listId) {
|
|
19
|
+
return store.getMeta(`lists/${listId}`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Executes a list descriptor query pipeline against local data arrays.
|
|
23
|
+
*/
|
|
24
|
+
export async function executeListDescriptor(desc, params = {}) {
|
|
25
|
+
const entity = desc.entity || desc.entityName;
|
|
26
|
+
if (!entity) {
|
|
27
|
+
return { rows: [], page: { offset: 0, limit: 50 } };
|
|
28
|
+
}
|
|
29
|
+
// Gather base collection (snapshots by default).
|
|
30
|
+
const baseCt = desc.baseContentType || "snapshots";
|
|
31
|
+
let records = store.getCollection(entity, baseCt);
|
|
32
|
+
// Apply descriptor query if present
|
|
33
|
+
if (desc.query) {
|
|
34
|
+
const mingo = (await import("mingo")).default;
|
|
35
|
+
const query = new mingo.Query(desc.query);
|
|
36
|
+
records = query.find(records).all();
|
|
37
|
+
}
|
|
38
|
+
const result = queryMockCollection(records, { ...params, ...(desc.query ? { filter: undefined } : {}) });
|
|
39
|
+
// Apply projections / field selection
|
|
40
|
+
const fields = desc.fields || (desc.projections ? Object.keys(desc.projections) : null);
|
|
41
|
+
if (fields && fields.length) {
|
|
42
|
+
result.rows = result.rows.map((r) => {
|
|
43
|
+
const proj = { id: r.id ?? r.recordId };
|
|
44
|
+
for (const f of fields) {
|
|
45
|
+
if (f in r)
|
|
46
|
+
proj[f] = r[f];
|
|
47
|
+
}
|
|
48
|
+
return proj;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Suggest extensions / analytics for unjoined content types.
|
|
55
|
+
*/
|
|
56
|
+
export function suggestListExtensions(entityName) {
|
|
57
|
+
const available = CONTENT_TYPES.filter((ct) => store.getCollection(entityName, ct).length > 0);
|
|
58
|
+
const suggestions = available
|
|
59
|
+
.filter((ct) => ct !== "snapshots")
|
|
60
|
+
.map((ct) => ({
|
|
61
|
+
contentType: ct,
|
|
62
|
+
collection: `${entityName}/${ct}`,
|
|
63
|
+
suggestion: `Add extension join for ${ct} records into the entity view.`,
|
|
64
|
+
}));
|
|
65
|
+
return { entityName, suggestions };
|
|
66
|
+
}
|