diff --git a/CONTEXT.md b/CONTEXT.md index 2db04e7cf..7d7bbf046 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -107,7 +107,7 @@ _Avoid_: Configuration file (the wire format is not the in-memory or persisted s ## Flagged ambiguities - "Configuration" was used to mean both **Connection** and the bundled object (connection + schema + user preferences) — resolved: **Connection** is canonical, "Configuration" is legacy. -- A Connection's data now has three distinct shapes that look similar but must not be conflated: the **Exported Connection File** (on-disk wire format), the in-memory configuration, and the IndexedDB storage shape. They are being separated into explicit types so each can evolve independently. +- A Connection's data now has three distinct shapes that look similar but must not be conflated, each with its own explicit type: the **Exported Connection File** (`ExportedConnectionFile`, on-disk wire format), the in-memory merged configuration (`MergedConfiguration` — connection plus a live `Schema` with `lastUpdate` as a `Date`), and the persisted storage shape (`RawConfiguration` — connection only; the schema is stored separately in `schemaAtom`, never embedded). `mergeConfiguration` assembles a `MergedConfiguration` from a stored `RawConfiguration` and its active schema. Remaining work: `configurationAtom` still keys by `RawConfiguration`, to be migrated to a connection record. - "Node" means **Vertex** in code but is the preferred UI term for property graphs — resolved: use **Vertex** in code, "node" in UI copy. - "Attribute" vs "Property" — resolved: **Property** is canonical, "attribute" is legacy code term being phased out. - "Active connection" meant a single shared per-origin value, but the app consumed it as if it were per-tab — resolved: **Active Connection** is per-tab (sessionStorage), **Last Active Connection** is the shared persisted breadcrumb. The legacy `activeConfigurationAtom` / `active-configuration` key is reused as the breadcrumb. diff --git a/packages/graph-explorer/src/core/ConfigurationProvider/types.test.ts b/packages/graph-explorer/src/core/ConfigurationProvider/types.test.ts index ea97f3c1f..d0fb8e79e 100644 --- a/packages/graph-explorer/src/core/ConfigurationProvider/types.test.ts +++ b/packages/graph-explorer/src/core/ConfigurationProvider/types.test.ts @@ -60,15 +60,33 @@ describe("RawConfiguration", () => { expect(deserialized).toStrictEqual(configs); }); +}); - test("serialization round-trip preserves configuration with schema", () => { - const config = createRandomRawConfiguration(); - config.schema = createRandomSchema(); - - const serialized = serializeData(config); - const deserialized = deserializeData(serialized) as RawConfiguration; - - expect(deserialized).toStrictEqual(config); - expect(deserialized.schema?.lastUpdate).toBeInstanceOf(Date); +/** + * BACKWARD COMPATIBILITY — PERSISTED DATA + * + * `RawConfiguration` is persisted to IndexedDB via localforage. Older versions + * embedded the schema directly on the stored config (`RawConfiguration.schema`). + * That field has been removed — the schema now lives only in `schemaAtom` — but + * previously persisted configs may still carry it. This verifies that + * serialization round-trips such a legacy config losslessly, including reviving + * the embedded schema's `lastUpdate` back into a `Date`. + * + * DO NOT delete or weaken this test without confirming that legacy persisted + * configs carrying an embedded schema are no longer a concern. + */ +describe("backward compatibility: legacy embedded schema on stored configuration", () => { + test("serialization round-trip preserves a configuration carrying a legacy schema", () => { + // Use `as` to simulate the legacy shape that TypeScript no longer allows. + const legacyConfig = { + ...createRandomRawConfiguration(), + schema: createRandomSchema(), + } as RawConfiguration & { schema: SchemaStorageModel }; + + const serialized = serializeData(legacyConfig); + const deserialized = deserializeData(serialized) as typeof legacyConfig; + + expect(deserialized).toStrictEqual(legacyConfig); + expect(deserialized.schema.lastUpdate).toBeInstanceOf(Date); }); }); diff --git a/packages/graph-explorer/src/core/ConfigurationProvider/types.ts b/packages/graph-explorer/src/core/ConfigurationProvider/types.ts index 1bea2321d..ab6695260 100644 --- a/packages/graph-explorer/src/core/ConfigurationProvider/types.ts +++ b/packages/graph-explorer/src/core/ConfigurationProvider/types.ts @@ -155,6 +155,11 @@ export function createEdgeConnection(options: { }; } +/** + * The persisted shape of a connection configuration, as stored in + * `configurationAtom` and IndexedDB. The schema is kept separately in + * `schemaAtom`, never embedded here. + */ export type RawConfiguration = { /** * Unique identifier for this config @@ -165,13 +170,25 @@ export type RawConfiguration = { * Connection configuration */ connection?: ConnectionConfig; +}; + +/** + * A configuration assembled in memory by merging the stored + * {@link RawConfiguration} with its active schema and user styling. Unlike the + * persisted {@link RawConfiguration}, this carries the schema inline for the UI + * to consume. + */ +export type MergedConfiguration = RawConfiguration & { /** * Database schema: types, names, labels, icons, ... + * + * Always present — `mergeConfiguration` builds it from the active schema and + * user styling, defaulting to empty type lists when there is no schema yet. */ - schema?: SchemaStorageModel; + schema: SchemaStorageModel; }; -export type ConfigurationContextProps = RawConfiguration & { +export type ConfigurationContextProps = MergedConfiguration & { totalVertices: number; vertexTypes: Array; totalEdges: number; diff --git a/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts b/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts index 807a8b097..046d2ce38 100644 --- a/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts +++ b/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts @@ -19,16 +19,16 @@ const assembledConfigSelector = atom(get => { } const vertexTypesMap = new Set( - configuration.schema?.vertices.map(v => v.type), + configuration.schema.vertices.map(v => v.type), ); - const edgeTypesMap = new Set(configuration.schema?.edges.map(e => e.type)); + const edgeTypesMap = new Set(configuration.schema.edges.map(e => e.type)); const result: ConfigurationContextProps = { ...configuration, - totalVertices: configuration.schema?.totalVertices ?? 0, + totalVertices: configuration.schema.totalVertices ?? 0, vertexTypes: vertexTypesMap.keys().toArray(), - totalEdges: configuration.schema?.totalEdges ?? 0, + totalEdges: configuration.schema.totalEdges ?? 0, edgeTypes: edgeTypesMap.keys().toArray(), }; return result; diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts index a42e7b6d5..bb0fadad1 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts @@ -14,6 +14,7 @@ import { } from "@/utils/testing"; import type { + MergedConfiguration, RawConfiguration, VertexTypeConfig, } from "../ConfigurationProvider"; @@ -73,7 +74,7 @@ describe("mergedConfiguration", () => { totalEdges: 0, totalVertices: 0, }, - } satisfies RawConfiguration); + } satisfies MergedConfiguration); }); it("should use schema when provided", () => { @@ -114,7 +115,7 @@ describe("mergedConfiguration", () => { graphDbUrl: config.connection?.graphDbUrl ?? "", }, schema: expectedSchema, - } satisfies RawConfiguration); + } satisfies MergedConfiguration); }); it("should use styling when provided", () => { @@ -289,6 +290,32 @@ describe("mergedConfiguration", () => { RESERVED_TYPES_PROPERTY, ); }); + + it("should ignore a schema embedded on the stored config and use the active schema", () => { + // A legacy stored config may carry an embedded schema (the field that was + // removed from RawConfiguration). The merge must source its schema solely + // from the active schema argument, never from the stored config. + const staleVertex = createRandomVertexTypeConfig(); + staleVertex.type = createVertexType("StaleType"); + const staleSchema = createRandomSchema(); + staleSchema.vertices = [staleVertex]; + + const config = { + ...createRandomRawConfiguration(), + schema: staleSchema, + } as RawConfiguration & { schema: SchemaStorageModel }; + + const activeVertex = createRandomVertexTypeConfig(); + activeVertex.type = createVertexType("ActiveType"); + const activeSchema = createRandomSchema(); + activeSchema.vertices = [activeVertex]; + + const result = mergeConfiguration(activeSchema, config, {}); + + expect(result.schema.vertices.map(v => v.type)).toEqual([ + createVertexType("ActiveType"), + ]); + }); }); /** Sorts the configs by type name */ diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.ts b/packages/graph-explorer/src/core/StateProvider/configuration.ts index 46bfdaf7e..a255613fb 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.ts @@ -10,6 +10,7 @@ import { configurationAtom, type EdgeType, type EdgeTypeConfig, + type MergedConfiguration, type RawConfiguration, userStylingAtom, type VertexType, @@ -61,7 +62,7 @@ export function mergeConfiguration( currentSchema: SchemaStorageModel | null | undefined, currentConfig: RawConfiguration, userStyling: UserStyling, -): RawConfiguration { +): MergedConfiguration { const prefsVertexMap = toMapByType(userStyling.vertices); const mergedVertices = (currentSchema?.vertices ?? []) .map(schemaVertex => @@ -153,22 +154,22 @@ const mergeEdge = ( export const allVertexTypeConfigsSelector = atom(get => { const configuration = get(mergedConfigurationSelector); - return new Map(configuration?.schema?.vertices.map(vt => [vt.type, vt])); + return new Map(configuration?.schema.vertices.map(vt => [vt.type, vt])); }); export const allEdgeTypeConfigsSelector = atom(get => { const configuration = get(mergedConfigurationSelector); - return new Map(configuration?.schema?.edges.map(et => [et.type, et])); + return new Map(configuration?.schema.edges.map(et => [et.type, et])); }); export const vertexTypesSelector = atom(get => { const configuration = get(mergedConfigurationSelector); - return configuration?.schema?.vertices?.map(vt => vt.type) || []; + return configuration?.schema.vertices.map(vt => vt.type) || []; }); export const edgeTypesSelector = atom(get => { const configuration = get(mergedConfigurationSelector); - return configuration?.schema?.edges?.map(vt => vt.type) || []; + return configuration?.schema.edges.map(vt => vt.type) || []; }); export const defaultVertexTypeConfig = { diff --git a/packages/graph-explorer/src/core/StateProvider/localDb.test.ts b/packages/graph-explorer/src/core/StateProvider/localDb.test.ts index a8634e4e7..e6b5bcb1d 100644 --- a/packages/graph-explorer/src/core/StateProvider/localDb.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/localDb.test.ts @@ -5,12 +5,16 @@ import { createRandomName, } from "@shared/utils/testing"; +import type { RawConfiguration } from "@/core"; + import { toJsonFileData } from "@/utils/fileData"; import { createRandomRawConfiguration, createRandomSchema, } from "@/utils/testing"; +import type { SchemaStorageModel } from "./schema"; + import { addRestoredPrefix, createBackupData, @@ -209,11 +213,14 @@ describe("backward compatibility: legacy schema field on stored configuration", const localDb = createFakeLocalDb(); // A stored config in the legacy shape: the schema is duplicated onto the - // RawConfiguration itself, which TypeScript still allows but no current - // writer produces. + // RawConfiguration itself. The current type no longer declares `schema`, so + // we cast to attach it — simulating a stale IndexedDB blob written by an + // older version. const config = createRandomRawConfiguration(); const schema = createRandomSchema(); - const legacyConfig = { ...config, schema } as typeof config; + const legacyConfig = { ...config, schema } as RawConfiguration & { + schema: SchemaStorageModel; + }; const configMap = new Map([[config.id, legacyConfig]]); const schemaMap = new Map([[config.id, schema]]); diff --git a/packages/graph-explorer/src/modules/AvailableConnections/useImportConnectionFile.test.tsx b/packages/graph-explorer/src/modules/AvailableConnections/useImportConnectionFile.test.tsx index 29acfc5c7..81277480e 100644 --- a/packages/graph-explorer/src/modules/AvailableConnections/useImportConnectionFile.test.tsx +++ b/packages/graph-explorer/src/modules/AvailableConnections/useImportConnectionFile.test.tsx @@ -533,9 +533,9 @@ describe("backward compatibility: legacy exported connection file with embedded ); // The schema must NOT be stored on the config entry — it belongs in - // schemaAtom. This is the invariant the dead `RawConfiguration.schema` - // merge leg relies on staying true. - expect(importedConfig.schema).toBeUndefined(); + // schemaAtom. `RawConfiguration` no longer declares a `schema` field, so we + // probe for a stray one to prove import never writes it back. + expect((importedConfig as { schema?: unknown }).schema).toBeUndefined(); // The full schema is split out into schemaAtom, styling and all. expect(importedSchema.vertices.map(v => v.type)).toStrictEqual([ diff --git a/packages/graph-explorer/src/utils/saveConfigurationToFile.test.ts b/packages/graph-explorer/src/utils/saveConfigurationToFile.test.ts index 4c6bcce1b..3db288bb8 100644 --- a/packages/graph-explorer/src/utils/saveConfigurationToFile.test.ts +++ b/packages/graph-explorer/src/utils/saveConfigurationToFile.test.ts @@ -17,15 +17,27 @@ vi.mock("file-saver", () => ({ const saveAsMock = vi.mocked(fileSaver.saveAs); +/** + * Builds a merged configuration for export tests. Defaults to an empty schema + * and zeroed runtime counts; pass overrides to exercise specific cases. + */ +function makeConfig( + overrides: Partial = {}, +): ConfigurationContextProps { + return { + ...createRandomRawConfiguration(), + schema: { vertices: [], edges: [] }, + totalVertices: 0, + vertexTypes: [], + totalEdges: 0, + edgeTypes: [], + ...overrides, + }; +} + describe("saveConfigurationToFile", () => { it("should save a minimal configuration to file", () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + const config = makeConfig(); saveConfigurationToFile(config); @@ -38,14 +50,7 @@ describe("saveConfigurationToFile", () => { }); it("should use id as displayLabel if displayLabel is not provided", () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), - displayLabel: undefined, - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + const config = makeConfig({ displayLabel: undefined }); saveConfigurationToFile(config); @@ -54,16 +59,11 @@ describe("saveConfigurationToFile", () => { }); it("should include connection with default queryEngine if not provided", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), + const config = makeConfig({ connection: { url: "https://example.com", }, - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + }); saveConfigurationToFile(config); @@ -76,17 +76,12 @@ describe("saveConfigurationToFile", () => { }); it("should preserve existing queryEngine", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), + const config = makeConfig({ connection: { url: "https://example.com", queryEngine: "sparql", }, - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + }); saveConfigurationToFile(config); @@ -98,8 +93,7 @@ describe("saveConfigurationToFile", () => { }); it("should export schema with vertices and edges", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), + const config = makeConfig({ schema: { vertices: [ { @@ -130,7 +124,7 @@ describe("saveConfigurationToFile", () => { vertexTypes: [createVertexType("Person"), createVertexType("Company")], totalEdges: 50, edgeTypes: [createEdgeType("worksAt")], - }; + }); saveConfigurationToFile(config); @@ -149,8 +143,7 @@ describe("saveConfigurationToFile", () => { // it (an explicit toISOString or a Date flushed by JSON.stringify), so the // writer's lastUpdate type can change without altering the file. const lastUpdate = new Date("2024-01-01T12:30:00Z"); - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), + const config = makeConfig({ schema: { vertices: [], edges: [], @@ -160,11 +153,7 @@ describe("saveConfigurationToFile", () => { lastUpdate, lastSyncFail: false, }, - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + }); saveConfigurationToFile(config); @@ -176,8 +165,7 @@ describe("saveConfigurationToFile", () => { }); it("should export prefixes without internal properties", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), + const config = makeConfig({ schema: { vertices: [], edges: [], @@ -196,11 +184,7 @@ describe("saveConfigurationToFile", () => { lastUpdate: new Date(), lastSyncFail: false, }, - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + }); saveConfigurationToFile(config); @@ -221,14 +205,7 @@ describe("saveConfigurationToFile", () => { }); it("should handle empty schema", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), - schema: undefined, - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + const config = makeConfig(); saveConfigurationToFile(config); @@ -243,14 +220,7 @@ describe("saveConfigurationToFile", () => { }); it("should handle missing connection", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), - connection: undefined, - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + const config = makeConfig({ connection: undefined }); saveConfigurationToFile(config); @@ -271,13 +241,12 @@ describe("saveConfigurationToFile", () => { }); it("should only export necessary fields", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), + const config = makeConfig({ totalVertices: 100, vertexTypes: [createVertexType("Person")], totalEdges: 50, edgeTypes: [createEdgeType("knows")], - }; + }); saveConfigurationToFile(config); @@ -299,8 +268,7 @@ describe("saveConfigurationToFile", () => { }); it("should produce a file that passes import validation", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), + const config = makeConfig({ connection: { url: "https://neptune.example.com:8182", queryEngine: "gremlin", @@ -330,7 +298,7 @@ describe("saveConfigurationToFile", () => { vertexTypes: [createVertexType("Person")], totalEdges: 1, edgeTypes: [createEdgeType("knows")], - }; + }); saveConfigurationToFile(config); @@ -355,8 +323,7 @@ describe("saveConfigurationToFile", () => { }); it("should export edgeConnections when present", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), + const config = makeConfig({ schema: { vertices: [], edges: [], @@ -379,11 +346,7 @@ describe("saveConfigurationToFile", () => { }, ], }, - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + }); saveConfigurationToFile(config); @@ -407,8 +370,7 @@ describe("saveConfigurationToFile", () => { }); it("should handle undefined edgeConnections", async () => { - const config: ConfigurationContextProps = { - ...createRandomRawConfiguration(), + const config = makeConfig({ schema: { vertices: [], edges: [], @@ -419,11 +381,7 @@ describe("saveConfigurationToFile", () => { lastSyncFail: false, edgeConnections: undefined, }, - totalVertices: 0, - vertexTypes: [], - totalEdges: 0, - edgeTypes: [], - }; + }); saveConfigurationToFile(config); diff --git a/packages/graph-explorer/src/utils/saveConfigurationToFile.ts b/packages/graph-explorer/src/utils/saveConfigurationToFile.ts index 88b6f569e..978265c5a 100644 --- a/packages/graph-explorer/src/utils/saveConfigurationToFile.ts +++ b/packages/graph-explorer/src/utils/saveConfigurationToFile.ts @@ -16,11 +16,11 @@ const saveConfigurationToFile = (config: ConfigurationContextProps) => { queryEngine: config.connection?.queryEngine || "gremlin", }, schema: { - vertices: config.schema?.vertices || [], - edges: config.schema?.edges || [], - prefixes: config.schema?.prefixes, - lastUpdate: config.schema?.lastUpdate, - edgeConnections: config.schema?.edgeConnections, + vertices: config.schema.vertices, + edges: config.schema.edges, + prefixes: config.schema.prefixes, + lastUpdate: config.schema.lastUpdate, + edgeConnections: config.schema.edgeConnections, }, }; diff --git a/packages/graph-explorer/src/utils/testing/DbState.ts b/packages/graph-explorer/src/utils/testing/DbState.ts index 3f61a9bc7..1ea058c55 100644 --- a/packages/graph-explorer/src/utils/testing/DbState.ts +++ b/packages/graph-explorer/src/utils/testing/DbState.ts @@ -63,9 +63,7 @@ export class DbState { constructor(explorer: Explorer = createMockExplorer()) { this.#activeSchema = createRandomSchema(); - const config = createRandomRawConfiguration(); - config.schema = this.#activeSchema; - this.activeConfig = config; + this.activeConfig = createRandomRawConfiguration(); this.activeStyling = createRandomUserStyling();