@x12i/static-memorix-explorer-api 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +18 -2
- package/dist/routes/index.js +2 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +12 -3
- package/dist/storage/InMemoryStore.d.ts +2 -0
- package/dist/storage/InMemoryStore.js +37 -2
- package/dist/storage/fs.js +19 -4
- package/guides/demo-app.md +6 -3
- package/guides/managing-json-files.md +18 -0
- package/guides/running-the-service.md +13 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -121,7 +121,9 @@ The mock server accepts the following identity keys across fetch/write engines:
|
|
|
121
121
|
- `memoryId` — memory
|
|
122
122
|
|
|
123
123
|
Endpoints like `/records/full` and `/records/content` require exactly one identity key
|
|
124
|
-
per request. Memory collections are keyed by `memoryId
|
|
124
|
+
per request. Memory collections are keyed by `memoryId`; the unprefixed
|
|
125
|
+
`./mocks/data/memory/memory.json` is the seed and routed writes use the active
|
|
126
|
+
Catalox-prefixed filename.
|
|
125
127
|
|
|
126
128
|
## Snapshot alias translation
|
|
127
129
|
|
|
@@ -184,7 +186,7 @@ Full implementation walkthrough: [guides/demo-app.md](./guides/demo-app.md).
|
|
|
184
186
|
|-----|---------|-------|
|
|
185
187
|
| `PORT` | `5030` | Listen port |
|
|
186
188
|
| `HOST` | `0.0.0.0` | Listen host |
|
|
187
|
-
| `MOCKS_DIR` | `<
|
|
189
|
+
| `MOCKS_DIR` | bundled `<package>/mocks` | Fixture root |
|
|
188
190
|
| `DISK_FLUSH_DEBOUNCE_MS` | `300` | Debounce before disk flush |
|
|
189
191
|
| `MEMORIX_ORG_ID` | `memorix` | Organization prefix for DB routing |
|
|
190
192
|
| `MEMORIX_AGENT_ID` | `default-agent` | Agent prefix for catalox DB routing |
|
|
@@ -193,6 +195,11 @@ Full implementation walkthrough: [guides/demo-app.md](./guides/demo-app.md).
|
|
|
193
195
|
| `MEMORIX_EXPLORER_ENABLE_PIPELINE_WRITES` | `false` | Allow pipeline writes |
|
|
194
196
|
| `MEMORIX_EXPLORER_ENABLE_REGISTRY_WRITES` | `false` | Allow registry writes |
|
|
195
197
|
|
|
198
|
+
For mutation-capable installed deployments, set `MOCKS_DIR` to a writable,
|
|
199
|
+
persistent directory. Routing configuration is process-scoped and resolved on
|
|
200
|
+
first import; use one process per organization/agent context and one writer per
|
|
201
|
+
prefix.
|
|
202
|
+
|
|
196
203
|
## DB Router Simulation
|
|
197
204
|
|
|
198
205
|
The mock server does not connect to MongoDB, but it **tricks the frontend** into
|
|
@@ -253,6 +260,8 @@ The mock's querying and writing engines now support `target=memory` and
|
|
|
253
260
|
and `knowledgeId`.
|
|
254
261
|
|
|
255
262
|
- Folder structure: `./mocks/data/memory/memory.json` (seeded with example memories).
|
|
263
|
+
- Runtime memory writes persist to the active
|
|
264
|
+
`<agent>-memorix-catalox--memory.json` route.
|
|
256
265
|
- Inventory engine accepts `target=memory` and includes memory collections in
|
|
257
266
|
the simulated `inventorySummary`.
|
|
258
267
|
- Records engine accepts `target=memory` and supports `memoryId` for fetching.
|
|
@@ -269,6 +278,16 @@ and `knowledgeId`.
|
|
|
269
278
|
|
|
270
279
|
## Release history
|
|
271
280
|
|
|
281
|
+
### v1.3.1
|
|
282
|
+
- Rejected unsafe routing IDs, object types, and metadata keys before path use.
|
|
283
|
+
- Changed malformed and empty JSON handling from silent fallback to explicit
|
|
284
|
+
startup failure, preventing accidental data loss.
|
|
285
|
+
- Made JSON persistence atomic through same-directory temporary-file renames.
|
|
286
|
+
- Made list deletion restart-safe with routed tombstones.
|
|
287
|
+
- Fixed installed npm CLI execution through symlink-aware entry detection.
|
|
288
|
+
- Added packed-install, restart, traversal, corruption, and atomic-write checks.
|
|
289
|
+
- Documented writable `MOCKS_DIR`, process isolation, and single-writer limits.
|
|
290
|
+
|
|
272
291
|
### v1.3.0
|
|
273
292
|
- Added database-routing parity through deterministic JSON filename prefixes.
|
|
274
293
|
- Added separate entity, event, and Catalox/metadata prefix resolution.
|
package/dist/config.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export declare const DATA_DIR: string;
|
|
|
5
5
|
export declare const PORT: number;
|
|
6
6
|
export declare const HOST: string;
|
|
7
7
|
export declare const DISK_FLUSH_DEBOUNCE_MS: number;
|
|
8
|
+
export declare function validateRouteId(value: string, variableName?: string): string;
|
|
8
9
|
export declare const MEMORIX_ORG_ID: string;
|
|
9
10
|
export declare const MEMORIX_AGENT_ID: string;
|
|
10
11
|
export declare const MEMORIX_DEPLOYMENT_PROFILE: string;
|
package/dist/config.js
CHANGED
|
@@ -20,13 +20,23 @@ function resolveOrgId() {
|
|
|
20
20
|
if ((process.env.MEMORIX_DEPLOYMENT_PROFILE || "").toLowerCase() === "ebook") {
|
|
21
21
|
return "ebooks";
|
|
22
22
|
}
|
|
23
|
-
return process.env.MEMORIX_ORG_ID || "memorix";
|
|
23
|
+
return validateRouteId(process.env.MEMORIX_ORG_ID || "memorix", "MEMORIX_ORG_ID");
|
|
24
24
|
}
|
|
25
25
|
function resolveAgentId() {
|
|
26
26
|
if ((process.env.MEMORIX_DEPLOYMENT_PROFILE || "").toLowerCase() === "ebook") {
|
|
27
27
|
return "ebooks";
|
|
28
28
|
}
|
|
29
|
-
return process.env.MEMORIX_AGENT_ID || "default-agent";
|
|
29
|
+
return validateRouteId(process.env.MEMORIX_AGENT_ID || "default-agent", "MEMORIX_AGENT_ID");
|
|
30
|
+
}
|
|
31
|
+
export function validateRouteId(value, variableName = "route ID") {
|
|
32
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) ||
|
|
33
|
+
value.includes("--") ||
|
|
34
|
+
value === "." ||
|
|
35
|
+
value === "..") {
|
|
36
|
+
throw Object.assign(new Error(`${variableName} must contain only letters, numbers, dot, underscore, or hyphen; ` +
|
|
37
|
+
`it must not contain path separators or the reserved "--" delimiter`), { statusCode: 400 });
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
30
40
|
}
|
|
31
41
|
export const MEMORIX_ORG_ID = resolveOrgId();
|
|
32
42
|
export const MEMORIX_AGENT_ID = resolveAgentId();
|
|
@@ -62,6 +72,12 @@ export function buildMetadataFilePrefix() {
|
|
|
62
72
|
return buildCataloxDbString();
|
|
63
73
|
}
|
|
64
74
|
export function prefixedJsonFilename(prefix, basename) {
|
|
75
|
+
validateRouteId(prefix, "file routing prefix");
|
|
76
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(basename) || basename.includes("--")) {
|
|
77
|
+
throw Object.assign(new Error(`Unsafe JSON fixture basename: ${basename}`), {
|
|
78
|
+
statusCode: 400,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
65
81
|
return `${prefix}--${basename}.json`;
|
|
66
82
|
}
|
|
67
83
|
// ---------------------------------------------------------------------------
|
package/dist/routes/index.js
CHANGED
|
@@ -228,7 +228,8 @@ export async function registerRoutes(app) {
|
|
|
228
228
|
return { ok: false, error: "Metadata writes are disabled" };
|
|
229
229
|
}
|
|
230
230
|
const { listId } = request.params;
|
|
231
|
-
store.
|
|
231
|
+
store.deleteMeta(`lists/${listId}`);
|
|
232
|
+
await store.flushAll();
|
|
232
233
|
return { ok: true, deleted: listId };
|
|
233
234
|
});
|
|
234
235
|
// ---------- F. Narratives Engine ----------
|
package/dist/server.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface StartServerOptions {
|
|
|
6
6
|
installSignalHandlers?: boolean;
|
|
7
7
|
}
|
|
8
8
|
export declare function parseCliArgs(args: string[]): StartServerOptions;
|
|
9
|
+
export declare function isCliEntrypoint(argvPath: string | undefined, moduleUrl: string): boolean;
|
|
9
10
|
/** Build a loaded Fastify instance without opening a network port. */
|
|
10
11
|
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
12
|
/** Build and listen. Options override PORT/HOST environment configuration. */
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import Fastify from "fastify";
|
|
3
|
+
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { store } from "./storage/InMemoryStore.js";
|
|
@@ -31,6 +32,16 @@ export function parseCliArgs(args) {
|
|
|
31
32
|
}
|
|
32
33
|
return options;
|
|
33
34
|
}
|
|
35
|
+
export function isCliEntrypoint(argvPath, moduleUrl) {
|
|
36
|
+
if (!argvPath)
|
|
37
|
+
return false;
|
|
38
|
+
try {
|
|
39
|
+
return fs.realpathSync(path.resolve(argvPath)) === fileURLToPath(moduleUrl);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
34
45
|
/** Build a loaded Fastify instance without opening a network port. */
|
|
35
46
|
export async function buildServer(options = {}) {
|
|
36
47
|
const app = Fastify({ logger: options.logger ?? false });
|
|
@@ -74,9 +85,7 @@ export async function startServer(options = {}) {
|
|
|
74
85
|
}
|
|
75
86
|
return app;
|
|
76
87
|
}
|
|
77
|
-
const isCliEntry = process.argv[1]
|
|
78
|
-
? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
79
|
-
: false;
|
|
88
|
+
const isCliEntry = isCliEntrypoint(process.argv[1], import.meta.url);
|
|
80
89
|
if (isCliEntry) {
|
|
81
90
|
Promise.resolve().then(() => startServer(parseCliArgs(process.argv.slice(2)))).catch((err) => {
|
|
82
91
|
// eslint-disable-next-line no-console
|
|
@@ -12,6 +12,8 @@ export declare class InMemoryStore {
|
|
|
12
12
|
private loadDataCollections;
|
|
13
13
|
getMeta<T = any>(key: string): T | undefined;
|
|
14
14
|
setMeta(key: string, data: any, persist?: boolean): void;
|
|
15
|
+
/** Persist a routed tombstone so an unprefixed seed does not reappear. */
|
|
16
|
+
deleteMeta(key: string): void;
|
|
15
17
|
getMetaKeys(): string[];
|
|
16
18
|
getCollection(objectType: string, ct: ContentType): any[];
|
|
17
19
|
setCollection(objectType: string, ct: ContentType, data: any[]): void;
|
|
@@ -1,11 +1,28 @@
|
|
|
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, buildDataFilePrefix, buildMetadataFilePrefix, prefixedJsonFilename, } from "../config.js";
|
|
4
|
+
import { MOCKS_DIR, METADATA_DIR, DATA_DIR, buildDataFilePrefix, buildMetadataFilePrefix, prefixedJsonFilename, validateRouteId, } 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";
|
|
8
8
|
const DIRTY = Symbol("dirty");
|
|
9
|
+
function routedMetadataPath(key) {
|
|
10
|
+
const parts = key.split("/");
|
|
11
|
+
if (parts.length === 1 && parts[0] === "agents") {
|
|
12
|
+
return path.join(METADATA_DIR, prefixedJsonFilename(buildMetadataFilePrefix(), "agents"));
|
|
13
|
+
}
|
|
14
|
+
if (parts.length !== 2) {
|
|
15
|
+
throw Object.assign(new Error(`Unsafe metadata key: ${key}`), { statusCode: 400 });
|
|
16
|
+
}
|
|
17
|
+
const [section, name] = parts;
|
|
18
|
+
if (!["object-types", "lists", "write-descriptors", "narratives"].includes(section)) {
|
|
19
|
+
throw Object.assign(new Error(`Unsupported metadata section: ${section}`), {
|
|
20
|
+
statusCode: 400,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
validateRouteId(name, "metadata fixture name");
|
|
24
|
+
return path.join(METADATA_DIR, section, prefixedJsonFilename(buildMetadataFilePrefix(), name));
|
|
25
|
+
}
|
|
9
26
|
export class InMemoryStore {
|
|
10
27
|
collections = new Map(); // key: `${objectType}/${contentType}`
|
|
11
28
|
metadata = new Map();
|
|
@@ -108,7 +125,7 @@ export class InMemoryStore {
|
|
|
108
125
|
}
|
|
109
126
|
else {
|
|
110
127
|
this.metadata.set(key, {
|
|
111
|
-
path:
|
|
128
|
+
path: routedMetadataPath(key),
|
|
112
129
|
data,
|
|
113
130
|
[DIRTY]: true,
|
|
114
131
|
});
|
|
@@ -116,11 +133,28 @@ export class InMemoryStore {
|
|
|
116
133
|
if (persist)
|
|
117
134
|
this.scheduleMetaFlush(key);
|
|
118
135
|
}
|
|
136
|
+
/** Persist a routed tombstone so an unprefixed seed does not reappear. */
|
|
137
|
+
deleteMeta(key) {
|
|
138
|
+
const existing = this.metadata.get(key);
|
|
139
|
+
if (existing) {
|
|
140
|
+
existing.data = null;
|
|
141
|
+
existing[DIRTY] = true;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
this.metadata.set(key, {
|
|
145
|
+
path: routedMetadataPath(key),
|
|
146
|
+
data: null,
|
|
147
|
+
[DIRTY]: true,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
this.scheduleMetaFlush(key);
|
|
151
|
+
}
|
|
119
152
|
getMetaKeys() {
|
|
120
153
|
return [...this.metadata.keys()];
|
|
121
154
|
}
|
|
122
155
|
// ---- Collection access ----
|
|
123
156
|
getCollection(objectType, ct) {
|
|
157
|
+
validateRouteId(objectType, "object type");
|
|
124
158
|
const key = `${objectType}/${ct}`;
|
|
125
159
|
const c = this.collections.get(key);
|
|
126
160
|
if (!c) {
|
|
@@ -134,6 +168,7 @@ export class InMemoryStore {
|
|
|
134
168
|
return c.data;
|
|
135
169
|
}
|
|
136
170
|
setCollection(objectType, ct, data) {
|
|
171
|
+
validateRouteId(objectType, "object type");
|
|
137
172
|
const key = `${objectType}/${ct}`;
|
|
138
173
|
const c = this.collections.get(key);
|
|
139
174
|
if (c) {
|
package/dist/storage/fs.js
CHANGED
|
@@ -15,16 +15,31 @@ export function readJson(p, fallback) {
|
|
|
15
15
|
try {
|
|
16
16
|
const raw = fs.readFileSync(p, "utf8");
|
|
17
17
|
if (!raw.trim())
|
|
18
|
-
|
|
18
|
+
throw new Error("file is empty");
|
|
19
19
|
return JSON.parse(raw);
|
|
20
20
|
}
|
|
21
|
-
catch {
|
|
22
|
-
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "ENOENT")
|
|
23
|
+
return fallback;
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
throw new Error(`Failed to read JSON fixture ${p}: ${message}`);
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
export function writeJson(p, data) {
|
|
26
29
|
ensureDir(path.dirname(p));
|
|
27
|
-
|
|
30
|
+
const serialized = JSON.stringify(data, null, 2);
|
|
31
|
+
if (serialized === undefined) {
|
|
32
|
+
throw new Error(`Cannot serialize undefined JSON fixture: ${p}`);
|
|
33
|
+
}
|
|
34
|
+
const temp = path.join(path.dirname(p), `.${path.basename(p)}.${process.pid}.tmp`);
|
|
35
|
+
try {
|
|
36
|
+
fs.writeFileSync(temp, serialized + "\n", "utf8");
|
|
37
|
+
fs.renameSync(temp, p);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
if (fs.existsSync(temp))
|
|
41
|
+
fs.unlinkSync(temp);
|
|
42
|
+
}
|
|
28
43
|
}
|
|
29
44
|
export function listJsonFiles(dir) {
|
|
30
45
|
if (!isDir(dir))
|
package/guides/demo-app.md
CHANGED
|
@@ -324,7 +324,9 @@ with fields for title, area, status, priority, and assignee. On submit:
|
|
|
324
324
|
descriptor for the chosen area (`product-task-write`, etc.).
|
|
325
325
|
3. The server validates the payload against the descriptor's JSON schema.
|
|
326
326
|
4. On success, the record is appended to the in-memory collection and the
|
|
327
|
-
|
|
327
|
+
flush writes it to
|
|
328
|
+
`mocks/data/<area>/<org>-memorix-entities--snapshots.json`. If that routed
|
|
329
|
+
file does not exist yet, the unprefixed `snapshots.json` is used as seed data.
|
|
328
330
|
5. The UI unshifts the returned record into `state.tasks`, selects it, and
|
|
329
331
|
re-renders.
|
|
330
332
|
|
|
@@ -385,8 +387,9 @@ scale. For higher volume you'd model comments as `events` records keyed by
|
|
|
385
387
|
## Why each area is a separate collection
|
|
386
388
|
|
|
387
389
|
We could have put all tasks in one `tasks` collection with an `area` field.
|
|
388
|
-
The demo deliberately splits
|
|
389
|
-
`marketing/snapshots.json`, and `admin/snapshots.json
|
|
390
|
+
The demo deliberately splits the seed fixtures across `product/snapshots.json`,
|
|
391
|
+
`marketing/snapshots.json`, and `admin/snapshots.json`; runtime mutations go to
|
|
392
|
+
their routed, prefixed counterparts. This demonstrates:
|
|
390
393
|
|
|
391
394
|
1. **Multi-entity applications** — typical real apps have more than one object
|
|
392
395
|
type. Showing 3 object types is more representative than 1.
|
|
@@ -29,6 +29,10 @@ the routing matrix is:
|
|
|
29
29
|
4. Unprefixed seed files are never overwritten while routed mode is active.
|
|
30
30
|
5. Files belonging to a different prefix are ignored by the active process.
|
|
31
31
|
|
|
32
|
+
Deleting a metadata document writes JSON `null` as a routed tombstone. This is
|
|
33
|
+
intentional: removing only the routed file would cause the unprefixed seed to
|
|
34
|
+
reappear on the next restart.
|
|
35
|
+
|
|
32
36
|
This makes onboarding migration-safe: existing files such as
|
|
33
37
|
`product/snapshots.json` continue to seed a new tenant, while the first write
|
|
34
38
|
creates `product/neo-memorix-entities--snapshots.json`. Starting with another
|
|
@@ -37,6 +41,20 @@ organization ID selects a different file and therefore different state.
|
|
|
37
41
|
The delimiter `--` is reserved. Do not use it inside ordinary, unprefixed
|
|
38
42
|
fixture basenames.
|
|
39
43
|
|
|
44
|
+
Routing IDs are validated before any files are opened. Organization IDs,
|
|
45
|
+
agent IDs, object types, and metadata names may contain letters, numbers,
|
|
46
|
+
dot, underscore, and hyphen, but cannot contain path separators, `..`, or the
|
|
47
|
+
reserved `--` delimiter.
|
|
48
|
+
|
|
49
|
+
Malformed or empty JSON stops startup with the affected path in the error;
|
|
50
|
+
it is never silently converted to an empty collection. Writes use a temporary
|
|
51
|
+
file and same-directory rename so readers never observe a partially written
|
|
52
|
+
JSON document.
|
|
53
|
+
|
|
54
|
+
Routing is process-scoped. Run one server process per organization/agent route,
|
|
55
|
+
and use only one writer process for a given prefix. Two processes writing the
|
|
56
|
+
same routed file concurrently use last-writer-wins semantics.
|
|
57
|
+
|
|
40
58
|
### Inspect the active route
|
|
41
59
|
|
|
42
60
|
```bash
|
|
@@ -104,7 +104,19 @@ All configuration is via environment variables. Defaults work out of the box.
|
|
|
104
104
|
| --------- | ----------- | ------------------------------ |
|
|
105
105
|
| `PORT` | `5030` | HTTP listen port |
|
|
106
106
|
| `HOST` | `0.0.0.0` | HTTP listen host |
|
|
107
|
-
| `MOCKS_DIR` | `<
|
|
107
|
+
| `MOCKS_DIR` | bundled `<package>/mocks` | Root directory for JSON fixtures |
|
|
108
|
+
|
|
109
|
+
`MOCKS_DIR` must be writable if you use mutation endpoints. For installed or
|
|
110
|
+
containerized deployments, point it at a persistent application-owned volume
|
|
111
|
+
rather than relying on the package's bundled seed directory:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
MOCKS_DIR=/var/lib/my-app/memorix-mocks static-memorix-explorer-api --port 5030
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Configuration is resolved when the package is imported. In programmatic usage,
|
|
118
|
+
set `process.env.MOCKS_DIR`, `MEMORIX_ORG_ID`, and `MEMORIX_AGENT_ID` before the
|
|
119
|
+
first dynamic import, and run separate processes for separate routing contexts.
|
|
108
120
|
|
|
109
121
|
### DB Router Simulation
|
|
110
122
|
|