@x12i/static-memorix 2.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 +431 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +278 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +109 -0
- package/dist/engine/aliases.d.ts +32 -0
- package/dist/engine/aliases.js +74 -0
- package/dist/engine/associations.d.ts +26 -0
- package/dist/engine/associations.js +60 -0
- package/dist/engine/identity.d.ts +22 -0
- package/dist/engine/identity.js +50 -0
- package/dist/engine/inventory.d.ts +29 -0
- package/dist/engine/inventory.js +133 -0
- package/dist/engine/lists.d.ts +18 -0
- package/dist/engine/lists.js +62 -0
- package/dist/engine/narratives.d.ts +16 -0
- package/dist/engine/narratives.js +67 -0
- package/dist/engine/objectTypes.d.ts +11 -0
- package/dist/engine/objectTypes.js +71 -0
- package/dist/engine/query.d.ts +38 -0
- package/dist/engine/query.js +191 -0
- package/dist/engine/records.d.ts +23 -0
- package/dist/engine/records.js +152 -0
- package/dist/engine/snapshots.d.ts +17 -0
- package/dist/engine/snapshots.js +110 -0
- package/dist/engine/write.d.ts +30 -0
- package/dist/engine/write.js +163 -0
- package/dist/routes/helpers.d.ts +4 -0
- package/dist/routes/helpers.js +29 -0
- package/dist/routes/index.d.ts +2 -0
- package/dist/routes/index.js +402 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +130 -0
- package/dist/storage/InMemoryStore.d.ts +31 -0
- package/dist/storage/InMemoryStore.js +242 -0
- package/dist/storage/fs.d.ts +6 -0
- package/dist/storage/fs.js +54 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +37 -0
- package/guides/demo-app.md +527 -0
- package/guides/managing-json-files.md +384 -0
- package/guides/running-the-service.md +269 -0
- package/guides/using-the-api.md +265 -0
- package/mocks/data/admin/snapshots.json +52 -0
- package/mocks/data/assets/analysis.json +14 -0
- package/mocks/data/assets/decisions.json +14 -0
- package/mocks/data/assets/events.json +4 -0
- package/mocks/data/assets/snapshots.json +49 -0
- package/mocks/data/findings/analysis.json +4 -0
- package/mocks/data/findings/decisions.json +4 -0
- package/mocks/data/findings/events.json +3 -0
- package/mocks/data/findings/snapshots.json +21 -0
- package/mocks/data/marketing/snapshots.json +75 -0
- package/mocks/data/memory/memory.json +31 -0
- package/mocks/data/product/snapshots.json +84 -0
- package/mocks/data/system/inventory.json +8 -0
- package/mocks/data/users/snapshots.json +5 -0
- package/mocks/demo.html +828 -0
- package/mocks/metadata/agents.json +7 -0
- package/mocks/metadata/lists/assets-default.json +22 -0
- package/mocks/metadata/lists/backlog.json +10 -0
- package/mocks/metadata/lists/findings-default.json +9 -0
- package/mocks/metadata/lists/my-plate.json +10 -0
- package/mocks/metadata/lists/this-week.json +10 -0
- package/mocks/metadata/narratives/admin.json +7 -0
- package/mocks/metadata/narratives/assets.json +17 -0
- package/mocks/metadata/narratives/findings.json +7 -0
- package/mocks/metadata/narratives/marketing.json +17 -0
- package/mocks/metadata/narratives/product.json +12 -0
- package/mocks/metadata/object-types/admin.json +6 -0
- package/mocks/metadata/object-types/assets.json +9 -0
- package/mocks/metadata/object-types/findings.json +8 -0
- package/mocks/metadata/object-types/marketing.json +6 -0
- package/mocks/metadata/object-types/memory.json +6 -0
- package/mocks/metadata/object-types/product.json +6 -0
- package/mocks/metadata/object-types/users.json +13 -0
- package/mocks/metadata/write-descriptors/admin-task-write.json +20 -0
- package/mocks/metadata/write-descriptors/assets-analysis-write.json +14 -0
- package/mocks/metadata/write-descriptors/marketing-task-write.json +20 -0
- package/mocks/metadata/write-descriptors/memory-write.json +14 -0
- package/mocks/metadata/write-descriptors/product-task-write.json +20 -0
- package/package.json +67 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import mingo from "mingo";
|
|
2
|
+
export function parseFilterToken(token) {
|
|
3
|
+
const idx1 = token.indexOf(":");
|
|
4
|
+
if (idx1 === -1)
|
|
5
|
+
return null;
|
|
6
|
+
const field = token.slice(0, idx1);
|
|
7
|
+
const rest = token.slice(idx1 + 1);
|
|
8
|
+
const idx2 = rest.indexOf(":");
|
|
9
|
+
if (idx2 === -1) {
|
|
10
|
+
// treat as eq
|
|
11
|
+
return { field, op: "eq", value: coerce(rest), raw: token };
|
|
12
|
+
}
|
|
13
|
+
const op = rest.slice(0, idx2);
|
|
14
|
+
const value = rest.slice(idx2 + 1);
|
|
15
|
+
// Narrative virtual filter interception: narrativeId:eq:has-vulns /
|
|
16
|
+
// narrativeKey:eq:has-vulns -> evaluate doc.narratives['has-vulns'].
|
|
17
|
+
if (field === "narrativeId" || field === "narrativeKey") {
|
|
18
|
+
return {
|
|
19
|
+
field,
|
|
20
|
+
op: op || "eq",
|
|
21
|
+
value: coerceForOp(op || "eq", value),
|
|
22
|
+
raw: token,
|
|
23
|
+
isVirtual: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (!["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "exists", "regex"].includes(op)) {
|
|
27
|
+
return { field, op: "eq", value: coerce(rest), raw: token };
|
|
28
|
+
}
|
|
29
|
+
return { field, op, value: coerceForOp(op, value), raw: token };
|
|
30
|
+
}
|
|
31
|
+
export function coerce(value) {
|
|
32
|
+
if (value === "true")
|
|
33
|
+
return true;
|
|
34
|
+
if (value === "false")
|
|
35
|
+
return false;
|
|
36
|
+
if (value === "null")
|
|
37
|
+
return null;
|
|
38
|
+
if (value !== "" && !isNaN(Number(value)))
|
|
39
|
+
return Number(value);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function coerceForOp(op, value) {
|
|
43
|
+
if (op === "in" || op === "nin") {
|
|
44
|
+
return value
|
|
45
|
+
.split("|")
|
|
46
|
+
.map((v) => coerce(v.trim()))
|
|
47
|
+
.filter((v) => v !== "");
|
|
48
|
+
}
|
|
49
|
+
if (op === "exists") {
|
|
50
|
+
return !(value === "false" || value === "0");
|
|
51
|
+
}
|
|
52
|
+
if (op === "regex") {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
return coerce(value);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse the filter param. Supports repeated values (array) and `;` separators.
|
|
59
|
+
* Returns virtual narrative filters separately so the caller can rewrite them.
|
|
60
|
+
*/
|
|
61
|
+
export function parseExplorerFilters(filter) {
|
|
62
|
+
const tokens = toArray(filter)
|
|
63
|
+
.flatMap((f) => f.split(";"))
|
|
64
|
+
.map((f) => f.trim())
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
const criteria = {};
|
|
67
|
+
const virtual = [];
|
|
68
|
+
for (const token of tokens) {
|
|
69
|
+
const parsed = parseFilterToken(token);
|
|
70
|
+
if (!parsed)
|
|
71
|
+
continue;
|
|
72
|
+
if (parsed.isVirtual) {
|
|
73
|
+
virtual.push(parsed);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
applyFilterToCriteria(criteria, parsed);
|
|
77
|
+
}
|
|
78
|
+
return { criteria, virtual };
|
|
79
|
+
}
|
|
80
|
+
function applyFilterToCriteria(criteria, parsed) {
|
|
81
|
+
const { field, op, value } = parsed;
|
|
82
|
+
switch (op) {
|
|
83
|
+
case "eq":
|
|
84
|
+
criteria[field] = value;
|
|
85
|
+
break;
|
|
86
|
+
case "ne":
|
|
87
|
+
criteria[field] = { $ne: value };
|
|
88
|
+
break;
|
|
89
|
+
case "gt":
|
|
90
|
+
criteria[field] = { $gt: value };
|
|
91
|
+
break;
|
|
92
|
+
case "gte":
|
|
93
|
+
criteria[field] = { $gte: value };
|
|
94
|
+
break;
|
|
95
|
+
case "lt":
|
|
96
|
+
criteria[field] = { $lt: value };
|
|
97
|
+
break;
|
|
98
|
+
case "lte":
|
|
99
|
+
criteria[field] = { $lte: value };
|
|
100
|
+
break;
|
|
101
|
+
case "in":
|
|
102
|
+
criteria[field] = { $in: value };
|
|
103
|
+
break;
|
|
104
|
+
case "nin":
|
|
105
|
+
criteria[field] = { $nin: value };
|
|
106
|
+
break;
|
|
107
|
+
case "exists":
|
|
108
|
+
criteria[field] = { $exists: value };
|
|
109
|
+
break;
|
|
110
|
+
case "regex":
|
|
111
|
+
criteria[field] = { $regex: value, $options: "i" };
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function parseSortParams(sort) {
|
|
116
|
+
const tokens = toArray(sort)
|
|
117
|
+
.flatMap((s) => s.split(";"))
|
|
118
|
+
.map((s) => s.trim())
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
const sortSpec = {};
|
|
121
|
+
for (const token of tokens) {
|
|
122
|
+
const [field, dirRaw] = token.split(":");
|
|
123
|
+
const dir = (dirRaw || "asc").toLowerCase();
|
|
124
|
+
sortSpec[field] = dir === "desc" ? -1 : 1;
|
|
125
|
+
}
|
|
126
|
+
return sortSpec;
|
|
127
|
+
}
|
|
128
|
+
function toArray(v) {
|
|
129
|
+
if (v === undefined || v === null)
|
|
130
|
+
return [];
|
|
131
|
+
return Array.isArray(v) ? v : [v];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Resolve virtual narrative filters into a predicate that checks
|
|
135
|
+
* doc.narratives[key] presence / truthiness.
|
|
136
|
+
*/
|
|
137
|
+
export function applyVirtualNarrativeFilters(records, virtual) {
|
|
138
|
+
if (virtual.length === 0)
|
|
139
|
+
return records;
|
|
140
|
+
return records.filter((doc) => {
|
|
141
|
+
const narratives = doc.narratives || {};
|
|
142
|
+
return virtual.every((vf) => {
|
|
143
|
+
const key = String(vf.value);
|
|
144
|
+
const actual = narratives[key];
|
|
145
|
+
if (vf.op === "exists")
|
|
146
|
+
return vf.value ? actual !== undefined : actual === undefined;
|
|
147
|
+
if (vf.op === "eq")
|
|
148
|
+
return actual === true || actual === vf.value;
|
|
149
|
+
return true;
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Execute a query against an in-memory record array using mingo, with
|
|
155
|
+
* full-text search, virtual narrative filters, sorting, and pagination.
|
|
156
|
+
*/
|
|
157
|
+
export function queryMockCollection(records, queryParams) {
|
|
158
|
+
let pipeline = [...records];
|
|
159
|
+
// 1. Intercept narrative virtual filters
|
|
160
|
+
const { criteria, virtual } = parseExplorerFilters(queryParams.filter);
|
|
161
|
+
pipeline = applyVirtualNarrativeFilters(pipeline, virtual);
|
|
162
|
+
// 2. Full-Text Search
|
|
163
|
+
const term = (queryParams.searchText || queryParams.q || "").toLowerCase();
|
|
164
|
+
if (term) {
|
|
165
|
+
pipeline = pipeline.filter((doc) => JSON.stringify(doc).toLowerCase().includes(term));
|
|
166
|
+
}
|
|
167
|
+
// 3. Mingo Mongo Query Execution
|
|
168
|
+
const query = new mingo.Query(criteria);
|
|
169
|
+
let cursor = query.find(pipeline);
|
|
170
|
+
// 4. Sorting
|
|
171
|
+
const sortSpec = parseSortParams(queryParams.sort);
|
|
172
|
+
if (Object.keys(sortSpec).length > 0) {
|
|
173
|
+
cursor = cursor.sort(sortSpec);
|
|
174
|
+
}
|
|
175
|
+
// Materialize all matched docs (mingo cursors are single-use, so we cannot
|
|
176
|
+
// call .count() and then .all() on the same cursor).
|
|
177
|
+
const matched = cursor.all();
|
|
178
|
+
const total = matched.length;
|
|
179
|
+
// 5. Pagination
|
|
180
|
+
const limit = Math.min(Number(queryParams.limit) || 50, 500);
|
|
181
|
+
const offset = Number(queryParams.offset) || Number(queryParams.skip) || 0;
|
|
182
|
+
const rows = matched.slice(offset, offset + limit);
|
|
183
|
+
return {
|
|
184
|
+
rows,
|
|
185
|
+
page: {
|
|
186
|
+
offset,
|
|
187
|
+
limit,
|
|
188
|
+
total: queryParams.includeTotal ? total : undefined,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -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,152 @@
|
|
|
1
|
+
import { store } from "../storage/InMemoryStore.js";
|
|
2
|
+
import { CONTENT_TYPES, } from "../types.js";
|
|
3
|
+
import { queryMockCollection } from "../engine/query.js";
|
|
4
|
+
import { executeListDescriptor, getListDescriptor } from "../engine/lists.js";
|
|
5
|
+
import { findByIdentity } from "../engine/identity.js";
|
|
6
|
+
export function inferIdField(entityName, ct) {
|
|
7
|
+
if (ct === "memory")
|
|
8
|
+
return "memoryId";
|
|
9
|
+
const sample = store.getCollection(entityName, ct)[0];
|
|
10
|
+
if (sample) {
|
|
11
|
+
if ("recordId" in sample)
|
|
12
|
+
return "recordId";
|
|
13
|
+
if ("entityId" in sample)
|
|
14
|
+
return "entityId";
|
|
15
|
+
if ("id" in sample)
|
|
16
|
+
return "id";
|
|
17
|
+
}
|
|
18
|
+
return "recordId";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Find a record within a collection using an identity map (recordId, entityId,
|
|
22
|
+
* eventId, knowledgeId, or memoryId). At least one identity key is required.
|
|
23
|
+
*/
|
|
24
|
+
export function findRecordByIdentity(entityName, ct, identity) {
|
|
25
|
+
const records = store.getCollection(entityName, ct);
|
|
26
|
+
return findByIdentity(records, identity);
|
|
27
|
+
}
|
|
28
|
+
export function findRecord(entityName, ct, recordId) {
|
|
29
|
+
const idField = inferIdField(entityName, ct);
|
|
30
|
+
return store
|
|
31
|
+
.getCollection(entityName, ct)
|
|
32
|
+
.find((d) => String(d[idField]) === String(recordId));
|
|
33
|
+
}
|
|
34
|
+
export async function getRecordsCollection(params) {
|
|
35
|
+
const target = params.target;
|
|
36
|
+
// The `memory` target routes to the memory tier regardless of entityName.
|
|
37
|
+
if (target === "memory") {
|
|
38
|
+
const ct = "memory";
|
|
39
|
+
const records = store.getCollection("memory", ct);
|
|
40
|
+
return queryMockCollection(records, params);
|
|
41
|
+
}
|
|
42
|
+
const entityName = params.entityName;
|
|
43
|
+
const mode = params.mode || "auto";
|
|
44
|
+
const listId = params.listId;
|
|
45
|
+
if (mode === "descriptor" || (mode === "auto" && listId)) {
|
|
46
|
+
const resolvedListId = listId || `${entityName}-default`;
|
|
47
|
+
const listSpec = getListDescriptor(resolvedListId);
|
|
48
|
+
if (listSpec) {
|
|
49
|
+
return executeListDescriptor(listSpec, params);
|
|
50
|
+
}
|
|
51
|
+
// fall through to raw if descriptor missing
|
|
52
|
+
}
|
|
53
|
+
const ct = params.contentType || "snapshots";
|
|
54
|
+
const records = store.getCollection(entityName, ct);
|
|
55
|
+
return queryMockCollection(records, params);
|
|
56
|
+
}
|
|
57
|
+
export function getRecordsFull(entityName, recordId) {
|
|
58
|
+
const identity = { recordId };
|
|
59
|
+
const snapshot = findRecordByIdentity(entityName, "snapshots", identity);
|
|
60
|
+
const analysis = findRecordByIdentity(entityName, "analysis", identity);
|
|
61
|
+
const decisions = findRecordByIdentity(entityName, "decisions", identity);
|
|
62
|
+
const memory = findRecordByIdentity(entityName, "memory", identity);
|
|
63
|
+
return {
|
|
64
|
+
recordId,
|
|
65
|
+
entityName,
|
|
66
|
+
contentTypes: { snapshot, analysis, decisions, memory },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function getRecordsItem(entityName, recordId, itemDescriptor) {
|
|
70
|
+
const record = findRecordByIdentity(entityName, "snapshots", { recordId });
|
|
71
|
+
if (!record)
|
|
72
|
+
return undefined;
|
|
73
|
+
if (!itemDescriptor)
|
|
74
|
+
return record;
|
|
75
|
+
// Hydrate sections/fields from metadata/items
|
|
76
|
+
const sections = itemDescriptor.sections || [];
|
|
77
|
+
const out = { recordId, entityName };
|
|
78
|
+
for (const section of sections) {
|
|
79
|
+
const fields = section.fields || [];
|
|
80
|
+
out[section.key || section.id] = fields.map((f) => ({
|
|
81
|
+
field: f.field || f.key,
|
|
82
|
+
value: getPath(record, f.field || f.key),
|
|
83
|
+
label: f.label,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Fetch a single content record by id. Supports the memory tier via memoryId.
|
|
90
|
+
* If entityName is empty and contentType is "memory", defaults to "memory".
|
|
91
|
+
* Otherwise, searches across all object types in the store.
|
|
92
|
+
*/
|
|
93
|
+
export function getRecordContent(entityName, contentType, identity) {
|
|
94
|
+
if (contentType === "memory") {
|
|
95
|
+
entityName = entityName || "memory";
|
|
96
|
+
return findRecordByIdentity(entityName, contentType, identity);
|
|
97
|
+
}
|
|
98
|
+
if (!entityName) {
|
|
99
|
+
// Search across all collections for the identity key
|
|
100
|
+
const keys = store.getCollectionKeys();
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
const [ot, ct] = key.split("/");
|
|
103
|
+
if (ct !== contentType)
|
|
104
|
+
continue;
|
|
105
|
+
const found = findRecordByIdentity(ot, contentType, identity);
|
|
106
|
+
if (found)
|
|
107
|
+
return found;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return findRecordByIdentity(entityName, contentType, identity);
|
|
112
|
+
}
|
|
113
|
+
export function getRawCollection(entityName, collectionName) {
|
|
114
|
+
// memory tier is keyed by a standalone `memory` object type
|
|
115
|
+
if (collectionName === "memory" || entityName === "memory") {
|
|
116
|
+
return store.getCollection("memory", "memory");
|
|
117
|
+
}
|
|
118
|
+
const ct = collectionName;
|
|
119
|
+
if (CONTENT_TYPES.includes(ct)) {
|
|
120
|
+
return store.getCollection(entityName, ct);
|
|
121
|
+
}
|
|
122
|
+
// allow objectType/contentType form
|
|
123
|
+
const [ot, c] = collectionName.split("/");
|
|
124
|
+
if (CONTENT_TYPES.includes(c)) {
|
|
125
|
+
return store.getCollection(ot, c);
|
|
126
|
+
}
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
export function getRawItem(collectionName, recordId) {
|
|
130
|
+
if (collectionName === "memory") {
|
|
131
|
+
return findRecordByIdentity("memory", "memory", { memoryId: recordId });
|
|
132
|
+
}
|
|
133
|
+
const ct = collectionName;
|
|
134
|
+
if (CONTENT_TYPES.includes(ct)) {
|
|
135
|
+
return findRecordByIdentity("memory", ct, { recordId }) ??
|
|
136
|
+
findRecord("__any__", ct, recordId);
|
|
137
|
+
}
|
|
138
|
+
const [entityName, c] = collectionName.split("/");
|
|
139
|
+
return findRecord(entityName, c, recordId);
|
|
140
|
+
}
|
|
141
|
+
export async function getWorkspaceRecords(params) {
|
|
142
|
+
// cross-entity query using a workspace list descriptor
|
|
143
|
+
const listId = params.listId || "workspace-default";
|
|
144
|
+
const listSpec = getListDescriptor(listId);
|
|
145
|
+
if (!listSpec) {
|
|
146
|
+
return { rows: [], page: { offset: 0, limit: 50 } };
|
|
147
|
+
}
|
|
148
|
+
return executeListDescriptor(listSpec, params);
|
|
149
|
+
}
|
|
150
|
+
function getPath(obj, path) {
|
|
151
|
+
return path.split(".").reduce((acc, k) => (acc == null ? undefined : acc[k]), obj);
|
|
152
|
+
}
|
|
@@ -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,110 @@
|
|
|
1
|
+
import { store } from "../storage/InMemoryStore.js";
|
|
2
|
+
import { buildAssociatedBucket, aliasClientToStorage, resolveInclude, } from "../engine/aliases.js";
|
|
3
|
+
import { inferIdField } from "../engine/records.js";
|
|
4
|
+
export function getSnapshotRaw(objectType, recordId) {
|
|
5
|
+
const idField = inferIdField(objectType, "snapshots");
|
|
6
|
+
const rawDoc = store
|
|
7
|
+
.getCollection(objectType, "snapshots")
|
|
8
|
+
.find((d) => String(d[idField]) === String(recordId));
|
|
9
|
+
if (!rawDoc)
|
|
10
|
+
return undefined;
|
|
11
|
+
return { rawDoc, idField };
|
|
12
|
+
}
|
|
13
|
+
export function getSnapshotNormalized(objectType, recordId, include) {
|
|
14
|
+
const found = getSnapshotRaw(objectType, recordId);
|
|
15
|
+
if (!found)
|
|
16
|
+
return undefined;
|
|
17
|
+
const { rawDoc } = found;
|
|
18
|
+
const { associated, strippedDoc } = buildAssociatedBucket(rawDoc);
|
|
19
|
+
const flags = resolveInclude(include);
|
|
20
|
+
const out = { ...strippedDoc, associated };
|
|
21
|
+
if (flags) {
|
|
22
|
+
for (const flag of flags) {
|
|
23
|
+
applyIncludeFlag(out, rawDoc, associated, flag);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function applyIncludeFlag(out, rawDoc, associated, flag) {
|
|
29
|
+
switch (flag) {
|
|
30
|
+
case "data":
|
|
31
|
+
out.data = associated.data ?? rawDoc.associatedData ?? [];
|
|
32
|
+
break;
|
|
33
|
+
case "discovery":
|
|
34
|
+
out.discovery =
|
|
35
|
+
associated.discovery ?? rawDoc.associatedInferred ?? rawDoc.associatedDiscovery ?? [];
|
|
36
|
+
break;
|
|
37
|
+
case "analysis":
|
|
38
|
+
out.analysis = associated.analysis ?? rawDoc.associatedAnalysis ?? [];
|
|
39
|
+
break;
|
|
40
|
+
case "enrichment":
|
|
41
|
+
out.enrichment = rawDoc.enrichment ?? [];
|
|
42
|
+
break;
|
|
43
|
+
case "insights":
|
|
44
|
+
out.insights = rawDoc.insights ?? [];
|
|
45
|
+
break;
|
|
46
|
+
case "system":
|
|
47
|
+
out.system = rawDoc.system ?? {};
|
|
48
|
+
break;
|
|
49
|
+
case "associated":
|
|
50
|
+
out.associated = associated;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function getSnapshotAssociated(objectType, recordId) {
|
|
55
|
+
const found = getSnapshotRaw(objectType, recordId);
|
|
56
|
+
if (!found)
|
|
57
|
+
return undefined;
|
|
58
|
+
const { associated, properties } = buildAssociatedBucket(found.rawDoc);
|
|
59
|
+
return { associated, properties };
|
|
60
|
+
}
|
|
61
|
+
export function getSnapshotAssociatedProperty(objectType, recordId, propertyName) {
|
|
62
|
+
const found = getSnapshotRaw(objectType, recordId);
|
|
63
|
+
if (!found)
|
|
64
|
+
return undefined;
|
|
65
|
+
const { associated } = buildAssociatedBucket(found.rawDoc);
|
|
66
|
+
if (propertyName in associated) {
|
|
67
|
+
return associated[propertyName];
|
|
68
|
+
}
|
|
69
|
+
// try alias resolution
|
|
70
|
+
const { fallbacks } = aliasClientToStorage(propertyName);
|
|
71
|
+
for (const fb of fallbacks) {
|
|
72
|
+
if (fb in found.rawDoc)
|
|
73
|
+
return found.rawDoc[fb];
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
export function getAssociatedProperties(objectType) {
|
|
78
|
+
const records = store.getCollection(objectType, "snapshots");
|
|
79
|
+
const agg = new Map();
|
|
80
|
+
for (const doc of records) {
|
|
81
|
+
for (const k of Object.keys(doc)) {
|
|
82
|
+
if (k.startsWith("associated")) {
|
|
83
|
+
const existing = agg.get(k) || { source: k, count: 0 };
|
|
84
|
+
const v = doc[k];
|
|
85
|
+
existing.count += Array.isArray(v) ? v.length : v != null ? 1 : 0;
|
|
86
|
+
agg.set(k, existing);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...agg.entries()].map(([source, v]) => ({
|
|
91
|
+
propertyName: source,
|
|
92
|
+
source,
|
|
93
|
+
count: v.count,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Deterministic fingerprint for association plan/apply/verify.
|
|
98
|
+
*/
|
|
99
|
+
export function computeAssociationFingerprint(objectType) {
|
|
100
|
+
const records = store.getCollection(objectType, "snapshots");
|
|
101
|
+
const serialized = JSON.stringify(records.map((r) => ({ id: r.id ?? r.recordId, keys: Object.keys(r) })));
|
|
102
|
+
return `fp:${objectType}:${hashString(serialized)}`;
|
|
103
|
+
}
|
|
104
|
+
export function hashString(s) {
|
|
105
|
+
let h = 5381;
|
|
106
|
+
for (let i = 0; i < s.length; i++) {
|
|
107
|
+
h = (h * 33) ^ s.charCodeAt(i);
|
|
108
|
+
}
|
|
109
|
+
return (h >>> 0).toString(16);
|
|
110
|
+
}
|
|
@@ -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[];
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { store } from "../storage/InMemoryStore.js";
|
|
2
|
+
export function loadWriteDescriptor(writeDescriptorId) {
|
|
3
|
+
const desc = store.getMeta(`write-descriptors/${writeDescriptorId}`);
|
|
4
|
+
if (!desc) {
|
|
5
|
+
throw Object.assign(new Error(`Write descriptor not found: ${writeDescriptorId}`), {
|
|
6
|
+
statusCode: 404,
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
return desc;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Validate input against the write descriptor schema (lightweight AJV-free check).
|
|
13
|
+
*
|
|
14
|
+
* `operation` controls how `required` is enforced:
|
|
15
|
+
* - `add` / `replace`: every field in `schema.required` must be present.
|
|
16
|
+
* - `upsert` / `patch` / `delete`: only the collection's identity field
|
|
17
|
+
* (e.g. `recordId`, `memoryId`) is required; other `required` entries are
|
|
18
|
+
* ignored because partial updates are allowed.
|
|
19
|
+
*
|
|
20
|
+
* Type checks on whatever fields ARE present always run.
|
|
21
|
+
*/
|
|
22
|
+
export function validateInputSchema(writeSpec, input, operation = "add") {
|
|
23
|
+
const errors = [];
|
|
24
|
+
const schema = (writeSpec.schema || {});
|
|
25
|
+
const payload = Array.isArray(input) ? input : input ? [input] : [];
|
|
26
|
+
const partial = operation === "upsert" || operation === "patch" || operation === "delete";
|
|
27
|
+
if (schema.required && Array.isArray(schema.required)) {
|
|
28
|
+
// Determine the identity field for this descriptor based on targetCollection.
|
|
29
|
+
const targetCollection = writeSpec.targetCollection || "";
|
|
30
|
+
const [, ctRaw] = targetCollection.split("/");
|
|
31
|
+
const idField = ctRaw === "memory" ? "memoryId" : "recordId";
|
|
32
|
+
for (const req of schema.required) {
|
|
33
|
+
for (const item of payload) {
|
|
34
|
+
if (item?.[req] === undefined) {
|
|
35
|
+
// In partial modes, only the identity field is mandatory.
|
|
36
|
+
if (partial && req !== idField)
|
|
37
|
+
continue;
|
|
38
|
+
errors.push(`Missing required field: ${req}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (schema.properties) {
|
|
44
|
+
for (const item of payload) {
|
|
45
|
+
for (const [field, def] of Object.entries(schema.properties)) {
|
|
46
|
+
if (item[field] === undefined || item[field] === null)
|
|
47
|
+
continue;
|
|
48
|
+
if (def.type) {
|
|
49
|
+
const actualType = Array.isArray(item[field]) ? "array" : typeof item[field];
|
|
50
|
+
const expected = def.type;
|
|
51
|
+
if (expected === "array" && !Array.isArray(item[field])) {
|
|
52
|
+
errors.push(`Field ${field} must be array`);
|
|
53
|
+
}
|
|
54
|
+
else if (expected !== "array" && expected !== actualType) {
|
|
55
|
+
errors.push(`Field ${field} must be ${expected}, got ${actualType}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { ok: errors.length === 0, errors };
|
|
62
|
+
}
|
|
63
|
+
export function parseTargetCollection(target) {
|
|
64
|
+
const [objectType, ctRaw] = target.split("/");
|
|
65
|
+
// The `memory` target routes to the standalone memory tier.
|
|
66
|
+
if (objectType === "memory" || ctRaw === "memory") {
|
|
67
|
+
return { objectType: "memory", ct: "memory" };
|
|
68
|
+
}
|
|
69
|
+
const ct = (ctRaw || "snapshots");
|
|
70
|
+
return { objectType, ct };
|
|
71
|
+
}
|
|
72
|
+
export function executeOperation(params) {
|
|
73
|
+
const { objectType, ct } = parseTargetCollection(params.target);
|
|
74
|
+
// Work on a copy so dry runs cannot leak into the live in-memory store and
|
|
75
|
+
// later get persisted by an unrelated write.
|
|
76
|
+
const collection = [...store.getCollection(objectType, ct)];
|
|
77
|
+
const payload = Array.isArray(params.data) ? params.data : [params.data];
|
|
78
|
+
const persist = params.persist !== false;
|
|
79
|
+
// Resolve the identity field for this collection tier.
|
|
80
|
+
// Memory tier uses memoryId; everything else defaults to recordId.
|
|
81
|
+
const idField = ct === "memory" ? "memoryId" : "recordId";
|
|
82
|
+
switch (params.op) {
|
|
83
|
+
case "add": {
|
|
84
|
+
const nextId = getNextId(collection, idField);
|
|
85
|
+
for (const item of payload) {
|
|
86
|
+
collection.push({ ...item, [idField]: item[idField] ?? nextId() });
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "upsert": {
|
|
91
|
+
for (const item of payload) {
|
|
92
|
+
const id = item[idField] ?? item.recordId ?? item.id;
|
|
93
|
+
const idx = collection.findIndex((d) => (d[idField] ?? d.recordId ?? d.id) === id);
|
|
94
|
+
if (idx >= 0) {
|
|
95
|
+
collection[idx] = { ...collection[idx], ...item };
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
collection.push(item);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "patch": {
|
|
104
|
+
for (const item of payload) {
|
|
105
|
+
const id = item[idField] ?? item.recordId ?? item.id;
|
|
106
|
+
const idx = collection.findIndex((d) => (d[idField] ?? d.recordId ?? d.id) === id);
|
|
107
|
+
if (idx >= 0) {
|
|
108
|
+
collection[idx] = { ...collection[idx], ...item };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case "replace": {
|
|
114
|
+
if (persist)
|
|
115
|
+
store.setCollection(objectType, ct, payload);
|
|
116
|
+
return payload;
|
|
117
|
+
}
|
|
118
|
+
case "delete": {
|
|
119
|
+
const removed = [];
|
|
120
|
+
for (const item of payload) {
|
|
121
|
+
const id = item[idField] ?? item.recordId ?? item.id;
|
|
122
|
+
if (id === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
let i = collection.length;
|
|
125
|
+
while (i--) {
|
|
126
|
+
const candidate = collection[i];
|
|
127
|
+
if ((candidate[idField] ?? candidate.recordId ?? candidate.id) === id) {
|
|
128
|
+
removed.push(collection.splice(i, 1)[0]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (persist)
|
|
133
|
+
store.setCollection(objectType, ct, collection);
|
|
134
|
+
return removed;
|
|
135
|
+
}
|
|
136
|
+
default:
|
|
137
|
+
throw Object.assign(new Error(`Unsupported write operation: ${params.op}`), {
|
|
138
|
+
statusCode: 400,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (persist)
|
|
142
|
+
store.setCollection(objectType, ct, collection);
|
|
143
|
+
return payload;
|
|
144
|
+
}
|
|
145
|
+
function getNextId(collection, idField = "recordId") {
|
|
146
|
+
let max = 0;
|
|
147
|
+
for (const d of collection) {
|
|
148
|
+
const id = Number(d[idField] ?? d.recordId ?? d.id);
|
|
149
|
+
if (!isNaN(id) && id > max)
|
|
150
|
+
max = id;
|
|
151
|
+
}
|
|
152
|
+
let counter = max + 1;
|
|
153
|
+
return () => String(counter++);
|
|
154
|
+
}
|
|
155
|
+
export function listWriteDescriptors() {
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const key of store.getMetaKeys().filter((item) => item.startsWith("write-descriptors/"))) {
|
|
158
|
+
const desc = store.getMeta(key);
|
|
159
|
+
if (desc)
|
|
160
|
+
out.push(desc);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|