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
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"test:coverage": "bun test --coverage src"
},
"dependencies": {
"@intx/log": "0.2.2",
"arktype": "^2.1.29"
},
"devDependencies": {
Expand Down
140 changes: 140 additions & 0 deletions src/ingress/binding-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, test } from "bun:test";

import { createGranolaBindingStore } from "./binding-store";
import type { GranolaBindingsPort } from "./bindings-port";
import type { GranolaBucket } from "../tools/types";

const TENANT = "tenant_1";
const PRINCIPAL = "principal_1";

const SEED_BUCKETS: GranolaBucket[] = [
{ folderId: "fol_seed", type: "diligence", channel: "C_SEED" },
];

type StoredBindings = { bindings: GranolaBucket[]; version: number };

function fakePort(initial?: StoredBindings): {
port: GranolaBindingsPort<GranolaBucket>;
saveCalls: { tenantId: string; principalId: string; bindings: GranolaBucket[] }[];
loadCalls: { tenantId: string }[];
} {
const saveCalls: { tenantId: string; principalId: string; bindings: GranolaBucket[] }[] = [];
const loadCalls: { tenantId: string }[] = [];
let stored = initial;

const port: GranolaBindingsPort<GranolaBucket> = {
async load(args) {
loadCalls.push(args);
return stored;
},
async save(args) {
saveCalls.push(args);
stored = { bindings: args.bindings, version: (stored?.version ?? 0) + 1 };
},
};

return { port, saveCalls, loadCalls };
}

