@exellix/graphs-studio-layers-core 0.1.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/package.json +35 -0
- package/src/index.js +77 -0
- package/src/layerDocuments.js +57 -0
- package/src/layerIdentity.js +52 -0
- package/src/server/graphLayersStore.js +67 -0
- package/src/server/index.js +7 -0
- package/src/transitions.js +88 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@exellix/graphs-studio-layers-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Graph authoring layer identity, Mongo document shapes, view transitions, and useGraphLayers hook for Graphs Studio.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20.19.0 || >=22.12.0"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public",
|
|
11
|
+
"registry": "https://registry.npmjs.org/"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+ssh://git@github.com/exellix/graphs-studio.git",
|
|
16
|
+
"directory": "packages/graphs-studio-layers-core"
|
|
17
|
+
},
|
|
18
|
+
"main": "./src/index.js",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.js",
|
|
21
|
+
"./server": "./src/server/index.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"prepublishOnly": "node --test tests/graphs-studio-layers-core.test.mjs"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"react": "^19.2.7"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { runLayerTransition, resolveInformationFlowForView } from './transitions.js';
|
|
3
|
+
import { computeGraphVersionHash } from './layerIdentity.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {object} opts
|
|
7
|
+
* @param {string} [opts.graphId]
|
|
8
|
+
* @param {string | null} [opts.persistableSnapshotJson]
|
|
9
|
+
* @param {Record<string, unknown> | null} [opts.conceptLayer]
|
|
10
|
+
* @param {Record<string, unknown> | null} [opts.dataFlowLayer]
|
|
11
|
+
* @param {unknown} [opts.liveInformationFlow]
|
|
12
|
+
* @param {Record<string, unknown> | null} [opts.graphSnapshot]
|
|
13
|
+
* @param {Record<string, unknown> | null} [opts.graphAnalysis]
|
|
14
|
+
* @param {object} [opts.handlers]
|
|
15
|
+
*/
|
|
16
|
+
export function useGraphLayers(opts) {
|
|
17
|
+
const [syncState, setSyncState] = useState(
|
|
18
|
+
opts.dataFlowLayer?.syncState ?? opts.conceptLayer?.syncState ?? 'clean',
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const graphVersionHash = useMemo(
|
|
22
|
+
() => computeGraphVersionHash(opts.persistableSnapshotJson),
|
|
23
|
+
[opts.persistableSnapshotJson],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const informationFlowView = useMemo(
|
|
27
|
+
() =>
|
|
28
|
+
resolveInformationFlowForView({
|
|
29
|
+
liveInformationFlow: opts.liveInformationFlow,
|
|
30
|
+
dataFlowLayer: opts.dataFlowLayer,
|
|
31
|
+
currentGraphHash: graphVersionHash,
|
|
32
|
+
}),
|
|
33
|
+
[opts.liveInformationFlow, opts.dataFlowLayer, graphVersionHash],
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const transition = useCallback(
|
|
37
|
+
(direction) => {
|
|
38
|
+
const result = runLayerTransition(direction, {
|
|
39
|
+
graphId: opts.graphId,
|
|
40
|
+
graphVersionHash,
|
|
41
|
+
conceptLayer: opts.conceptLayer,
|
|
42
|
+
dataFlowLayer: opts.dataFlowLayer,
|
|
43
|
+
graphSnapshot: opts.graphSnapshot,
|
|
44
|
+
graphAnalysis: opts.graphAnalysis,
|
|
45
|
+
...opts.handlers,
|
|
46
|
+
});
|
|
47
|
+
if (result.ok && result.dataFlowLayer?.syncState) {
|
|
48
|
+
setSyncState(result.dataFlowLayer.syncState);
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
},
|
|
52
|
+
[opts, graphVersionHash],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
graphVersionHash,
|
|
57
|
+
syncState,
|
|
58
|
+
setSyncState,
|
|
59
|
+
informationFlowView,
|
|
60
|
+
transition,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
computeGraphVersionHash,
|
|
66
|
+
buildLayerKey,
|
|
67
|
+
parseLayerKey,
|
|
68
|
+
nextSyncStateAfterEdit,
|
|
69
|
+
SYNC_STATES,
|
|
70
|
+
} from './layerIdentity.js';
|
|
71
|
+
export { runLayerTransition, resolveInformationFlowForView } from './transitions.js';
|
|
72
|
+
export {
|
|
73
|
+
buildConceptLayerDocument,
|
|
74
|
+
buildDataFlowLayerDocument,
|
|
75
|
+
CONCEPT_LAYER_FORMAT,
|
|
76
|
+
DATA_FLOW_LAYER_FORMAT,
|
|
77
|
+
} from './layerDocuments.js';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const COLLECTION_GRAPH_CONCEPT_LAYERS = 'graph-concept-layers';
|
|
2
|
+
export const COLLECTION_GRAPH_DATA_FLOW_LAYERS = 'graph-data-flow-layers';
|
|
3
|
+
|
|
4
|
+
export const CONCEPT_LAYER_FORMAT = 'graphs-studio.concept-layer/v1';
|
|
5
|
+
export const DATA_FLOW_LAYER_FORMAT = 'graphs-studio.data-flow-layer/v1';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} input
|
|
9
|
+
* @param {string} input.graphId
|
|
10
|
+
* @param {string} input.graphVersionHash
|
|
11
|
+
* @param {Record<string, unknown>} input.concept
|
|
12
|
+
* @param {Record<string, unknown>} [input.studioConcept]
|
|
13
|
+
* @param {string} [input.sourceGraphHash]
|
|
14
|
+
* @param {string} [input.syncState]
|
|
15
|
+
* @param {string} [input.studioId]
|
|
16
|
+
*/
|
|
17
|
+
export function buildConceptLayerDocument(input) {
|
|
18
|
+
const { graphId, graphVersionHash } = input;
|
|
19
|
+
return {
|
|
20
|
+
layerKey: `${graphId}::${graphVersionHash}`,
|
|
21
|
+
graphId,
|
|
22
|
+
graphVersionHash,
|
|
23
|
+
formatVersion: CONCEPT_LAYER_FORMAT,
|
|
24
|
+
concept: input.concept,
|
|
25
|
+
studioConcept: input.studioConcept,
|
|
26
|
+
sourceGraphHash: input.sourceGraphHash ?? graphVersionHash,
|
|
27
|
+
syncState: input.syncState ?? 'clean',
|
|
28
|
+
studioId: input.studioId ?? 'default',
|
|
29
|
+
updatedAt: new Date().toISOString(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} input
|
|
35
|
+
* @param {string} input.graphId
|
|
36
|
+
* @param {string} input.graphVersionHash
|
|
37
|
+
* @param {Record<string, unknown>} input.informationFlow
|
|
38
|
+
* @param {string} [input.focusState]
|
|
39
|
+
* @param {string} [input.sourceGraphHash]
|
|
40
|
+
* @param {string} [input.syncState]
|
|
41
|
+
* @param {string} [input.studioId]
|
|
42
|
+
*/
|
|
43
|
+
export function buildDataFlowLayerDocument(input) {
|
|
44
|
+
const { graphId, graphVersionHash } = input;
|
|
45
|
+
return {
|
|
46
|
+
layerKey: `${graphId}::${graphVersionHash}`,
|
|
47
|
+
graphId,
|
|
48
|
+
graphVersionHash,
|
|
49
|
+
formatVersion: DATA_FLOW_LAYER_FORMAT,
|
|
50
|
+
informationFlow: input.informationFlow,
|
|
51
|
+
focusState: input.focusState ?? 'graph',
|
|
52
|
+
sourceGraphHash: input.sourceGraphHash ?? graphVersionHash,
|
|
53
|
+
syncState: input.syncState ?? 'clean',
|
|
54
|
+
studioId: input.studioId ?? 'default',
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SHA-256 hash of a persistable graph snapshot string.
|
|
5
|
+
* @param {string | null | undefined} snapshotJson
|
|
6
|
+
*/
|
|
7
|
+
export function computeGraphVersionHash(snapshotJson) {
|
|
8
|
+
if (!snapshotJson || typeof snapshotJson !== 'string') return null;
|
|
9
|
+
return createHash('sha256').update(snapshotJson, 'utf8').digest('hex').slice(0, 16);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} graphId
|
|
14
|
+
* @param {string} graphVersionHash
|
|
15
|
+
*/
|
|
16
|
+
export function buildLayerKey(graphId, graphVersionHash) {
|
|
17
|
+
return `${graphId}::${graphVersionHash}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} layerKey
|
|
22
|
+
*/
|
|
23
|
+
export function parseLayerKey(layerKey) {
|
|
24
|
+
const idx = layerKey.lastIndexOf('::');
|
|
25
|
+
if (idx <= 0) return null;
|
|
26
|
+
return {
|
|
27
|
+
graphId: layerKey.slice(0, idx),
|
|
28
|
+
graphVersionHash: layerKey.slice(idx + 2),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const SYNC_STATES = /** @type {const} */ ([
|
|
33
|
+
'clean',
|
|
34
|
+
'concept-ahead',
|
|
35
|
+
'graph-ahead',
|
|
36
|
+
'layer-ahead',
|
|
37
|
+
'conflict',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** @typedef {(typeof SYNC_STATES)[number]} SyncState */
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {SyncState} current
|
|
44
|
+
* @param {'concept' | 'data-flow' | 'graph'} source
|
|
45
|
+
*/
|
|
46
|
+
export function nextSyncStateAfterEdit(current, source) {
|
|
47
|
+
if (current === 'conflict') return 'conflict';
|
|
48
|
+
if (source === 'concept') return 'concept-ahead';
|
|
49
|
+
if (source === 'data-flow') return 'layer-ahead';
|
|
50
|
+
if (source === 'graph') return 'graph-ahead';
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COLLECTION_GRAPH_CONCEPT_LAYERS,
|
|
3
|
+
COLLECTION_GRAPH_DATA_FLOW_LAYERS,
|
|
4
|
+
buildConceptLayerDocument,
|
|
5
|
+
buildDataFlowLayerDocument,
|
|
6
|
+
} from '../layerDocuments.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* In-memory layer store for tests and server wiring.
|
|
10
|
+
*/
|
|
11
|
+
export class GraphLayersMemoryStore {
|
|
12
|
+
constructor() {
|
|
13
|
+
/** @type {Map<string, Record<string, unknown>>} */
|
|
14
|
+
this.conceptLayers = new Map();
|
|
15
|
+
/** @type {Map<string, Record<string, unknown>>} */
|
|
16
|
+
this.dataFlowLayers = new Map();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {Record<string, unknown>} doc */
|
|
20
|
+
upsertConceptLayer(doc) {
|
|
21
|
+
this.conceptLayers.set(doc.layerKey, doc);
|
|
22
|
+
return doc;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} layerKey */
|
|
26
|
+
getConceptLayer(layerKey) {
|
|
27
|
+
return this.conceptLayers.get(layerKey) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string} graphId */
|
|
31
|
+
getLatestConceptLayerForGraph(graphId) {
|
|
32
|
+
let latest = null;
|
|
33
|
+
for (const doc of this.conceptLayers.values()) {
|
|
34
|
+
if (doc.graphId !== graphId) continue;
|
|
35
|
+
if (!latest || String(doc.updatedAt) > String(latest.updatedAt)) latest = doc;
|
|
36
|
+
}
|
|
37
|
+
return latest;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {Record<string, unknown>} doc */
|
|
41
|
+
upsertDataFlowLayer(doc) {
|
|
42
|
+
this.dataFlowLayers.set(doc.layerKey, doc);
|
|
43
|
+
return doc;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {string} layerKey */
|
|
47
|
+
getDataFlowLayer(layerKey) {
|
|
48
|
+
return this.dataFlowLayers.get(layerKey) ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string} graphId */
|
|
52
|
+
getLatestDataFlowLayerForGraph(graphId) {
|
|
53
|
+
let latest = null;
|
|
54
|
+
for (const doc of this.dataFlowLayers.values()) {
|
|
55
|
+
if (doc.graphId !== graphId) continue;
|
|
56
|
+
if (!latest || String(doc.updatedAt) > String(latest.updatedAt)) latest = doc;
|
|
57
|
+
}
|
|
58
|
+
return latest;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
COLLECTION_GRAPH_CONCEPT_LAYERS,
|
|
64
|
+
COLLECTION_GRAPH_DATA_FLOW_LAYERS,
|
|
65
|
+
buildConceptLayerDocument,
|
|
66
|
+
buildDataFlowLayerDocument,
|
|
67
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** @typedef {'concept-to-graph' | 'graph-to-concept' | 'graph-to-data-flow' | 'data-flow-to-graph'} TransitionDirection */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {TransitionDirection} direction
|
|
5
|
+
* @param {object} ctx
|
|
6
|
+
* @param {string} ctx.graphId
|
|
7
|
+
* @param {string} [ctx.graphVersionHash]
|
|
8
|
+
* @param {Record<string, unknown>} [ctx.conceptLayer]
|
|
9
|
+
* @param {Record<string, unknown>} [ctx.dataFlowLayer]
|
|
10
|
+
* @param {Record<string, unknown>} [ctx.graphSnapshot]
|
|
11
|
+
* @param {Record<string, unknown>} [ctx.graphAnalysis]
|
|
12
|
+
* @param {(input: unknown) => Record<string, unknown> | null} [ctx.buildInformationFlow]
|
|
13
|
+
* @param {(layer: unknown, graph: unknown) => Record<string, unknown>} [ctx.applyInformationFlowToGraph]
|
|
14
|
+
* @param {(graph: unknown) => Record<string, unknown>} [ctx.graphToConcept]
|
|
15
|
+
* @param {(concept: unknown) => Record<string, unknown>} [ctx.conceptToGraph]
|
|
16
|
+
*/
|
|
17
|
+
export function runLayerTransition(direction, ctx) {
|
|
18
|
+
switch (direction) {
|
|
19
|
+
case 'graph-to-concept': {
|
|
20
|
+
if (ctx.graphToConcept && ctx.graphSnapshot) {
|
|
21
|
+
return { ok: true, conceptLayer: ctx.graphToConcept(ctx.graphSnapshot) };
|
|
22
|
+
}
|
|
23
|
+
return { ok: false, error: 'graphToConcept handler missing' };
|
|
24
|
+
}
|
|
25
|
+
case 'concept-to-graph': {
|
|
26
|
+
if (ctx.conceptToGraph && ctx.conceptLayer) {
|
|
27
|
+
return { ok: true, graphPatch: ctx.conceptToGraph(ctx.conceptLayer) };
|
|
28
|
+
}
|
|
29
|
+
return { ok: false, error: 'conceptToGraph handler missing' };
|
|
30
|
+
}
|
|
31
|
+
case 'graph-to-data-flow': {
|
|
32
|
+
if (ctx.buildInformationFlow && ctx.graphSnapshot) {
|
|
33
|
+
const informationFlow = ctx.buildInformationFlow({
|
|
34
|
+
graph: ctx.graphSnapshot,
|
|
35
|
+
graphAnalysis: ctx.graphAnalysis,
|
|
36
|
+
});
|
|
37
|
+
if (!informationFlow) {
|
|
38
|
+
return { ok: false, error: 'Information flow could not be built from graph analysis' };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
dataFlowLayer: {
|
|
43
|
+
graphId: ctx.graphId,
|
|
44
|
+
graphVersionHash: ctx.graphVersionHash,
|
|
45
|
+
informationFlow,
|
|
46
|
+
syncState: 'clean',
|
|
47
|
+
sourceGraphHash: ctx.graphVersionHash,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { ok: false, error: 'buildInformationFlow handler missing' };
|
|
52
|
+
}
|
|
53
|
+
case 'data-flow-to-graph': {
|
|
54
|
+
if (ctx.applyInformationFlowToGraph && ctx.dataFlowLayer && ctx.graphSnapshot) {
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
graphPatch: ctx.applyInformationFlowToGraph(ctx.dataFlowLayer, ctx.graphSnapshot),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { ok: false, error: 'applyInformationFlowToGraph handler missing' };
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
return { ok: false, error: `Unknown direction: ${direction}` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve information flow for display: live build first, then persisted layer fallback.
|
|
69
|
+
* @param {object} opts
|
|
70
|
+
* @param {unknown} opts.liveInformationFlow
|
|
71
|
+
* @param {Record<string, unknown> | null | undefined} opts.dataFlowLayer
|
|
72
|
+
* @param {string | null | undefined} opts.currentGraphHash
|
|
73
|
+
*/
|
|
74
|
+
export function resolveInformationFlowForView(opts) {
|
|
75
|
+
if (opts.liveInformationFlow) {
|
|
76
|
+
return { informationFlow: opts.liveInformationFlow, stale: false };
|
|
77
|
+
}
|
|
78
|
+
const layer = opts.dataFlowLayer;
|
|
79
|
+
if (layer?.informationFlow) {
|
|
80
|
+
const stale = Boolean(
|
|
81
|
+
opts.currentGraphHash &&
|
|
82
|
+
layer.sourceGraphHash &&
|
|
83
|
+
layer.sourceGraphHash !== opts.currentGraphHash,
|
|
84
|
+
);
|
|
85
|
+
return { informationFlow: layer.informationFlow, stale, fromLayer: true };
|
|
86
|
+
}
|
|
87
|
+
return { informationFlow: null, stale: false };
|
|
88
|
+
}
|