@x12i/static-memorix-explorer-api 1.1.1 → 1.3.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 CHANGED
@@ -35,6 +35,12 @@ Installed CLI, using the default port `5030`:
35
35
  npx @x12i/static-memorix-explorer-api
36
36
  ```
37
37
 
38
+ Or, after a global install:
39
+
40
+ ```bash
41
+ static-memorix-explorer-api --port 5030 --host 127.0.0.1
42
+ ```
43
+
38
44
  Override the port from the shell:
39
45
 
40
46
  ```bash
@@ -201,6 +207,28 @@ These strings are derived from `MEMORIX_ORG_ID`, `MEMORIX_AGENT_ID`, and the
201
207
  `MEMORIX_DEPLOYMENT_PROFILE` (which, when set to `ebook`, forces both IDs to
202
208
  `ebooks`).
203
209
 
210
+ ### File-backed routing
211
+
212
+ Those routed database names also select JSON files. The separator is `--`:
213
+
214
+ ```text
215
+ mocks/data/product/neo-memorix-entities--snapshots.json
216
+ mocks/data/product/neo-memorix-events--events.json
217
+ mocks/data/memory/neo-agent-memorix-catalox--memory.json
218
+ mocks/metadata/lists/neo-agent-memorix-catalox--my-plate.json
219
+ ```
220
+
221
+ For `MEMORIX_ORG_ID=neo` and `MEMORIX_AGENT_ID=neo-agent`, entity-like
222
+ collections use the first prefix, events use the second, and memory plus
223
+ metadata use the Catalox prefix. If a routed file does not exist, the server
224
+ loads its unprefixed counterpart as seed data, but all mutations are written
225
+ to the routed filename. This provides database-style isolation without a
226
+ database and preserves existing fixtures.
227
+
228
+ The health response exposes the resolved values under `fileRouting`.
229
+ See [guides/managing-json-files.md](./guides/managing-json-files.md) for the
230
+ complete precedence and migration rules.
231
+
204
232
  ## Feature Flag Enforcement
205
233
 
206
234
  The mock server enforces the following feature-flag gates with error responses:
@@ -241,6 +269,24 @@ and `knowledgeId`.
241
269
 
242
270
  ## Release history
243
271
 
272
+ ### v1.3.0
273
+ - Added database-routing parity through deterministic JSON filename prefixes.
274
+ - Added separate entity, event, and Catalox/metadata prefix resolution.
275
+ - Added migration-safe fallback from missing routed files to unprefixed seeds;
276
+ writes always persist to the selected routed file.
277
+ - Added resolved `fileRouting` prefixes to the health response.
278
+ - Updated metadata discovery to ignore other routed tenants.
279
+ - Added routed persistence and prefix regression coverage.
280
+
281
+ ### v1.2.0
282
+ - Added native `--port` and `--host` CLI options.
283
+ - Added the canonical `static-memorix-explorer-api` executable while retaining
284
+ `mock-memorix-explorer-api` as a compatibility alias.
285
+ - Published TypeScript declarations for `buildServer()`, `startServer()`,
286
+ configuration exports, and server options.
287
+ - Corrected the supported Node.js runtime to Node 20 or newer for Fastify 5.
288
+ - Expanded CLI, programmatic, test, precedence, and lifecycle documentation.
289
+
244
290
  ### v1.1.1
245
291
  - Changed the default listen port from `4300` to `5030`.
246
292
  - Added safe programmatic APIs: `buildServer()` and `startServer(options)`.
