Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 0 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ COPY package.json bun.lock bunfig.toml tsconfig.json bun-env.d.ts build.ts ./
COPY --from=build /app/node_modules ./node_modules
COPY src ./src

RUN mkdir -p /data

EXPOSE 3000

CMD ["bun", "src/index.ts"]
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Link

Link is a local-first Bun + React graph tracker for flexible work-related entities and relationships. Graph data is stored as canonical, Git-friendly JSON files under `./.data/graph`; Git is the history, collaboration, backup, and conflict-resolution layer.
Link is a local-first Bun + React graph tracker for flexible work-related entities and relationships. Graph data is stored as canonical, Git-friendly JSON files under `./.data/graph` after the first save; Git is the history, collaboration, backup, and conflict-resolution layer.

## Features

- Dynamic node types and edge types.
- Directed and bidirectional edges.
- Metadata schemas for declared fields, while preserving unknown metadata fields.
- One JSON file per graph record with slug IDs mapped directly to file names.
- Automatic bootstrap of default node and edge types on first run.
- Explicit bootstrap of default node and edge types with `--seed`.
- Deterministic HTTP APIs, realtime WebSocket refresh, and local-only MCP tools.
- Validation CLI for catching malformed JSON, merge conflicts, and broken references.

Expand All @@ -21,6 +21,12 @@ bun run dev

Open the app at the printed server URL, usually `http://localhost:3000`.

To create the default node and edge types for an empty graph, start Link once with `--seed`:

```bash
bun src/index.ts --seed
```

Common checks:

```bash
Expand All @@ -31,7 +37,7 @@ bun run build

## Graph storage

The default graph path is `./.data/graph`:
The default graph path is `./.data/graph`, but Link does not create `.data` or graph collection directories until a mutation saves data or explicit `--seed` bootstrap data is written:

```text
.data/
Expand All @@ -50,10 +56,12 @@ The default graph path is `./.data/graph`:

Each record is pretty-printed JSON with deterministic top-level key order and a trailing newline. Deletes remove files; Git keeps the historical copy. Link never runs Git commands for you.

If the graph is empty and you want the built-in starter types, run `bun src/index.ts --seed`. Seeding is skipped when any graph JSON records already exist, so it will not backfill defaults into an existing graph.

## Git workflow

1. `git pull`.
2. Run Link locally and edit through the UI, HTTP API, or MCP tools.
2. Run Link locally and edit through the UI, HTTP API, or MCP tools. Use `bun src/index.ts --seed` first only when you want default types in an empty graph.
3. Inspect JSON changes under `.data/graph`.
4. `bun src/index.ts --validate`.
5. `git add .data/graph && git commit`.
Expand Down Expand Up @@ -103,6 +111,8 @@ curl -s -X POST http://localhost:3000/api/nodes \
-d '{"name":"Ada Lovelace","typeId":"person"}'
```

This example assumes the `person` node type already exists, either from `--seed` or from creating that type yourself.

## Realtime updates

Connect a WebSocket client to `/api/realtime`. Successful graph mutations broadcast `graph.changed` events without graph versions. Clients should refetch `/api/graph` after receiving a change event.
Expand Down Expand Up @@ -133,4 +143,4 @@ Run with graph data mounted:
docker run --rm -p 3000:3000 -v "$PWD/.data:/data" link
```