describe("createGranolaBindingStore", () => {
test("falls back to seed bindings, without writing, when no artifact is persisted yet", async () => {
const { port, saveCalls, loadCalls } = fakePort();
const store = createGranolaBindingStore({
port,
tenantId: TENANT,
principalId: PRINCIPAL,
seedBindings: SEED_BUCKETS,
});

const bindings = await store.list();

expect(bindings).toEqual(SEED_BUCKETS);
expect(loadCalls).toHaveLength(1);
expect(saveCalls).toHaveLength(0);
});

test("prefers a persisted artifact's bindings over the seed once one exists", async () => {
const persistedBucket: GranolaBucket = {
folderId: "fol_persisted",
type: "internal",
channel: "C_PERSISTED",
};
const { port } = fakePort({
bindings: [persistedBucket],
version: 1,
});
const store = createGranolaBindingStore({
port,
tenantId: TENANT,
principalId: PRINCIPAL,
seedBindings: SEED_BUCKETS,
});

const bindings = await store.list();

expect(bindings).toEqual([persistedBucket]);
});

test("caches list() across calls, and only hits the port once", async () => {
const { port, loadCalls } = fakePort();
const store = createGranolaBindingStore({
port,
tenantId: TENANT,
principalId: PRINCIPAL,
seedBindings: SEED_BUCKETS,
});

await store.list();
await store.list();
await store.list();

expect(loadCalls).toHaveLength(1);
});

test("replaceAll persists the new binding set and invalidates the cache so the next list() reflects it", async () => {
const { port, saveCalls, loadCalls } = fakePort();
const store = createGranolaBindingStore({
port,
tenantId: TENANT,
principalId: PRINCIPAL,
seedBindings: SEED_BUCKETS,
});

await store.list();
expect(loadCalls).toHaveLength(1);

const newBindings: GranolaBucket[] = [
{ folderId: "fol_new", type: "diligence", channel: "C_NEW" },
];
await store.replaceAll(newBindings);

expect(saveCalls).toHaveLength(1);
expect(saveCalls[0]?.bindings).toEqual(newBindings);

const listed = await store.list();
expect(listed).toEqual(newBindings);
// Cache was invalidated by replaceAll, so this list() had to re-query.
expect(loadCalls).toHaveLength(2);
});

test("replaceAll invokes the onChange hook with the new bindings", async () => {
const { port } = fakePort();
const onChangeCalls: GranolaBucket[][] = [];
const store = createGranolaBindingStore({
port,
tenantId: TENANT,
principalId: PRINCIPAL,
seedBindings: SEED_BUCKETS,
onChange: (bindings) => {
onChangeCalls.push(bindings);
},
});

const newBindings: GranolaBucket[] = [
{ folderId: "fol_new", type: "internal", channel: "C_NEW" },
];
await store.replaceAll(newBindings);

expect(onChangeCalls).toEqual([newBindings]);
});
});
121 changes: 121 additions & 0 deletions src/ingress/binding-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Durable, mutable Granola bucket-binding store: folder id -> workflow type
* -> chat channel bindings. Persistence goes through `GranolaBindingsPort`
* (`./bindings-port.ts`) — the package declares the seam, the host
* implements it against its own storage (a config artifact, a database row,
* a file — this module doesn't know or care), which is what keeps this file
* free of any dependency on a specific host or product.
*
* A host's own seed configuration is demoted to a fallback: `list()` returns
* the persisted bindings when any exist, and only falls back to
* `seedBindings` when nothing has ever been saved for the tenant. Reading
* never writes — a host that has never touched bindings stays on the seed
* fallback indefinitely; the persisted set is created only by an explicit
* `replaceAll` call. This is the "seed-on-first-write, no silent
* auto-migration" behavior: a read-triggered persist would make the seed
* fallback disappear the moment anything called `list()`, with no operator
* action behind it.
*
* An in-memory cache avoids round-tripping to the port on every ingested
* Granola event; `replaceAll` invalidates it so the next `list()` reflects
* the write it just made.
*/
import { getLogger } from "@intx/log";
import { type } from "arktype";

import type { GranolaBindingsPort } from "./bindings-port.js";
import { GranolaBucketsArray, type GranolaBucket } from "../tools/types.js";

type Logger = ReturnType<typeof getLogger>;

const log = getLogger(["corbits", "granola", "binding-store"]);

export type GranolaBindingStore = {
/** Current bindings for the tenant — from the persisted store, or the seed-config fallback. */
list(): Promise<GranolaBucket[]>;
/** Persists a whole new binding set as the next version of the tenant's bindings. */
replaceAll(bindings: GranolaBucket[]): Promise<void>;
};

export type CreateGranolaBindingStoreOptions = {
port: GranolaBindingsPort<GranolaBucket>;
tenantId: string;
principalId: string;
/** Seed bindings, used only while nothing has been persisted for this tenant. */
seedBindings: GranolaBucket[];
log?: Logger;
/** Invoked with the new binding set after every successful `replaceAll` — e.g. to reconcile a webhook subscription's folder scope. */
onChange?: (bindings: GranolaBucket[]) => void | Promise<void>;
};

export function createGranolaBindingStore(
options: CreateGranolaBindingStoreOptions,
): GranolaBindingStore {
const { port, tenantId, principalId, seedBindings } = options;
const logger = options.log ?? log;

let cache: GranolaBucket[] | undefined;

async function load(): Promise<GranolaBucket[]> {
const found = await port.load({ tenantId });
if (found !== undefined) {
const validated = GranolaBucketsArray(found.bindings);
if (validated instanceof type.errors) {
logger.error(
"Granola bindings for tenant {tenantId} exist but failed validation — falling back to {count} seed binding(s): {summary}",
{ tenantId, count: seedBindings.length, summary: validated.summary },
);
return seedBindings;
}
logger.info(
"Granola bindings for tenant {tenantId} resolved from the persisted store — {count} binding(s), version {version}",
{ tenantId, count: validated.length, version: found.version },
);
return validated;
}

logger.info(
"No persisted Granola bindings for tenant {tenantId} — falling back to {count} seed binding(s) until replaceAll persists a binding set",
{ tenantId, count: seedBindings.length },
);
return seedBindings;
}

// Single-process cache: invalidation happens only via replaceAll on THIS
// store instance. Running more than one host replica means a binding edit
// on one process is invisible to the others until restart — revisit with
// a TTL or notification channel before a host scales horizontally.
return {
async list() {
if (cache === undefined) {
cache = await load();
}
// Defensive copy: callers must not be able to mutate the cached set.
return [...cache];
},
async replaceAll(bindings) {
await port.save({
tenantId,
principalId,
bindings,
});
logger.info(
"Granola bindings for tenant {tenantId} replaced — {count} binding(s) written",
{
tenantId,
count: bindings.length,
},
);
cache = undefined;
try {
await options.onChange?.(bindings);
} catch (cause) {
// The binding write already succeeded — a convergence hook failure
// must never make it look failed to the caller.
logger.error("Granola binding onChange hook failed: {error}", {
error: cause instanceof Error ? cause.message : String(cause),
});
}
},
};
}
12 changes: 12 additions & 0 deletions src/ingress/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,15 @@ export type {
GranolaEventType,
} from "./webhook.js";
export type { GranolaBindingsPort, GranolaBindingsLoadResult } from "./bindings-port.js";

export { createGranolaBindingStore } from "./binding-store.js";
export type {
GranolaBindingStore,
CreateGranolaBindingStoreOptions,
} from "./binding-store.js";

export {
ensureGranolaWebhook,
reconcileGranolaWebhookFolders,
} from "./webhook-registration.js";
export type { EnsureGranolaWebhookOptions } from "./webhook-registration.js";
Loading
Loading