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
24 changes: 14 additions & 10 deletions .kiro/steering/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,20 @@ complete over time.
The project uses branded types from `@/utils` for type safety. These prevent
accidental mixing of similar types at compile time.

| Type | Creator Function | Location |
| ------------------ | ---------------------------- | --------------------------------------- |
| `VertexId` | `createVertexId()` | `@/core/entities/vertex` |
| `VertexType` | `createVertexType()` | `@/core/entities/vertex` |
| `EdgeId` | `createEdgeId()` | `@/core/entities/edge` |
| `EdgeType` | `createEdgeType()` | `@/core/entities/edge` |
| `EdgeConnectionId` | `createEdgeConnectionId()` | `@/core/StateProvider/edgeConnectionId` |
| `ConfigurationId` | `createNewConfigurationId()` | `@/core/ConfigurationProvider/types` |
| `RenderedVertexId` | `toRenderedVertexId()` | `@/core/StateProvider/renderedEntities` |
| `RenderedEdgeId` | `toRenderedEdgeId()` | `@/core/StateProvider/renderedEntities` |
| Type | Creator Function | Location |
| ------------------------ | ---------------------------- | --------------------------------------- |
| `VertexId` | `createVertexId()` | `@/core/entities/vertex` |
| `VertexType` | `createVertexType()` | `@/core/entities/vertex` |
| `EdgeId` | `createEdgeId()` | `@/core/entities/edge` |
| `EdgeType` | `createEdgeType()` | `@/core/entities/edge` |
| `EdgeConnectionId` | `createEdgeConnectionId()` | `@/core/StateProvider/edgeConnectionId` |
| `ConfigurationId` | `createNewConfigurationId()` | `@/core/ConfigurationProvider/types` |
| `RenderedVertexId` | `toRenderedVertexId()` | `@/core/StateProvider/renderedEntities` |
| `RenderedEdgeId` | `toRenderedEdgeId()` | `@/core/StateProvider/renderedEntities` |
| `IriNamespace` | `as IriNamespace` | `@/utils/rdf` |
| `IriLocalValue` | `as IriLocalValue` | `@/utils/rdf` |
| `RdfPrefix` | `as RdfPrefix` | `@/utils/rdf` |
| `NormalizedIriNamespace` | `as NormalizedIriNamespace` | `@/utils/rdf` |

