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
122 changes: 122 additions & 0 deletions packages/client/src/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,125 @@ describe("Client (SDK-backed facade)", () => {
expect(err.message).toMatch(RE_CONFLICT);
});
});

describe("branch staging surface", () => {
it("createBranch posts and resolves; a 409 (name exists) surfaces as OmnigraphHttpError", async () => {
const ok = clientWith(
fetchReturning(
jsonResponse({ uri: "og://g", from: "main", name: "stage", actor_id: null }),
),
);
await expect(ok.createBranch("stage")).resolves.toBeUndefined();

const exists = clientWith(
fetchReturning(
jsonResponse({ error: "branch 'stage' already exists", code: "conflict" }, 409),
),
);
await expect(exists.createBranch("stage")).rejects.toMatchObject({
name: "OmnigraphHttpError",
status: 409,
});
});

it("mergeBranch maps the three success outcomes", async () => {
for (const outcome of ["already_up_to_date", "fast_forward", "merged"]) {
const client = clientWith(
fetchReturning(
jsonResponse({ source: "stage", target: "main", outcome, actor_id: null }),
),
);
await expect(client.mergeBranch("stage")).resolves.toEqual({
ok: true,
outcome,
});
}
});

it("mergeBranch turns a 409 with merge_conflicts into {ok:false, conflicts}", async () => {
const client = clientWith(
fetchReturning(
jsonResponse(
{
error: "merge conflicts",
code: "conflict",
merge_conflicts: [
{
table_key: "node:Task",
row_id: "t1",
kind: "DivergentUpdate",
message: "status changed on both branches",
},
{
table_key: "edge:AssignedTo",
row_id: null,
kind: "OrphanEdge",
message: "edge target deleted on main",
},
],
},
409,
),
),
);
const result = await client.mergeBranch("stage", "main");
expect(result).toEqual({
ok: false,
conflicts: [
{
table_key: "node:Task",
row_id: "t1",
kind: "DivergentUpdate",
message: "status changed on both branches",
},
{
table_key: "edge:AssignedTo",
kind: "OrphanEdge",
message: "edge target deleted on main",
},
],
});
});

it("deleteBranch resolves on 2xx", async () => {
const client = clientWith(
fetchReturning(jsonResponse({ uri: "og://g", name: "stage", actor_id: null })),
);
await expect(client.deleteBranch("stage")).resolves.toBeUndefined();
});

it("snapshot reshapes tables to {table_key, version, row_count}", async () => {
const client = clientWith(
fetchReturning(
jsonResponse({
branch: "stage",
manifest_version: 7,
tables: [
{
table_key: "node:Task",
table_path: "tables/task",
table_version: 16,
table_branch: "stage",
row_count: 12,
},
{
table_key: "node:Comment",
table_path: "tables/comment",
table_version: 9,
table_branch: null,
row_count: 4,
},
],
}),
),
);
await expect(client.snapshot("stage")).resolves.toEqual({
branch: "stage",
manifest_version: 7,
tables: [
{ table_key: "node:Task", version: 16, row_count: 12, writer: "stage" },
{ table_key: "node:Comment", version: 9, row_count: 4, writer: null },
],
});
});
});
143 changes: 143 additions & 0 deletions packages/client/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import {
Omnigraph,
ConflictError,
NetworkError,
OmnigraphError,
type QueryInput as SdkQueryInput,
Expand Down Expand Up @@ -74,6 +75,43 @@ export interface BranchListOutput {
branches: string[];
}

/** One structured conflict from an all-or-nothing merge attempt (409). */
export interface MergeConflictInfo {
table_key: string;
row_id?: string;
kind: string;
message: string;
}

/**
* Merge result as a discriminated union: conflicts are an EXPECTED outcome of
* reviewing staged work, not an exception — the target is untouched on
* conflict, so callers render `conflicts` and keep going.
*/
export type BranchMergeResult =
| { ok: true; outcome: "already_up_to_date" | "fast_forward" | "merged" }
| { ok: false; conflicts: MergeConflictInfo[] };

/** Per-table manifest state on a branch — the table-level delta source. */
export interface SnapshotTableInfo {
table_key: string;
version: number;
row_count: number;
/**
* The branch lineage that last wrote this table (null = the graph's main
* lineage). Version numbers are per-lineage counters, so two DIVERGED
* branches can share a version with different contents — the writer is
* what disambiguates them.
*/
writer: string | null;
}

export interface SnapshotOutput {
branch: string;
manifest_version: number;
tables: SnapshotTableInfo[];
}

export type ParamKind =
| "string"
| "bool"
Expand Down Expand Up @@ -304,6 +342,111 @@ export class Client {
}
}

