@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 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
+ }