Always use the appropriate branded type instead of `string` when working with
these identifiers.
Expand Down
83 changes: 83 additions & 0 deletions .kiro/steering/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,86 @@ test("should handle errors gracefully", async () => {
await expect(functionUnderTest(mockFetch)).rejects.toThrow("Test error");
});
```

## Persistent Storage Backward Compatibility

Graph Explorer persists state to IndexedDB via localforage (managed through
Jotai atoms). When a type used in persistent storage changes shape — for
example, a property is added, removed, or renamed — previously stored data will
still be loaded with the old shape. This can silently break logic that assumes
the new shape.

### Requirements

Any type or object that is persisted through Jotai and localforage **must** have
tests that exercise the old storage shape alongside the new one. These tests
verify that:

1. Data in the old shape is accepted without errors
2. Logic that consumes the data produces correct results with both shapes
3. Old and new shapes can coexist (e.g., a mix of old and new entries in an
array)

### Test Structure

Group backward-compatibility tests in a dedicated `describe` block with a clear
comment block explaining:

- What the old shape looked like
- Why the tests exist
- A warning not to delete or weaken them without confirming migration

```typescript
/**
* BACKWARD COMPATIBILITY — PERSISTED DATA
*
* <TypeName> is persisted to IndexedDB via localforage. Older versions stored
* <description of old shape>. That property/shape has been changed to
* <description of new shape>, but previously persisted data may still contain
* the old form. These tests verify that <module> continues to work correctly
* when given data in the old shape.
*
* DO NOT delete or weaken these tests without confirming that all persisted
* data has been migrated or that the old shape is no longer in the wild.
*/
describe("backward compatibility: <brief description>", () => {
it("should handle data in the old shape", () => {
// Use `as TypeName` to bypass compile-time checks and simulate
// the old shape that TypeScript no longer allows.
const legacyData = {
...currentFields,
removedField: "old value",
} as TypeName;

const result = functionUnderTest(legacyData);
expect(result).toEqual(expectedOutput);
});

it("should handle a mix of old and new shapes", () => {
const legacy = { ...oldShape } as TypeName;
const current = { ...newShape };
const result = functionUnderTest([legacy, current]);
expect(result).toEqual(expectedOutput);
});
});
```

### Key Persisted Types

These types are stored in IndexedDB and require backward-compatibility tests
when modified:

- `SchemaStorageModel` — vertex/edge configs, prefixes, edge connections
- `PrefixTypeConfig` — RDF namespace prefix definitions
- `VertexTypeConfig` / `EdgeTypeConfig` — schema type configurations
- `RawConfiguration` — connection and schema configuration
- User preferences (`VertexPreferencesStorageModel`,
`EdgePreferencesStorageModel`)

### When to Add These Tests

- Removing a property from a persisted type
- Renaming a property on a persisted type
- Changing the type of a property (e.g., `string[]` → `Set<string>`)
- Adding a required property (old data will not have it)
- Changing the semantics of an existing property
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
VertexPreferencesStorageModel,
} from "@/core/StateProvider/userPreferences";
import type { Branded } from "@/utils";
import type { IriNamespace, RdfPrefix } from "@/utils/rdf";

import type { EdgeType, VertexType } from "../entities";
import type { SchemaStorageModel } from "../StateProvider";
Expand Down Expand Up @@ -85,11 +86,11 @@ export type EdgeTypeConfig = {
} & EdgePreferencesStorageModel;

export type PrefixTypeConfig = {
prefix: string;
prefix: RdfPrefix;
/**
* Full URI for the prefix
*/
uri: string;
uri: IriNamespace;
/**
* Internal purpose only.
* Mark as true after inferring from the schema.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { QueryEngine } from "@shared/types";

import { createRandomDate, createRandomName } from "@shared/utils/testing";

import type { IriNamespace, RdfPrefix } from "@/utils/rdf";

import { getDisplayValueForScalar } from "@/connector/entities";
import {
activeConfigurationAtom,
Expand Down Expand Up @@ -160,8 +162,8 @@ describe("useDisplayEdgeFromEdge", () => {
const schema = createRandomSchema();
schema.prefixes = [
{
prefix: "example-class",
uri: "http://www.example.com/class#",
prefix: "example-class" as RdfPrefix,
uri: "http://www.example.com/class#" as IriNamespace,
},
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { QueryEngine } from "@shared/types";

import { createRandomDate, createRandomName } from "@shared/utils/testing";

import type { IriNamespace, RdfPrefix } from "@/utils/rdf";

import { getDisplayValueForScalar } from "@/connector/entities";
import {
activeConfigurationAtom,
Expand Down Expand Up @@ -112,8 +114,8 @@ describe("useDisplayVertexFromVertex", () => {
const schema = createRandomSchema();
schema.prefixes = [
{
prefix: "example-class",
uri: "http://www.example.com/class#",
prefix: "example-class" as RdfPrefix,
uri: "http://www.example.com/class#" as IriNamespace,
},
];

Expand Down Expand Up @@ -186,12 +188,12 @@ describe("useDisplayVertexFromVertex", () => {
const schema = createRandomSchema();
schema.prefixes = [
{
prefix: "example",
uri: "http://www.example.com/resources#",
prefix: "example" as RdfPrefix,
uri: "http://www.example.com/resources#" as IriNamespace,
},
{
prefix: "example-class",
uri: "http://www.example.com/class#",
prefix: "example-class" as RdfPrefix,
uri: "http://www.example.com/class#" as IriNamespace,
},
];

Expand Down
10 changes: 6 additions & 4 deletions packages/graph-explorer/src/core/StateProvider/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createArray, createRandomName } from "@shared/utils/testing";
import { useAtomValue } from "jotai";

import type { IriNamespace, RdfPrefix } from "@/utils/rdf";

import {
activeConfigurationAtom,
configurationAtom,
Expand Down Expand Up @@ -273,14 +275,14 @@ describe("schema", () => {
expect(result.prefixes).toBeDefined();
expect(result.prefixes).toEqual([
{
prefix: "ver",
uri: "http://abcdefg.com/vertex#",
prefix: "ver" as RdfPrefix,
uri: "http://abcdefg.com/vertex#" as IriNamespace,
__inferred: true,
__matches: new Set(schema.vertices.map(v => v.type)),
},
{
prefix: "edg",
uri: "http://abcdefg.com/edge#",
prefix: "edg" as RdfPrefix,
uri: "http://abcdefg.com/edge#" as IriNamespace,
__inferred: true,
__matches: new Set(schema.edges.map(e => e.type)),
},
Expand Down
6 changes: 4 additions & 2 deletions packages/graph-explorer/src/hooks/useTextTransform.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { vi } from "vitest";

import type { IriNamespace, RdfPrefix } from "@/utils/rdf";

import {
activeConfigurationAtom,
type AppStore,
Expand All @@ -21,8 +23,8 @@ function initializeConfigWithPrefix(store: AppStore) {
config.connection!.queryEngine = "sparql";
schema.prefixes = [
{
prefix: "rdf",
uri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
prefix: "rdf" as RdfPrefix,
uri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#" as IriNamespace,
},
];
store.set(configurationAtom, new Map([[config.id, config]]));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useAtomCallback } from "jotai/utils";
import { useCallback, useState } from "react";
import { Virtuoso } from "react-virtuoso";

import type { IriNamespace, RdfPrefix } from "@/utils/rdf";

import {
AddIcon,
Button,
Expand Down Expand Up @@ -228,7 +230,10 @@ function EditPrefixModal({
...(activeSchema || {}),
vertices: activeSchema?.vertices || [],
edges: activeSchema?.edges || [],
prefixes: [...(activeSchema?.prefixes || []), { prefix, uri }],
prefixes: [
...(activeSchema?.prefixes || []),
{ prefix: prefix as RdfPrefix, uri: uri as IriNamespace },
],
});

return updatedSchemas;
Expand Down
31 changes: 20 additions & 11 deletions packages/graph-explorer/src/utils/rdf/generatePrefixes.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import type { PrefixTypeConfig } from "@/core";
import type { IriNamespace, RdfPrefix } from "@/utils/rdf";

import generatePrefixes, {
generateHashPrefix,
generatePrefix,
} from "./generatePrefixes";

describe("generatePrefixes", () => {
it("should return null when nothing is updated", () => {
const existing = [
const existing: PrefixTypeConfig[] = [
{
prefix: "owl",
uri: "https://www.w3.org/2002/07/owl#",
prefix: "owl" as RdfPrefix,
uri: "https://www.w3.org/2002/07/owl#" as IriNamespace,
__matches: new Set(["https://www.w3.org/2002/07/owl#ObjectProperty"]),
},
{
prefix: "rdf",
uri: "https://www.w3.org/2000/01/rdf-schema#",
prefix: "rdf" as RdfPrefix,
uri: "https://www.w3.org/2000/01/rdf-schema#" as IriNamespace,
__matches: new Set([
"https://www.w3.org/2000/01/rdf-schema#subClassOf",
]),
Expand Down Expand Up @@ -111,12 +114,18 @@ describe("generatePrefixes", () => {
"http://www.example.com/location/resource#Manchester",
]),
[
{ prefix: "owl", uri: "https://www.w3.org/2002/07/owl#" },
{ prefix: "dbr", uri: "https://dbpedia.org/resource/" },
{
prefix: "owl" as RdfPrefix,
uri: "https://www.w3.org/2002/07/owl#" as IriNamespace,
},
{
prefix: "dbr" as RdfPrefix,
uri: "https://dbpedia.org/resource/" as IriNamespace,
},
{
__inferred: true,
prefix: "loc-r",
uri: "http://www.example.com/location/resource#",
prefix: "loc-r" as RdfPrefix,
uri: "http://www.example.com/location/resource#" as IriNamespace,
__matches: new Set([
"http://www.example.com/location/resource#London",
"http://www.example.com/location/resource#Manchester",
Expand Down Expand Up @@ -169,8 +178,8 @@ describe("generatePrefixes", () => {
[
{
__inferred: true,
prefix: "ent",
uri: "http://secretspyorg/entity/",
prefix: "ent" as RdfPrefix,
uri: "http://secretspyorg/entity/" as IriNamespace,
__matches: new Set(["http://SecretSpyOrg/entity/quantity"]),
},
],
Expand Down
27 changes: 16 additions & 11 deletions packages/graph-explorer/src/utils/rdf/generatePrefixes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { PrefixTypeConfig } from "@/core";

import type { IriNamespace, RdfPrefix } from "./types";

import commonPrefixes from "./common-prefixes.json";

// Create a map of the common prefixes
const commonPrefixesMap = toPrefixTypeConfigMap(
Object.entries(commonPrefixes).map(([prefix, uri]) => ({ prefix, uri })),
Object.entries(commonPrefixes).map(([prefix, uri]) => ({
prefix: prefix as RdfPrefix,
uri: uri as IriNamespace,
})),
);

/** Helper function to create a map of prefix configs from an array of configs. */
Expand Down Expand Up @@ -79,8 +84,8 @@ export function generateHashPrefix(

return {
__inferred: true,
uri: url.href.replace(url.hash, "#"),
prefix,
uri: url.href.replace(url.hash, "#") as IriNamespace,
prefix: prefix as RdfPrefix,
};
}

Expand All @@ -95,8 +100,8 @@ export function generatePrefix(url: URL): Omit<PrefixTypeConfig, "__count"> {
const prefix = prefixFromHost(url.host);
return {
__inferred: true,
uri: url.origin + "/",
prefix,
uri: (url.origin + "/") as IriNamespace,
prefix: prefix as RdfPrefix,
};
}

Expand All @@ -113,8 +118,8 @@ export function generatePrefix(url: URL): Omit<PrefixTypeConfig, "__count"> {

return {
__inferred: true,
uri: uriChunks.join("/") + "/",
prefix,
uri: (uriChunks.join("/") + "/") as IriNamespace,
prefix: prefix as RdfPrefix,
};
}

Expand All @@ -126,17 +131,17 @@ export function generatePrefix(url: URL): Omit<PrefixTypeConfig, "__count"> {
const prefix = prefixFromHost(url.host);
return {
__inferred: true,
uri: url.origin + "/",
prefix,
uri: (url.origin + "/") as IriNamespace,
prefix: prefix as RdfPrefix,
};
}

const uriChunks = url.href.split("/");
uriChunks.length = uriChunks.length - 1;
return {
__inferred: true,
uri: uriChunks.join("/") + "/",
prefix: filteredPaths[0].substring(0, 3),
uri: (uriChunks.join("/") + "/") as IriNamespace,
prefix: filteredPaths[0].substring(0, 3) as RdfPrefix,
};
}

Expand Down
Loading