/**
* Fork `name` off `from` (server default: main). A name collision surfaces
* as the usual 409 `OmnigraphHttpError` — callers that want "switch to the
* existing branch instead" match on `status === 409`.
*/
async createBranch(
name: string,
from?: string,
signal?: AbortSignal,
): Promise<void> {
this.requireGraph("/branches");
try {
await this.og.branches.create(
{ name, ...(from !== undefined ? { from } : {}) },
signal ? { signal } : {},
);
} catch (e) {
throw toHttpError(e, "/branches");
}
}

/**
* Merge `source` into `target` (server default: main). Three-way and
* all-or-nothing: on conflict nothing is published and the structured
* conflict list comes back as `{ok: false}` rather than a throw.
*/
async mergeBranch(
source: string,
target?: string,
signal?: AbortSignal,
): Promise<BranchMergeResult> {
this.requireGraph("/branches/merge");
try {
const r = await this.og.branches.merge(
{ source, ...(target !== undefined ? { target } : {}) },
signal ? { signal } : {},
);
return {
ok: true,
outcome: r.outcome as "already_up_to_date" | "fast_forward" | "merged",
};
} catch (e) {
if (e instanceof ConflictError) {
// SDK 0.7.0 reads `body?.mergeConflicts` but the server sends
// snake_case `merge_conflicts` (error bodies aren't camelized), so
// `e.mergeConflicts` is always undefined — fall back to the raw body.
const raw = (e.body as { merge_conflicts?: unknown } | undefined)
?.merge_conflicts;
const entries =
e.mergeConflicts?.map((c) => ({
table_key: c.tableKey,
...(c.rowId != null ? { row_id: c.rowId } : {}),
kind: String(c.kind),
message: c.message,
})) ??
(Array.isArray(raw)
? (raw as Record<string, unknown>[]).map((c) => ({
table_key: String(c.table_key ?? ""),
...(c.row_id != null ? { row_id: String(c.row_id) } : {}),
kind: String(c.kind ?? "conflict"),
message: String(c.message ?? ""),
}))
: undefined);
if (entries !== undefined) return { ok: false, conflicts: entries };
}
throw toHttpError(e, "/branches/merge");
}
}

/** Delete a branch pointer. Idempotent server-side (missing = no-op). */
async deleteBranch(name: string, signal?: AbortSignal): Promise<void> {
this.requireGraph(`/branches/${name}`);
try {
await this.og.branches.delete(name, signal ? { signal } : {});
} catch (e) {
throw toHttpError(e, `/branches/${name}`);
}
}

/**
* Latest-commit manifest state of a branch: per-table version + row count.
* Two snapshots (branch vs its base) make the table-level change summary.
*/
async snapshot(branch?: string, signal?: AbortSignal): Promise<SnapshotOutput> {
this.requireGraph("/snapshot");
try {
const r = await this.og.snapshot(
branch !== undefined ? { branch } : {},
signal ? { signal } : {},
);
return {
branch: r.branch,
manifest_version: r.manifestVersion,
tables: r.tables.map((t) => ({
table_key: t.tableKey,
version: t.tableVersion,
row_count: t.rowCount,
writer: t.tableBranch ?? null,
})),
};
} catch (e) {
throw toHttpError(e, "/snapshot");
}
}

async healthz(): Promise<void> {
try {
await this.og.health();
Expand Down
4 changes: 4 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export {
type MutateInput,
type ChangeOutput,
type BranchListOutput,
type BranchMergeResult,
type MergeConflictInfo,
type SnapshotOutput,
type SnapshotTableInfo,
type ParamDescriptor,
type ParamKind,
type QueriesOutput,
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ describe("buildConfig", () => {
"http://127.0.0.1:5173/?server=http://example.test&token=tok&branch=review&graph=acme",
);
const config = await buildConfig();
expect(config.label).toBe("server: http://example.test · graph: acme · review");
// The branch is session-switchable chrome (BranchBar), not part of the
// static label — it surfaces on config.branch instead.
expect(config.label).toBe("server: http://example.test · graph: acme");
expect(config.branch).toBe("review");
// rawGq is off unless the explicit ?allowRawGq escape hatch is present.
expect(config.source.capabilities().rawGq).toBe(false);
expect(storage.get("omnigraph_token")).toBe("tok");
Expand Down
Loading
Loading