Use `LINK_DATA_DIR` if you mount the data directory somewhere else inside the container.
Use `LINK_DATA_DIR` if you mount the data directory somewhere else inside the container. To seed an empty mounted graph, run the container command with `bun src/index.ts --seed`.
Comment thread
PabloZaiden marked this conversation as resolved.
Outdated
6 changes: 3 additions & 3 deletions src/api/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ afterEach(() => {
async function start() {
graphRoot = mkdtempSync(path.join(tmpdir(), "link-http-"));
const graphPath = path.join(graphRoot, "graph");
repository = new JsonGraphRepository(graphPath);
repository = new JsonGraphRepository(graphPath, { seed: true });
const app = createApp({
index,
repository,
Expand All @@ -57,7 +57,7 @@ async function request<T>(base: string, requestPath: string, init?: RequestInit)
}

describe("HTTP API", () => {
test("supports automatic bootstrap, CRUD, search, context, and no version fields", async () => {
test("supports explicit seed bootstrap, CRUD, search, context, and no version fields", async () => {
const base = await start();
const health = await request<{ ok: boolean; graphPath: string }>(base, "/api/health");
expect(health.ok).toBe(true);
Expand Down Expand Up @@ -223,7 +223,7 @@ describe("HTTP API", () => {
test("supports versionless MCP tools with shared graph state", () => {
const graphPath = tempGraphPath();
try {
const toolRepository = new JsonGraphRepository(graphPath);
const toolRepository = new JsonGraphRepository(graphPath, { seed: true });
const realtime = new RealtimeHub();
const createNodeTool = linkMcpTools.find(tool => tool.name === "create_node");
expect(Object.keys(createNodeTool?.inputSchema ?? {})).not.toContain("expectedVersion");
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import index from "./index.html";
import { GraphError } from "./domain/errors";
import { startApp } from "./server/app";
import { parseCliOptions } from "./server/cli";
import { loadConfig } from "./server/config";
import { validateGraphPath } from "./storage/json";

if (Bun.argv.includes("--validate")) {
const cliOptions = parseCliOptions(Bun.argv);

if (cliOptions.validate) {
const config = loadConfig();
try {
validateGraphPath(config.graphPath);
Expand All @@ -20,5 +23,5 @@ if (Bun.argv.includes("--validate")) {
process.exit(1);
}
} else {
startApp(index);
startApp(index, { seed: cliOptions.seed });
}
37 changes: 37 additions & 0 deletions src/server/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import index from "../index.html";
import { createApp } from "./app";

function tempGraphPath(): string {
return path.join(mkdtempSync(path.join(tmpdir(), "link-app-")), "graph");
}

function cleanup(graphPath: string): void {
rmSync(path.dirname(graphPath), { recursive: true, force: true });
}

describe("createApp", () => {
test("does not create graph directories during normal startup", () => {
const graphPath = tempGraphPath();
try {
createApp({ index, config: { port: 0, graphPath } });
expect(existsSync(graphPath)).toBe(false);
} finally {
cleanup(graphPath);
}
});

test("passes explicit seed startup through to repository creation", () => {
const graphPath = tempGraphPath();
try {
createApp({ index, config: { port: 0, graphPath }, seed: true });
expect(existsSync(path.join(graphPath, "node-types", "person.json"))).toBe(true);
expect(existsSync(path.join(graphPath, "edge-types", "works-on.json"))).toBe(true);
} finally {
cleanup(graphPath);
}
});
});
11 changes: 8 additions & 3 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ export interface AppDependencies {
config?: AppConfig;
repository?: GraphRepository;
realtime?: RealtimeHub;
seed?: boolean;
index: Response | BunFile | HTMLBundle;
}

export function createApp(dependencies: AppDependencies) {
const config = dependencies.config ?? loadConfig();
Comment thread
PabloZaiden marked this conversation as resolved.
const repository = dependencies.repository ?? new JsonGraphRepository(config.graphPath);
const repository = dependencies.repository ?? new JsonGraphRepository(config.graphPath, { seed: dependencies.seed ?? false });
const realtime = dependencies.realtime ?? new RealtimeHub();

return {
Expand All @@ -29,8 +30,12 @@ export function createApp(dependencies: AppDependencies) {
};
}

export function startApp(index: Response | BunFile | HTMLBundle): Server<undefined> {
const app = createApp({ index });
export interface StartAppOptions {
seed?: boolean;
}

export function startApp(index: Response | BunFile | HTMLBundle, options: StartAppOptions = {}): Server<undefined> {
const app = createApp({ index, seed: options.seed ?? false });
Comment thread
PabloZaiden marked this conversation as resolved.
Outdated
const server = serve(app);
console.log(`Link server running at ${server.url}`);
return server;
Expand Down
16 changes: 16 additions & 0 deletions src/server/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import { parseCliOptions } from "./cli";

describe("parseCliOptions", () => {
test("defaults to read-only startup without validation or seed", () => {
expect(parseCliOptions(["bun", "src/index.ts"])).toEqual({ validate: false, seed: false });
});

test("detects explicit seed startup", () => {
expect(parseCliOptions(["bun", "src/index.ts", "--seed"])).toEqual({ validate: false, seed: true });
});

test("keeps validate and seed flags visible so validation can remain read-only", () => {
expect(parseCliOptions(["bun", "src/index.ts", "--validate", "--seed"])).toEqual({ validate: true, seed: true });
});
});
11 changes: 11 additions & 0 deletions src/server/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface CliOptions {
validate: boolean;
seed: boolean;
}

export function parseCliOptions(argv: string[]): CliOptions {
return {
validate: argv.includes("--validate"),
seed: argv.includes("--seed"),
};
}
65 changes: 60 additions & 5 deletions src/storage/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,22 @@ function cleanup(graphPath: string): void {
}

describe("JsonGraphRepository", () => {
test("creates directories, seeds bootstrap types, and writes canonical JSON", () => {
test("constructs read-only storage for missing paths without creating directories", () => {
const graphPath = tempGraphPath();
try {
const storage = new JsonGraphRepository(graphPath);
expect(storage.getSnapshot()).toEqual({ nodeTypes: [], edgeTypes: [], nodes: [], edges: [] });
expect(storage.search("person")).toEqual({ nodeTypes: [], edgeTypes: [], nodes: [], edges: [] });
expect(existsSync(graphPath)).toBe(false);
} finally {
cleanup(graphPath);
}
});

test("seeds bootstrap types only when explicitly requested and empty", () => {
const graphPath = tempGraphPath();
try {
const storage = new JsonGraphRepository(graphPath, { seed: true });
const snapshot = storage.getSnapshot();
expect(snapshot.nodeTypes.some(type => type.id === "person")).toBe(true);
expect(snapshot.edgeTypes.some(type => type.id === "works-on")).toBe(true);
Expand All @@ -29,10 +41,53 @@ describe("JsonGraphRepository", () => {
}
});

test("persists create, update, delete, search, and context operations", () => {
test("does not seed bootstrap types into non-empty data", () => {
const graphPath = tempGraphPath();
try {
const storage = new JsonGraphRepository(graphPath);
storage.createNodeType({ id: "custom", name: "Custom" });

const restarted = new JsonGraphRepository(graphPath, { seed: true });
const snapshot = restarted.getSnapshot();
expect(snapshot.nodeTypes.map(type => type.id)).toEqual(["custom"]);
expect(snapshot.edgeTypes).toEqual([]);
expect(existsSync(path.join(graphPath, "node-types", "person.json"))).toBe(false);
} finally {
cleanup(graphPath);
}
});

test("creates directories lazily on first actual save", () => {
const graphPath = tempGraphPath();
try {
const storage = new JsonGraphRepository(graphPath);
expect(existsSync(graphPath)).toBe(false);

storage.createNodeType({ id: "person", name: "Person" });
expect(existsSync(path.join(graphPath, "node-types", "person.json"))).toBe(true);
expect(existsSync(path.join(graphPath, "edge-types"))).toBe(true);
expect(existsSync(path.join(graphPath, "nodes"))).toBe(true);
expect(existsSync(path.join(graphPath, "edges"))).toBe(true);
} finally {
cleanup(graphPath);
}
});

test("fails clearly when creating nodes without seeded or saved types", () => {
const graphPath = tempGraphPath();
try {
const storage = new JsonGraphRepository(graphPath);
expect(() => storage.createNode({ name: "Ada Lovelace", typeId: "person" })).toThrow(GraphError);
expect(existsSync(graphPath)).toBe(false);
} finally {
cleanup(graphPath);
}
});

test("persists create, update, delete, search, and context operations", () => {
const graphPath = tempGraphPath();
try {
const storage = new JsonGraphRepository(graphPath, { seed: true });
const ada = storage.createNode({ name: "Ada Lovelace", typeId: "person" });
const link = storage.createNode({ name: "Link", typeId: "project" });
const edge = storage.createEdge({
Expand All @@ -58,7 +113,7 @@ describe("JsonGraphRepository", () => {
test("ignores dotfiles and temporary files in collection directories", () => {
const graphPath = tempGraphPath();
try {
const storage = new JsonGraphRepository(graphPath);
const storage = new JsonGraphRepository(graphPath, { seed: true });
writeFileSync(path.join(graphPath, "nodes", ".DS_Store"), "metadata");
writeFileSync(path.join(graphPath, "nodes", "person.json.123.tmp"), "{\"partial\":true}");
writeFileSync(path.join(graphPath, "nodes", "Thumbs.db"), "metadata");
Expand All @@ -73,7 +128,7 @@ describe("JsonGraphRepository", () => {
test("refuses deleting types that are still in use", () => {
const graphPath = tempGraphPath();
try {
const storage = new JsonGraphRepository(graphPath);
const storage = new JsonGraphRepository(graphPath, { seed: true });
storage.createNode({ name: "Ada Lovelace", typeId: "person" });
expect(() => storage.deleteNodeType("person")).toThrow(GraphError);
} finally {
Expand All @@ -84,7 +139,7 @@ describe("JsonGraphRepository", () => {
test("fails loudly for file/id mismatches, missing references, and merge conflicts", () => {
const graphPath = tempGraphPath();
try {
const storage = new JsonGraphRepository(graphPath);
const storage = new JsonGraphRepository(graphPath, { seed: true });
storage.createNode({ id: "ada", name: "Ada", typeId: "person" });

writeFileSync(
Expand Down
11 changes: 4 additions & 7 deletions src/storage/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ type Collection = "nodeTypes" | "edgeTypes" | "nodes" | "edges";
type RecordTypeName = "node type" | "edge type" | "node" | "edge";

interface JsonRepositoryOptions {
bootstrap?: boolean;
createDirectories?: boolean;
seed?: boolean;
}

const collectionDirs: Record<Collection, string> = {
Expand Down Expand Up @@ -210,10 +209,7 @@ export class JsonGraphRepository implements GraphRepository {
private readonly graphPath: string,
options: JsonRepositoryOptions = {},
) {
const bootstrap = options.bootstrap ?? true;
const createDirectories = options.createDirectories ?? true;
if (createDirectories) this.ensureDirectories();
if (bootstrap && this.isEmpty()) {
if (options.seed === true && this.isEmpty()) {
this.seedBootstrap();
} else {
this.getSnapshot();
Expand Down Expand Up @@ -342,6 +338,7 @@ export class JsonGraphRepository implements GraphRepository {
}

private writeRecord(collection: Collection, record: NodeTypeDefinition | EdgeTypeDefinition | GraphNode | GraphEdge): void {
this.ensureDirectories();
const filePath = this.recordPath(collection, record.id);
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
writeFileSync(tempPath, canonicalJson(record), "utf8");
Expand Down Expand Up @@ -561,6 +558,6 @@ export function validateGraphPath(graphPath: string): GraphSnapshot {
throw validationError(`Invalid graph data in ${dirPath}: expected a directory.`);
}
}
const repository = new JsonGraphRepository(graphPath, { bootstrap: false, createDirectories: false });
const repository = new JsonGraphRepository(graphPath);
return repository.getSnapshot();
}