@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 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,7 @@
1
+ export {
2
+ GraphLayersMemoryStore,
3
+ COLLECTION_GRAPH_CONCEPT_LAYERS,
4
+ COLLECTION_GRAPH_DATA_FLOW_LAYERS,
5
+ buildConceptLayerDocument,
6
+ buildDataFlowLayerDocument,
7
+ } from './graphLayersStore.js';
@@ -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
+ }