@@ -0,0 +1,38 @@
1
+ export declare const ROOT_DIR: string;
2
+ export declare const MOCKS_DIR: string;
3
+ export declare const METADATA_DIR: string;
4
+ export declare const DATA_DIR: string;
5
+ export declare const PORT: number;
6
+ export declare const HOST: string;
7
+ export declare const DISK_FLUSH_DEBOUNCE_MS: number;
8
+ export declare const MEMORIX_ORG_ID: string;
9
+ export declare const MEMORIX_AGENT_ID: string;
10
+ export declare const MEMORIX_DEPLOYMENT_PROFILE: string;
11
+ /**
12
+ * Construct the router string the frontend expects.
13
+ * e.g. "neo-memorix-entities + neo-memorix-events" for MEMORIX_ORG_ID=neo
14
+ */
15
+ export declare function buildMemorixDbString(): string;
16
+ /**
17
+ * Construct the agent-scoped Catalox DB name.
18
+ * e.g. "ebooks-memorix-catalox"
19
+ */
20
+ export declare function buildCataloxDbString(): string;
21
+ /**
22
+ * Files emulate routed databases by using the resolved database name as a
23
+ * prefix. The `--` separator is reserved for routing and must not be used in
24
+ * an unprefixed fixture basename.
25
+ */
26
+ export declare function buildDataFilePrefix(contentType: string): string;
27
+ export declare function buildMetadataFilePrefix(): string;
28
+ export declare function prefixedJsonFilename(prefix: string, basename: string): string;
29
+ export declare const FLAGS: {
30
+ METADATA_WRITES: boolean;
31
+ PIPELINE_WRITES: boolean;
32
+ REGISTRY_WRITES: boolean;
33
+ };
34
+ export declare function metadataWritesEnabled(): boolean;
35
+ export declare function pipelineWritesEnabled(): boolean;
36
+ export declare function registryWritesEnabled(): boolean;
37
+ export declare function metadataPath(...segments: string[]): string;
38
+ export declare function dataPath(...segments: string[]): string;
package/dist/config.js CHANGED
@@ -46,6 +46,24 @@ export function buildMemorixDbString() {
46
46
  export function buildCataloxDbString() {
47
47
  return `${MEMORIX_AGENT_ID}-memorix-catalox`;
48
48
  }
49
+ /**
50
+ * Files emulate routed databases by using the resolved database name as a
51
+ * prefix. The `--` separator is reserved for routing and must not be used in
52
+ * an unprefixed fixture basename.
53
+ */
54
+ export function buildDataFilePrefix(contentType) {
55
+ if (contentType === "events")
56
+ return `${MEMORIX_ORG_ID}-memorix-events`;
57
+ if (contentType === "memory")
58
+ return buildCataloxDbString();
59
+ return `${MEMORIX_ORG_ID}-memorix-entities`;
60
+ }
61
+ export function buildMetadataFilePrefix() {
62
+ return buildCataloxDbString();
63
+ }
64
+ export function prefixedJsonFilename(prefix, basename) {
65
+ return `${prefix}--${basename}.json`;
66
+ }
49
67
  // ---------------------------------------------------------------------------
50
68
  // Feature Flag Enforcement
51
69
  // ---------------------------------------------------------------------------
@@ -0,0 +1,32 @@
1
+ import type { IncludeFlag } from "../types.js";
2
+ /**
3
+ * Storage Alias Translation Map
4
+ * Client API key -> storage property / fallback.
5
+ */
6
+ export interface AliasEntry {
7
+ clientKey: string;
8
+ storageProp: string;
9
+ fallbacks: string[];
10
+ }
11
+ export declare const STORAGE_ALIASES: Record<string, AliasEntry>;
12
+ /**
13
+ * Resolve a normalized associated bucket from a raw document.
14
+ * Strips raw `associated*` fields and reconstructs the `associated` bucket
15
+ * using the alias map. Custom associated properties follow the pattern
16
+ * associated{Custom}.
17
+ */
18
+ export declare function buildAssociatedBucket(rawDoc: Record<string, unknown>): {
19
+ associated: Record<string, unknown>;
20
+ strippedDoc: Record<string, unknown>;
21
+ properties: Array<{
22
+ propertyName: string;
23
+ source: string;
24
+ count: number;
25
+ }>;
26
+ };
27
+ export declare function aliasStorageToClient(storageKey: string): string;
28
+ export declare function aliasClientToStorage(clientKey: string): {
29
+ primary: string;
30
+ fallbacks: string[];
31
+ };
32
+ export declare function resolveInclude(include?: string | string[]): Set<IncludeFlag> | null;
@@ -0,0 +1,26 @@
1
+ export interface AssociationPlan {
2
+ objectType: string;
3
+ expectedPlanFingerprint: string;
4
+ proposedMutations: Array<{
5
+ recordId: string;
6
+ targetArray: string;
7
+ op: string;
8
+ count: number;
9
+ }>;
10
+ }
11
+ export declare function planAssociations(objectType?: string): AssociationPlan[];
12
+ export interface ApplyResult {
13
+ objectType: string;
14
+ applied: boolean;
15
+ expectedPlanFingerprint: string;
16
+ actualFingerprint: string;
17
+ mismatch: boolean;
18
+ count: number;
19
+ }
20
+ export declare function applyAssociations(expectedPlanFingerprint: string, objectType?: string): ApplyResult[];
21
+ export declare function verifyAssociations(expectedPlanFingerprint: string, objectType?: string): {
22
+ objectType: string;
23
+ expectedPlanFingerprint: string;
24
+ actualFingerprint: string;
25
+ verified: boolean;
26
+ }[];
@@ -0,0 +1,22 @@
1
+ import type { ContentType, IdentityKey } from "../types.js";
2
+ /**
3
+ * Resolve the primary identity field for a given object type / content type.
4
+ * `memory` collections are keyed by `memoryId`; everything else falls back to
5
+ * the per-document heuristic used elsewhere.
6
+ */
7
+ export declare function resolveIdField(ct: ContentType, sample?: any): IdentityKey;
8
+ /**
9
+ * Mock of the production `MemorixRecordArraySchema`. Validates that each record
10
+ * in a collection array carries the collection's canonical identity key.
11
+ * Additional identity-shaped fields are allowed because they can represent
12
+ * relationships (for example a memory has `memoryId` and may reference an
13
+ * `entityId` or `eventId`).
14
+ */
15
+ export declare function validateRecordArray(objectType: string, ct: ContentType, records: any[]): {
16
+ ok: boolean;
17
+ errors: string[];
18
+ };
19
+ /**
20
+ * Find a record by any of the supported identity keys.
21
+ */
22
+ export declare function findByIdentity(records: any[], identity: Partial<Record<IdentityKey, string>>): any | undefined;
@@ -0,0 +1,29 @@
1
+ import { type ExplorerQueryParams } from "../types.js";
2
+ export interface InventoryRow {
3
+ objectType: string;
4
+ contentType: string;
5
+ collection: string;
6
+ count: number;
7
+ status: "populated" | "empty" | "orphan";
8
+ provenance: {
9
+ source: "catalog" | "orphan";
10
+ };
11
+ missingCollections?: boolean;
12
+ }
13
+ export declare function listObjectTypes(): string[];
14
+ export declare function listDataObjectTypes(): string[];
15
+ export declare function computeInventory(params: ExplorerQueryParams): InventoryRow[];
16
+ export declare function computeInventorySummary(rows: InventoryRow[], params: ExplorerQueryParams): Record<string, unknown>;
17
+ export declare function computeInventoryIssues(rows: InventoryRow[]): InventoryRow[];
18
+ export declare function computeGraph(rows: InventoryRow[]): {
19
+ nodes: {
20
+ id: string;
21
+ objectType: string;
22
+ populated: boolean;
23
+ }[];
24
+ edges: {
25
+ from: string;
26
+ to: string;
27
+ relationType?: string;
28
+ }[];
29
+ };
@@ -1,13 +1,13 @@
1
1
  import path from "node:path";
2
2
  import { store } from "../storage/InMemoryStore.js";
3
3
  import { CONTENT_TYPES } from "../types.js";
4
- import { listJsonFiles, basenameNoExt } from "../storage/fs.js";
5
- import { METADATA_DIR, DATA_DIR } from "../config.js";
4
+ import { DATA_DIR } from "../config.js";
6
5
  import fs from "node:fs";
7
6
  import { isDir } from "../storage/fs.js";
8
7
  export function listObjectTypes() {
9
- const dir = path.join(METADATA_DIR, "object-types");
10
- return listJsonFiles(dir).map((f) => basenameNoExt(f));
8
+ return store.getMetaKeys()
9
+ .filter((key) => key.startsWith("object-types/"))
10
+ .map((key) => key.slice("object-types/".length));
11
11
  }
12
12
  export function listDataObjectTypes() {
13
13
  if (!isDir(DATA_DIR))
@@ -0,0 +1,18 @@
1
+ import { type ListDescriptor, type ExplorerQueryParams } from "../types.js";
2
+ export declare function listListDescriptors(entityName?: string): ListDescriptor[];
3
+ export declare function getListDescriptor(listId: string): ListDescriptor | undefined;
4
+ /**
5
+ * Executes a list descriptor query pipeline against local data arrays.
6
+ */
7
+ export declare function executeListDescriptor(desc: ListDescriptor, params?: ExplorerQueryParams): Promise<any>;
8
+ /**
9
+ * Suggest extensions / analytics for unjoined content types.
10
+ */
11
+ export declare function suggestListExtensions(entityName: string): {
12
+ entityName: string;
13
+ suggestions: {
14
+ contentType: "events" | "memory" | "analysis" | "decisions";
15
+ collection: string;
16
+ suggestion: string;
17
+ }[];
18
+ };
@@ -1,14 +1,10 @@
1
- import path from "node:path";
2
1
  import { store } from "../storage/InMemoryStore.js";
3
2
  import { CONTENT_TYPES } from "../types.js";
4
- import { listJsonFiles, basenameNoExt } from "../storage/fs.js";
5
- import { METADATA_DIR } from "../config.js";
6
3
  import { queryMockCollection } from "../engine/query.js";
7
4
  export function listListDescriptors(entityName) {
8
- const dir = path.join(METADATA_DIR, "lists");
9
5
  const out = [];
10
- for (const file of listJsonFiles(dir)) {
11
- const desc = store.getMeta(`lists/${basenameNoExt(file)}`);
6
+ for (const key of store.getMetaKeys().filter((item) => item.startsWith("lists/"))) {
7
+ const desc = store.getMeta(key);
12
8
  if (desc && (!entityName || desc.entity === entityName || desc.entityName === entityName)) {
13
9
  out.push(desc);
14
10
  }
@@ -0,0 +1,16 @@
1
+ import { type NarrativeDefinition } from "../types.js";
2
+ /**
3
+ * Discover narrative keys tagged on records via doc.narratives.*.
4
+ */
5
+ export declare function discoverRecordNarrativeTags(entityName: string): string[];
6
+ export declare function mergeCatalogWithSignals(authored: Record<string, NarrativeDefinition>, recordTags: string[]): Array<NarrativeDefinition & {
7
+ signaled: boolean;
8
+ }>;
9
+ export declare function getMergedNarratives(entityName?: string): (NarrativeDefinition & {
10
+ signaled: boolean;
11
+ })[] | Record<string, (NarrativeDefinition & {
12
+ signaled: boolean;
13
+ })[]>;
14
+ export declare function getRawNarratives(entityName: string): Record<string, NarrativeDefinition>;
15
+ export declare function queryNarrativeRecords(entityName: string, key: string): any[];
16
+ export declare function listNarrativeEntities(): string[];
@@ -1,10 +1,5 @@
1
- import path from "node:path";
2
1
  import { store } from "../storage/InMemoryStore.js";
3
2
  import { CONTENT_TYPES } from "../types.js";
4
- import { listJsonFiles, basenameNoExt, readJson } from "../storage/fs.js";
5
- import { METADATA_DIR } from "../config.js";
6
- import { isDir } from "../storage/fs.js";
7
- import fs from "node:fs";
8
3
  /**
9
4
  * Discover narrative keys tagged on records via doc.narratives.*.
10
5
  */
@@ -40,11 +35,10 @@ export function getMergedNarratives(entityName) {
40
35
  return mergeCatalogWithSignals(authored, recordTags);
41
36
  }
42
37
  // global across all entities
43
- const dir = path.join(METADATA_DIR, "narratives");
44
38
  const out = {};
45
- for (const file of listJsonFiles(dir)) {
46
- const name = basenameNoExt(file);
47
- const authored = readJson(file, {});
39
+ for (const metaKey of store.getMetaKeys().filter((key) => key.startsWith("narratives/"))) {
40
+ const name = metaKey.slice("narratives/".length);
41
+ const authored = store.getMeta(metaKey) || {};
48
42
  const recordTags = discoverRecordNarrativeTags(name);
49
43
  out[name] = mergeCatalogWithSignals(authored, recordTags);
50
44
  }
@@ -67,11 +61,7 @@ export function queryNarrativeRecords(entityName, key) {
67
61
  return results;
68
62
  }
69
63
  export function listNarrativeEntities() {
70
- const dir = path.join(METADATA_DIR, "narratives");
71
- if (!isDir(dir))
72
- return [];
73
- return fs
74
- .readdirSync(dir)
75
- .filter((f) => f.endsWith(".json"))
76
- .map((f) => basenameNoExt(f));
64
+ return store.getMetaKeys()
65
+ .filter((key) => key.startsWith("narratives/"))
66
+ .map((key) => key.slice("narratives/".length));
77
67
  }
@@ -0,0 +1,11 @@
1
+ export interface RootPropertyEntry {
2
+ path: string;
3
+ occurrencePct: number;
4
+ types: string[];
5
+ sampleValues: unknown[];
6
+ }
7
+ /**
8
+ * Full-scan all record arrays in ./data/{name}/*.json, compute property
9
+ * occurrence percentages, data types, and paths.
10
+ */
11
+ export declare function computeRootPropertyCatalog(objectType: string): RootPropertyEntry[];
@@ -0,0 +1,38 @@
1
+ import type { ExplorerQueryParams } from "../types.js";
2
+ export type FilterOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in" | "nin" | "exists" | "regex";
3
+ export interface ParsedFilter {
4
+ field: string;
5
+ op: FilterOp;
6
+ value: unknown;
7
+ raw: string;
8
+ isVirtual?: boolean;
9
+ }
10
+ export declare function parseFilterToken(token: string): ParsedFilter | null;
11
+ export declare function coerce(value: string): unknown;
12
+ /**
13
+ * Parse the filter param. Supports repeated values (array) and `;` separators.
14
+ * Returns virtual narrative filters separately so the caller can rewrite them.
15
+ */
16
+ export declare function parseExplorerFilters(filter?: string | string[]): {
17
+ criteria: Record<string, unknown>;
18
+ virtual: ParsedFilter[];
19
+ };
20
+ export declare function parseSortParams(sort?: string | string[]): Record<string, 1 | -1>;
21
+ /**
22
+ * Resolve virtual narrative filters into a predicate that checks
23
+ * doc.narratives[key] presence / truthiness.
24
+ */
25
+ export declare function applyVirtualNarrativeFilters(records: any[], virtual: ParsedFilter[]): any[];
26
+ export interface QueryResult {
27
+ rows: any[];
28
+ page: {
29
+ offset: number;
30
+ limit: number;
31
+ total?: number;
32
+ };
33
+ }
34
+ /**
35
+ * Execute a query against an in-memory record array using mingo, with
36
+ * full-text search, virtual narrative filters, sorting, and pagination.
37
+ */
38
+ export declare function queryMockCollection(records: any[], queryParams: ExplorerQueryParams): QueryResult;
@@ -0,0 +1,23 @@
1
+ import { type ContentType, type ExplorerQueryParams, type IdentityKey } from "../types.js";
2
+ export declare function inferIdField(entityName: string, ct: ContentType): string;
3
+ /**
4
+ * Find a record within a collection using an identity map (recordId, entityId,
5
+ * eventId, knowledgeId, or memoryId). At least one identity key is required.
6
+ */
7
+ export declare function findRecordByIdentity(entityName: string, ct: ContentType, identity: Partial<Record<IdentityKey, string>>): any | undefined;
8
+ export declare function findRecord(entityName: string, ct: ContentType, recordId: string): any | undefined;
9
+ export declare function getRecordsCollection(params: ExplorerQueryParams): Promise<{
10
+ rows: any[];
11
+ page: any;
12
+ }>;
13
+ export declare function getRecordsFull(entityName: string, recordId: string): any;
14
+ export declare function getRecordsItem(entityName: string, recordId: string, itemDescriptor?: any): any;
15
+ /**
16
+ * Fetch a single content record by id. Supports the memory tier via memoryId.
17
+ * If entityName is empty and contentType is "memory", defaults to "memory".
18
+ * Otherwise, searches across all object types in the store.
19
+ */
20
+ export declare function getRecordContent(entityName: string, contentType: ContentType, identity: Partial<Record<IdentityKey, string>>): any | undefined;
21
+ export declare function getRawCollection(entityName: string, collectionName: string): any[];
22
+ export declare function getRawItem(collectionName: string, recordId: string): any | undefined;
23
+ export declare function getWorkspaceRecords(params: ExplorerQueryParams): Promise<any>;
@@ -0,0 +1,17 @@
1
+ export declare function getSnapshotRaw(objectType: string, recordId: string): {
2
+ rawDoc: any;
3
+ idField: string;
4
+ } | undefined;
5
+ export declare function getSnapshotNormalized(objectType: string, recordId: string, include?: string | string[]): any | undefined;
6
+ export declare function getSnapshotAssociated(objectType: string, recordId: string): any | undefined;
7
+ export declare function getSnapshotAssociatedProperty(objectType: string, recordId: string, propertyName: string): any | undefined;
8
+ export declare function getAssociatedProperties(objectType: string): Array<{
9
+ propertyName: string;
10
+ source: string;
11
+ count: number;
12
+ }>;
13
+ /**
14
+ * Deterministic fingerprint for association plan/apply/verify.
15
+ */
16
+ export declare function computeAssociationFingerprint(objectType: string): string;
17
+ export declare function hashString(s: string): string;
@@ -0,0 +1,30 @@
1
+ import type { ContentType, WriteDescriptor } from "../types.js";
2
+ export type WriteOperation = "add" | "upsert" | "patch" | "replace" | "delete";
3
+ export declare function loadWriteDescriptor(writeDescriptorId: string): WriteDescriptor;
4
+ export interface ValidationResult {
5
+ ok: boolean;
6
+ errors: string[];
7
+ }
8
+ /**
9
+ * Validate input against the write descriptor schema (lightweight AJV-free check).
10
+ *
11
+ * `operation` controls how `required` is enforced:
12
+ * - `add` / `replace`: every field in `schema.required` must be present.
13
+ * - `upsert` / `patch` / `delete`: only the collection's identity field
14
+ * (e.g. `recordId`, `memoryId`) is required; other `required` entries are
15
+ * ignored because partial updates are allowed.
16
+ *
17
+ * Type checks on whatever fields ARE present always run.
18
+ */
19
+ export declare function validateInputSchema(writeSpec: WriteDescriptor, input: any, operation?: WriteOperation): ValidationResult;
20
+ export declare function parseTargetCollection(target: string): {
21
+ objectType: string;
22
+ ct: ContentType;
23
+ };
24
+ export declare function executeOperation(params: {
25
+ target: string;
26
+ op: WriteOperation;
27
+ data: any;
28
+ persist?: boolean;
29
+ }): any[];
30
+ export declare function listWriteDescriptors(): WriteDescriptor[];
@@ -1,7 +1,4 @@
1
- import path from "node:path";
2
1
  import { store } from "../storage/InMemoryStore.js";
3
- import { basenameNoExt, listJsonFiles } from "../storage/fs.js";
4
- import { METADATA_DIR } from "../config.js";
5
2
  export function loadWriteDescriptor(writeDescriptorId) {
6
3
  const desc = store.getMeta(`write-descriptors/${writeDescriptorId}`);
7
4
  if (!desc) {
@@ -156,10 +153,9 @@ function getNextId(collection, idField = "recordId") {
156
153
  return () => String(counter++);
157
154
  }
158
155
  export function listWriteDescriptors() {
159
- const dir = path.join(METADATA_DIR, "write-descriptors");
160
156
  const out = [];
161
- for (const file of listJsonFiles(dir)) {
162
- const desc = store.getMeta(`write-descriptors/${basenameNoExt(file)}`);
157
+ for (const key of store.getMetaKeys().filter((item) => item.startsWith("write-descriptors/"))) {
158
+ const desc = store.getMeta(key);
163
159
  if (desc)
164
160
  out.push(desc);
165
161
  }
@@ -0,0 +1,4 @@
1
+ import type { FastifyRequest } from "fastify";
2
+ import type { ExplorerQueryParams } from "../types.js";
3
+ export declare function qp(request: FastifyRequest): ExplorerQueryParams;
4
+ export declare function str(v: unknown, fallback?: string): string;
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ export declare function registerRoutes(app: FastifyInstance): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { store } from "../storage/InMemoryStore.js";
2
- import { buildMemorixDbString, buildCataloxDbString, metadataWritesEnabled, } from "../config.js";
2
+ import { buildMemorixDbString, buildCataloxDbString, buildDataFilePrefix, buildMetadataFilePrefix, metadataWritesEnabled, } from "../config.js";
3
3
  import { listObjectTypes } from "../engine/inventory.js";
4
4
  import { computeInventory, computeInventorySummary, computeInventoryIssues, computeGraph, } from "../engine/inventory.js";
5
5
  import { qp, str } from "./helpers.js";
@@ -25,6 +25,12 @@ export async function registerRoutes(app) {
25
25
  discoverySample: objectTypes,
26
26
  memorixDb: buildMemorixDbString(),
27
27
  cataloxDb: buildCataloxDbString(),
28
+ fileRouting: {
29
+ entitiesPrefix: buildDataFilePrefix("snapshots"),
30
+ eventsPrefix: buildDataFilePrefix("events"),
31
+ memoryPrefix: buildDataFilePrefix("memory"),
32
+ metadataPrefix: buildMetadataFilePrefix(),
33
+ },
28
34
  };
29
35
  if (includeInventory) {
30
36
  const rows = computeInventory({ sourceLens: "catalox-first" });
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ export interface StartServerOptions {
3
+ port?: number;
4
+ host?: string;
5
+ logger?: boolean;
6
+ installSignalHandlers?: boolean;
7
+ }
8
+ export declare function parseCliArgs(args: string[]): StartServerOptions;
9
+ /** Build a loaded Fastify instance without opening a network port. */
10
+ export declare function buildServer(options?: Pick<StartServerOptions, "logger">): Promise<import("fastify").FastifyInstance<import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, import("fastify").FastifyBaseLogger, import("fastify").FastifyTypeProviderDefault>>;
11
+ /** Build and listen. Options override PORT/HOST environment configuration. */
12
+ export declare function startServer(options?: StartServerOptions): Promise<import("fastify").FastifyInstance<import("http").Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, import("fastify").FastifyBaseLogger, import("fastify").FastifyTypeProviderDefault>>;
package/dist/server.js CHANGED
@@ -5,6 +5,32 @@ import { fileURLToPath } from "node:url";
5
5
  import { store } from "./storage/InMemoryStore.js";
6
6
  import { registerRoutes } from "./routes/index.js";
7
7
  import { PORT, HOST, MOCKS_DIR } from "./config.js";
8
+ export function parseCliArgs(args) {
9
+ const options = {};
10
+ for (let index = 0; index < args.length; index++) {
11
+ const arg = args[index];
12
+ if (arg === "--port") {
13
+ const raw = args[++index];
14
+ const port = Number(raw);
15
+ if (!raw || !Number.isInteger(port) || port < 0 || port > 65535) {
16
+ throw Object.assign(new Error("--port must be an integer from 0 to 65535"), {
17
+ statusCode: 400,
18
+ });
19
+ }
20
+ options.port = port;
21
+ }
22
+ else if (arg === "--host") {
23
+ const host = args[++index];
24
+ if (!host)
25
+ throw Object.assign(new Error("--host requires a value"), { statusCode: 400 });
26
+ options.host = host;
27
+ }
28
+ else {
29
+ throw Object.assign(new Error(`Unknown CLI argument: ${arg}`), { statusCode: 400 });
30
+ }
31
+ }
32
+ return options;
33
+ }
8
34
  /** Build a loaded Fastify instance without opening a network port. */
9
35
  export async function buildServer(options = {}) {
10
36
  const app = Fastify({ logger: options.logger ?? false });
@@ -31,8 +57,10 @@ export async function startServer(options = {}) {
31
57
  const port = options.port ?? PORT;
32
58
  const host = options.host ?? HOST;
33
59
  await app.listen({ port, host });
60
+ const address = app.server.address();
61
+ const boundPort = typeof address === "object" && address ? address.port : port;
34
62
  // eslint-disable-next-line no-console
35
- console.log(`[mock-memorix-explorer-api] listening on ${host}:${port}`);
63
+ console.log(`[mock-memorix-explorer-api] listening on ${host}:${boundPort}`);
36
64
  console.log(`[mock-memorix-explorer-api] mocks dir: ${MOCKS_DIR}`);
37
65
  if (options.installSignalHandlers ?? true) {
38
66
  const shutdown = async () => {
@@ -50,7 +78,7 @@ const isCliEntry = process.argv[1]
50
78
  ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
51
79
  : false;
52
80
  if (isCliEntry) {
53
- startServer().catch((err) => {
81
+ Promise.resolve().then(() => startServer(parseCliArgs(process.argv.slice(2)))).catch((err) => {
54
82
  // eslint-disable-next-line no-console
55
83
  console.error(err);
56
84
  process.exit(1);
@@ -0,0 +1,29 @@
1
+ import { type ContentType } from "../types.js";
2
+ export declare class InMemoryStore {
3
+ private collections;
4
+ private metadata;
5
+ private flushTimers;
6
+ private loaded;
7
+ /**
8
+ * Load everything from disk into memory. Idempotent.
9
+ */
10
+ load(): Promise<void>;
11
+ private loadMetadataCatalog;
12
+ private loadDataCollections;
13
+ getMeta<T = any>(key: string): T | undefined;
14
+ setMeta(key: string, data: any, persist?: boolean): void;
15
+ getMetaKeys(): string[];
16
+ getCollection(objectType: string, ct: ContentType): any[];
17
+ setCollection(objectType: string, ct: ContentType, data: any[]): void;
18
+ getCollectionKeys(): string[];
19
+ private scheduleCollectionFlush;
20
+ private scheduleMetaFlush;
21
+ flushCollectionNow(key: string): void;
22
+ flushMetaNow(key: string): void;
23
+ /**
24
+ * Flush every dirty file immediately and clear timers.
25
+ */
26
+ flushAll(): Promise<void>;
27
+ get mocksDir(): string;
28
+ }
29
+ export declare const store: InMemoryStore;
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { CONTENT_TYPES } from "../types.js";
4
- import { MOCKS_DIR, METADATA_DIR, DATA_DIR } from "../config.js";
4
+ import { MOCKS_DIR, METADATA_DIR, DATA_DIR, buildDataFilePrefix, buildMetadataFilePrefix, prefixedJsonFilename, } from "../config.js";
5
5
  import { validateRecordArray } from "../engine/identity.js";
6
6
  import { isDir, listJsonFiles, readJson, writeJson, basenameNoExt, } from "./fs.js";
7
7
  import { DISK_FLUSH_DEBOUNCE_MS } from "../config.js";
@@ -28,18 +28,33 @@ export class InMemoryStore {
28
28
  "write-descriptors": "write-descriptors",
29
29
  narratives: "narratives",
30
30
  };
31
+ const prefix = buildMetadataFilePrefix();
31
32
  for (const [subdir] of Object.entries(sections)) {
32
33
  const dir = path.join(METADATA_DIR, subdir);
33
- for (const file of listJsonFiles(dir)) {
34
- const key = `${subdir}/${basenameNoExt(file)}`;
35
- this.metadata.set(key, { path: file, data: readJson(file, {}) });
34
+ const files = listJsonFiles(dir);
35
+ const logicalNames = new Set();
36
+ for (const file of files) {
37
+ const name = basenameNoExt(file);
38
+ if (!name.includes("--"))
39
+ logicalNames.add(name);
40
+ if (name.startsWith(`${prefix}--`))
41
+ logicalNames.add(name.slice(prefix.length + 2));
42
+ }
43
+ for (const name of logicalNames) {
44
+ const baseFile = path.join(dir, `${name}.json`);
45
+ const routedFile = path.join(dir, prefixedJsonFilename(prefix, name));
46
+ const source = fs.existsSync(routedFile) ? routedFile : baseFile;
47
+ const key = `${subdir}/${name}`;
48
+ this.metadata.set(key, { path: routedFile, data: readJson(source, {}) });
36
49
  }
37
50
  }
38
- const agentsFile = path.join(METADATA_DIR, "agents.json");
39
- if (fs.existsSync(agentsFile)) {
51
+ const baseAgentsFile = path.join(METADATA_DIR, "agents.json");
52
+ const routedAgentsFile = path.join(METADATA_DIR, prefixedJsonFilename(prefix, "agents"));
53
+ const agentsSource = fs.existsSync(routedAgentsFile) ? routedAgentsFile : baseAgentsFile;
54
+ if (fs.existsSync(agentsSource)) {
40
55
  this.metadata.set("agents", {
41
- path: agentsFile,
42
- data: readJson(agentsFile, { agents: [] }),
56
+ path: routedAgentsFile,
57
+ data: readJson(agentsSource, { agents: [] }),
43
58
  });
44
59
  }
45
60
  }
@@ -51,9 +66,11 @@ export class InMemoryStore {
51
66
  if (!isDir(otDir))
52
67
  continue;
53
68
  for (const ct of CONTENT_TYPES) {
54
- const file = path.join(otDir, `${ct}.json`);
69
+ const baseFile = path.join(otDir, `${ct}.json`);
70
+ const file = path.join(otDir, prefixedJsonFilename(buildDataFilePrefix(ct), ct));
55
71
  const key = `${objectType}/${ct}`;
56
- const data = readJson(file, []);
72
+ const source = fs.existsSync(file) ? file : baseFile;
73
+ const data = readJson(source, []);
57
74
  // Startup validation: ensure records carry exactly one identity key
58
75
  // (MemorixRecordArraySchema mock). Memory collections use memoryId.
59
76
  const result = validateRecordArray(objectType, ct, data);
@@ -68,11 +85,13 @@ export class InMemoryStore {
68
85
  });
69
86
  }
70
87
  // system overrides
71
- const invFile = path.join(DATA_DIR, "system", "inventory.json");
72
- if (objectType === "system" && fs.existsSync(invFile)) {
88
+ const baseInvFile = path.join(DATA_DIR, "system", "inventory.json");
89
+ const invFile = path.join(DATA_DIR, "system", prefixedJsonFilename(buildDataFilePrefix("snapshots"), "inventory"));
90
+ const invSource = fs.existsSync(invFile) ? invFile : baseInvFile;
91
+ if (objectType === "system" && fs.existsSync(invSource)) {
73
92
  this.collections.set("system/inventory", {
74
93
  path: invFile,
75
- data: readJson(invFile, []),
94
+ data: readJson(invSource, []),
76
95
  });
77
96
  }
78
97
  }
@@ -89,7 +108,7 @@ export class InMemoryStore {
89
108
  }
90
109
  else {
91
110
  this.metadata.set(key, {
92
- path: path.join(METADATA_DIR, `${key}.json`),
111
+ path: path.join(METADATA_DIR, `${path.dirname(key)}/${prefixedJsonFilename(buildMetadataFilePrefix(), path.basename(key))}`),
93
112
  data,
94
113
  [DIRTY]: true,
95
114
  });
@@ -106,7 +125,7 @@ export class InMemoryStore {
106
125
  const c = this.collections.get(key);
107
126
  if (!c) {
108
127
  const newC = {
109
- path: path.join(DATA_DIR, objectType, `${ct}.json`),
128
+ path: path.join(DATA_DIR, objectType, prefixedJsonFilename(buildDataFilePrefix(ct), ct)),
110
129
  data: [],
111
130
  };
112
131
  this.collections.set(key, newC);
@@ -123,7 +142,7 @@ export class InMemoryStore {
123
142
  }
124
143
  else {
125
144
  this.collections.set(key, {
126
- path: path.join(DATA_DIR, objectType, `${ct}.json`),
145
+ path: path.join(DATA_DIR, objectType, prefixedJsonFilename(buildDataFilePrefix(ct), ct)),
127
146
  data,
128
147
  [DIRTY]: true,
129
148
  });
@@ -0,0 +1,6 @@
1
+ export declare function ensureDir(dir: string): void;
2
+ export declare function isDir(p: string): boolean;
3
+ export declare function readJson<T = any>(p: string, fallback: T): T;
4
+ export declare function writeJson(p: string, data: unknown): void;
5
+ export declare function listJsonFiles(dir: string): string[];
6
+ export declare function basenameNoExt(p: string): string;
@@ -0,0 +1,88 @@
1
+ export type ContentType = "snapshots" | "analysis" | "decisions" | "events" | "memory";
2
+ export declare const CONTENT_TYPES: ContentType[];
3
+ /**
4
+ * The fourth tier of data alongside entities, events, and knowledge.
5
+ * `memory` collections are keyed by `memoryId`.
6
+ */
7
+ export declare const MEMORY_CONTENT_TYPE: ContentType;
8
+ /** Primary identity keys accepted across fetch/write engines. */
9
+ export declare const IDENTITY_KEYS: readonly ["recordId", "entityId", "eventId", "knowledgeId", "memoryId"];
10
+ export type IdentityKey = (typeof IDENTITY_KEYS)[number];
11
+ /** Map a target type to its canonical id field. */
12
+ export declare const TARGET_TO_ID_FIELD: Record<string, IdentityKey>;
13
+ export declare const INCLUDE_FLAGS: readonly ["data", "analytics", "enrichment", "insights", "system", "associated", "discovery", "analysis"];
14
+ export type IncludeFlag = (typeof INCLUDE_FLAGS)[number];
15
+ export interface ExplorerQueryParams {
16
+ filter?: string | string[];
17
+ sort?: string | string[];
18
+ searchText?: string;
19
+ q?: string;
20
+ limit?: string | number;
21
+ offset?: string | number;
22
+ skip?: string | number;
23
+ includeTotal?: boolean;
24
+ include?: string | string[];
25
+ contentType?: string;
26
+ mode?: "descriptor" | "raw" | "auto";
27
+ listId?: string;
28
+ entityName?: string;
29
+ sourceLens?: "db-first" | "catalox-first";
30
+ target?: "entity" | "event" | "knowledge" | "memory" | string;
31
+ includeExactCounts?: boolean;
32
+ includeInventory?: boolean | string;
33
+ memoryId?: string;
34
+ recordId?: string;
35
+ entityId?: string;
36
+ eventId?: string;
37
+ knowledgeId?: string;
38
+ }
39
+ export interface CatalogRelation {
40
+ sourceProperty?: string;
41
+ targetObject?: string;
42
+ targetProperty?: string;
43
+ relationType?: string;
44
+ }
45
+ export interface ObjectTypeMeta {
46
+ name: string;
47
+ summary?: string;
48
+ rootPropertyCatalog?: RootPropertyCatalogEntry[];
49
+ catalogRelations?: CatalogRelation[];
50
+ [key: string]: unknown;
51
+ }
52
+ export interface RootPropertyCatalogEntry {
53
+ path: string;
54
+ occurrencePct?: number;
55
+ types?: string[];
56
+ sampleValues?: unknown[];
57
+ [key: string]: unknown;
58
+ }
59
+ export interface ListDescriptor {
60
+ id?: string;
61
+ listId?: string;
62
+ entity?: string;
63
+ entityName?: string;
64
+ fields?: string[];
65
+ projections?: Record<string, unknown>;
66
+ extensions?: string[];
67
+ sort?: Record<string, 1 | -1>;
68
+ query?: Record<string, unknown>;
69
+ [key: string]: unknown;
70
+ }
71
+ export interface WriteDescriptor {
72
+ writeDescriptorId?: string;
73
+ id?: string;
74
+ targetCollection?: string;
75
+ schema?: unknown;
76
+ [key: string]: unknown;
77
+ }
78
+ export interface NarrativeDefinition {
79
+ key: string;
80
+ label?: string;
81
+ description?: string;
82
+ [key: string]: unknown;
83
+ }
84
+ export interface AgentEntry {
85
+ id?: string;
86
+ name?: string;
87
+ [key: string]: unknown;
88
+ }
@@ -1,5 +1,61 @@
1
1
  # Managing JSON Files
2
2
 
3
+ ## File-backed database routing
4
+
5
+ The static server has no database, so database routing is represented by a
6
+ reserved filename prefix followed by `--`.
7
+
8
+ With:
9
+
10
+ ```bash
11
+ MEMORIX_ORG_ID=neo
12
+ MEMORIX_AGENT_ID=neo-agent
13
+ ```
14
+
15
+ the routing matrix is:
16
+
17
+ | Data kind | Filename prefix | Example |
18
+ | --- | --- | --- |
19
+ | Snapshots, analysis, decisions | `neo-memorix-entities--` | `data/product/neo-memorix-entities--snapshots.json` |
20
+ | Events | `neo-memorix-events--` | `data/product/neo-memorix-events--events.json` |
21
+ | Memory | `neo-agent-memorix-catalox--` | `data/memory/neo-agent-memorix-catalox--memory.json` |
22
+ | Metadata | `neo-agent-memorix-catalox--` | `metadata/lists/neo-agent-memorix-catalox--my-plate.json` |
23
+
24
+ ### Read and write precedence
25
+
26
+ 1. The server first looks for the routed, prefixed file.
27
+ 2. If it does not exist, it reads the matching unprefixed file as seed data.
28
+ 3. Any mutation writes the complete resulting collection to the routed file.
29
+ 4. Unprefixed seed files are never overwritten while routed mode is active.
30
+ 5. Files belonging to a different prefix are ignored by the active process.
31
+
32
+ This makes onboarding migration-safe: existing files such as
33
+ `product/snapshots.json` continue to seed a new tenant, while the first write
34
+ creates `product/neo-memorix-entities--snapshots.json`. Starting with another
35
+ organization ID selects a different file and therefore different state.
36
+
37
+ The delimiter `--` is reserved. Do not use it inside ordinary, unprefixed
38
+ fixture basenames.
39
+
40
+ ### Inspect the active route
41
+
42
+ ```bash
43
+ curl http://localhost:5030/api/explorer/health
44
+ ```
45
+
46
+ The response includes:
47
+
48
+ ```json
49
+ {
50
+ "fileRouting": {
51
+ "entitiesPrefix": "neo-memorix-entities",
52
+ "eventsPrefix": "neo-memorix-events",
53
+ "memoryPrefix": "neo-agent-memorix-catalox",
54
+ "metadataPrefix": "neo-agent-memorix-catalox"
55
+ }
56
+ }
57
+ ```
58
+
3
59
  The mock server stores all state in JSON files under the `mocks/` directory. At
4
60
  startup, every file is loaded into an in-memory store. Mutations happen in
5
61
  memory and are debounced-flushed back to disk automatically.
@@ -13,6 +13,8 @@ npm install -g @x12i/static-memorix-explorer-api
13
13
  ```
14
14
 
15
15
  This makes the `mock-memorix-explorer-api` command available globally.
16
+ The preferred command name is `static-memorix-explorer-api`; the older
17
+ `mock-memorix-explorer-api` name remains as a compatibility alias.
16
18
 
17
19
  ### From source (git clone)
18
20
 
@@ -43,7 +45,7 @@ iteration, but not suitable for distribution.
43
45
  ### As a global CLI
44
46
 
45
47
  ```bash
46
- mock-memorix-explorer-api
48
+ static-memorix-explorer-api
47
49
  ```
48
50
 
49
51
  After `npm install -g`, the CLI starts the server with default settings. The
@@ -55,6 +57,14 @@ mocks directory defaults to a `mocks/` folder relative to the installed package.
55
57
  npx @x12i/static-memorix-explorer-api
56
58
  ```
57
59
 
60
+ Choose a port with a CLI option or environment variable. CLI options take
61
+ precedence:
62
+
63
+ ```bash
64
+ static-memorix-explorer-api --port 5030 --host 127.0.0.1
65
+ PORT=5030 HOST=127.0.0.1 static-memorix-explorer-api
66
+ ```
67
+
58
68
  ### From application code
59
69
 
60
70
  Importing the package does not open a port. Call `startServer()` explicitly:
@@ -111,11 +121,25 @@ correct tenant.
111
121
  The health endpoint returns:
112
122
  - `memorixDb`: `"<orgId>-memorix-entities + <orgId>-memorix-events"`
113
123
  - `cataloxDb`: `"<agentId>-memorix-catalox"`
124
+ - `fileRouting`: the concrete entity, event, memory, and metadata filename
125
+ prefixes selected for this process
126
+
127
+ These are functional routes, not display-only labels. For example:
128
+
129
+ ```bash
130
+ MEMORIX_ORG_ID=neo MEMORIX_AGENT_ID=neo-agent npm start
131
+ ```
132
+
133
+ selects filenames beginning with `neo-memorix-entities--`,
134
+ `neo-memorix-events--`, and `neo-agent-memorix-catalox--`. See
135
+ [managing-json-files.md](./managing-json-files.md#file-backed-database-routing)
136
+ for exact examples and fallback behavior.
114
137
 
115
138
  ### Feature flags
116
139
 
117
- Mutations (POST/PATCH/PUT/DELETE) on lists, narratives, and records are gated
118
- behind feature flags. All default to `false`.
140
+ Metadata mutations on lists and narratives are gated behind feature flags.
141
+ Record mutations are governed by write descriptors and are available by
142
+ default.
119
143
 
120
144
  | Variable | Controls |
121
145
  | ------------------------------------------- | --------------------- |
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@x12i/static-memorix-explorer-api",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Static mock server providing full API parity for the Memorix Explorer API.",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",
7
7
  "exports": {
8
8
  ".": {
9
+ "types": "./dist/server.d.ts",
9
10
  "import": "./dist/server.js"
10
11
  },
11
12
  "./config": {
13
+ "types": "./dist/config.d.ts",
12
14
  "import": "./dist/config.js"
13
15
  }
14
16
  },
15
17
  "bin": {
18
+ "static-memorix-explorer-api": "dist/server.js",
16
19
  "mock-memorix-explorer-api": "dist/server.js"
17
20
  },
18
21
  "scripts": {
@@ -59,6 +62,6 @@
59
62
  "static"
60
63
  ],
61
64
  "engines": {
62
- "node": ">=18.0.0"
65
+ "node": ">=20.0.0"
63
66
  }
64
67
  }