Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
21 changes: 19 additions & 2 deletions packages/graph-explorer/src/core/ConfigurationProvider/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<VertexType>;
totalEdges: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@/utils/testing";

import type {
MergedConfiguration,
RawConfiguration,
VertexTypeConfig,
} from "../ConfigurationProvider";
Expand Down Expand Up @@ -73,7 +74,7 @@ describe("mergedConfiguration", () => {
totalEdges: 0,
totalVertices: 0,
},
} satisfies RawConfiguration);
} satisfies MergedConfiguration);
});

it("should use schema when provided", () => {
Expand Down Expand Up @@ -114,7 +115,7 @@ describe("mergedConfiguration", () => {
graphDbUrl: config.connection?.graphDbUrl ?? "",
},
schema: expectedSchema,
} satisfies RawConfiguration);
} satisfies MergedConfiguration);
});

it("should use styling when provided", () => {
Expand Down Expand Up @@ -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 */
Expand Down
11 changes: 6 additions & 5 deletions packages/graph-explorer/src/core/StateProvider/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
configurationAtom,
type EdgeType,
type EdgeTypeConfig,
type MergedConfiguration,
type RawConfiguration,
userStylingAtom,
type VertexType,
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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 = {
Expand Down
13 changes: 10 additions & 3 deletions packages/graph-explorer/src/core/StateProvider/localDb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading
Loading