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
42 changes: 42 additions & 0 deletions src/ingress/bindings-port.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import type { GranolaBindingsPort } from "./bindings-port.js";

type FakeBinding = { folderId: string };

function fakePort(): {
port: GranolaBindingsPort<FakeBinding>;
saved: { tenantId: string; principalId: string; bindings: FakeBinding[] }[];
} {
const saved: { tenantId: string; principalId: string; bindings: FakeBinding[] }[] = [];
let stored: { bindings: FakeBinding[]; version: number } | undefined;

const port: GranolaBindingsPort<FakeBinding> = {
async load({ tenantId: _tenantId }) {
return stored;
},
async save({ tenantId, principalId, bindings }) {
saved.push({ tenantId, principalId, bindings });
stored = { bindings, version: (stored?.version ?? 0) + 1 };
},
};

return { port, saved };
}

describe("GranolaBindingsPort", () => {
test("load returns undefined when nothing has ever been saved", async () => {
const { port } = fakePort();
expect(await port.load({ tenantId: "t1" })).toBeUndefined();
});

test("save then load round-trips the bindings and bumps the version", async () => {
const { port, saved } = fakePort();
const bindings: FakeBinding[] = [{ folderId: "fol_1" }];

await port.save({ tenantId: "t1", principalId: "p1", bindings });
const loaded = await port.load({ tenantId: "t1" });

expect(loaded).toEqual({ bindings, version: 1 });
expect(saved).toEqual([{ tenantId: "t1", principalId: "p1", bindings }]);
});
});
19 changes: 19 additions & 0 deletions src/ingress/bindings-port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// The persistence seam for durable Granola bucket bindings (folder id ->
// workflow type -> chat channel). This package never persists anything
// itself — a host composing @corbits/granola/ingress supplies a concrete
// GranolaBindingsPort backed by whatever storage it already has (a
// database row, a config artifact, a file — this package doesn't care).
// Generic over the binding shape so this file has zero dependency on any
// host-specific or product-specific type.

export interface GranolaBindingsLoadResult<Binding> {
bindings: Binding[];
version: number;
}

export interface GranolaBindingsPort<Binding> {
/** Current persisted bindings for a tenant, or `undefined` if none have ever been saved. */
load(args: { tenantId: string }): Promise<GranolaBindingsLoadResult<Binding> | undefined>;
/** Persists a whole new binding set as the tenant's current bindings. */
save(args: { tenantId: string; principalId: string; bindings: Binding[] }): Promise<void>;
}
1 change: 1 addition & 0 deletions src/ingress/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
// structurally enforced.
export { verifyGranolaSignature } from "./webhook.js";
export type { GranolaIngressOptions, GranolaWebhookHandler } from "./webhook.js";
export type { GranolaBindingsPort, GranolaBindingsLoadResult } from "./bindings-port.js";
Loading