@vendoai/store 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ import type { Principal } from "@vendoai/core";
2
+ import type { VendoDb } from "./db.js";
3
+ /** Structural stand-in for `vendo/server`'s `IntegrationCatalogEntry`. */
4
+ export interface IntegrationCatalogEntry {
5
+ id: string;
6
+ name: string;
7
+ }
8
+ export interface DurableConnectionsStore {
9
+ list(): Promise<Array<IntegrationCatalogEntry & {
10
+ connected: boolean;
11
+ }>>;
12
+ connect(id: string): Promise<void>;
13
+ disconnect(id: string): Promise<void>;
14
+ connectedToolkits(): Promise<string[]>;
15
+ /** Record the Composio connected-account id once the OAuth flow lands. */
16
+ setConnectedAccount(toolkit: string, connectedAccountId: string): Promise<void>;
17
+ /** Webhook routing: which principal owns this connected account? */
18
+ findByConnectedAccount(connectedAccountId: string): Promise<{
19
+ toolkit: string;
20
+ principal: Principal;
21
+ } | undefined>;
22
+ }
23
+ export declare function createDrizzleConnectionsStore(handle: VendoDb, scope: Principal, catalog: IntegrationCatalogEntry[], opts?: {
24
+ now?: () => string;
25
+ }): DurableConnectionsStore;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * DrizzleConnectionsStore — durable implementation of the STRUCTURAL shape of
3
+ * `vendo/server`'s `ConnectionsStore` (packages/vendo-server/src/connections.ts):
4
+ * which toolkits are connected, i.e. what the agent ingests. Duck-typed
5
+ * locally (not imported) to avoid a server -> store -> server dependency cycle.
6
+ * Both the upstream interface and this durable port are fully async — every
7
+ * operation here is a DB round-trip, and the upstream in-memory store matches
8
+ * that shape (even though it never actually awaits) so the two are drop-in
9
+ * compatible: `vendo/server`'s handler wires this in whenever durable
10
+ * storage is configured, the in-memory one otherwise.
11
+ *
12
+ * Two additions beyond the upstream shape (webhook + integrations flow):
13
+ * - `setConnectedAccount` records the Composio connected-account id once the
14
+ * OAuth flow lands.
15
+ * - `findByConnectedAccount` is a CROSS-PRINCIPAL lookup (unlike every other
16
+ * method here, which is scoped to the store's own Principal) — webhook
17
+ * routing needs to answer "which principal owns this connected account?"
18
+ * before it has a scope to work with.
19
+ */
20
+ import { and, eq } from "drizzle-orm";
21
+ import { connections } from "./schema.js";
22
+ const CONNECTED = "connected";
23
+ const DISCONNECTED = "disconnected";
24
+ export function createDrizzleConnectionsStore(handle, scope, catalog, opts = {}) {
25
+ const db = handle.db;
26
+ const now = opts.now ?? (() => new Date().toISOString());
27
+ const validIds = new Set(catalog.map((c) => c.id));
28
+ async function connectedSet() {
29
+ const rows = await db
30
+ .select({ toolkit: connections.toolkit })
31
+ .from(connections)
32
+ .where(and(eq(connections.tenantId, scope.tenantId), eq(connections.subject, scope.subject), eq(connections.status, CONNECTED)));
33
+ return new Set(rows.map((r) => r.toolkit));
34
+ }
35
+ return {
36
+ async list() {
37
+ const connected = await connectedSet();
38
+ return catalog.map((c) => ({ ...c, connected: connected.has(c.id) }));
39
+ },
40
+ async connect(id) {
41
+ if (!validIds.has(id))
42
+ return;
43
+ await db
44
+ .insert(connections)
45
+ .values({
46
+ toolkit: id,
47
+ tenantId: scope.tenantId,
48
+ subject: scope.subject,
49
+ connectedAccountId: null,
50
+ status: CONNECTED,
51
+ createdAt: now(),
52
+ })
53
+ .onConflictDoUpdate({
54
+ target: [connections.tenantId, connections.subject, connections.toolkit],
55
+ set: { status: CONNECTED },
56
+ });
57
+ },
58
+ async disconnect(id) {
59
+ await db
60
+ .update(connections)
61
+ .set({ status: DISCONNECTED })
62
+ .where(and(eq(connections.tenantId, scope.tenantId), eq(connections.subject, scope.subject), eq(connections.toolkit, id)));
63
+ },
64
+ async connectedToolkits() {
65
+ const connected = await connectedSet();
66
+ return catalog.filter((c) => connected.has(c.id)).map((c) => c.id);
67
+ },
68
+ async setConnectedAccount(toolkit, connectedAccountId) {
69
+ if (!validIds.has(toolkit))
70
+ return;
71
+ await db
72
+ .insert(connections)
73
+ .values({
74
+ toolkit,
75
+ tenantId: scope.tenantId,
76
+ subject: scope.subject,
77
+ connectedAccountId,
78
+ status: CONNECTED,
79
+ createdAt: now(),
80
+ })
81
+ .onConflictDoUpdate({
82
+ target: [connections.tenantId, connections.subject, connections.toolkit],
83
+ set: { connectedAccountId, status: CONNECTED },
84
+ });
85
+ },
86
+ async findByConnectedAccount(connectedAccountId) {
87
+ // Status-filtered: disconnect() flips the row to DISCONNECTED without
88
+ // clearing connectedAccountId (so reconnecting can leave routing
89
+ // history intact) — a redelivered/live webhook for a disconnected
90
+ // account must NOT resolve a principal, or a revoked toolkit would
91
+ // keep firing automations after the user disconnected it.
92
+ const rows = await db
93
+ .select()
94
+ .from(connections)
95
+ .where(and(eq(connections.connectedAccountId, connectedAccountId), eq(connections.status, CONNECTED)));
96
+ const row = rows[0];
97
+ if (!row)
98
+ return undefined;
99
+ return { toolkit: row.toolkit, principal: { tenantId: row.tenantId, subject: row.subject } };
100
+ },
101
+ };
102
+ }
package/dist/db.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
2
+ import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
3
+ import { Pool } from "pg";
4
+ export interface VendoDatabaseConfig {
5
+ connectionString?: string;
6
+ pglite?: {
7
+ dataDir: string;
8
+ };
9
+ }
10
+ export type VendoDb = {
11
+ kind: "pglite";
12
+ db: ReturnType<typeof drizzlePglite>;
13
+ cacheKey: string;
14
+ } | {
15
+ kind: "pg";
16
+ db: ReturnType<typeof drizzlePg>;
17
+ cacheKey: string;
18
+ };
19
+ /**
20
+ * pg.Pool re-emits idle-client errors (backend restart, dropped connection)
21
+ * on itself; with no 'error' listener Node escalates that to an uncaught
22
+ * exception and kills the host process. The pool already discards the dead
23
+ * client — log and keep serving.
24
+ */
25
+ export declare function createPgPool(connectionString: string): Pool;
26
+ export declare function createVendoDatabase(config?: VendoDatabaseConfig): Promise<VendoDb>;
27
+ /** Idempotent, race-safe (advisory lock on real PG), memoized per handle. */
28
+ export declare function migrateVendoDatabase(handle: VendoDb): Promise<void>;
package/dist/db.js ADDED
@@ -0,0 +1,105 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
3
+ import { migrate as migratePglite } from "drizzle-orm/pglite/migrator";
4
+ import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
5
+ import { migrate as migratePg } from "drizzle-orm/node-postgres/migrator";
6
+ import { sql } from "drizzle-orm";
7
+ import { Client, Pool } from "pg";
8
+ import { fileURLToPath } from "node:url";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ const SERVERLESS_ENVS = ["VERCEL", "CF_PAGES", "AWS_LAMBDA_FUNCTION_NAME"];
12
+ const MIGRATIONS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "migrations");
13
+ const ADVISORY_LOCK_KEY = 7461001;
14
+ const registry = (globalThis["__vendoStoreRegistry"] ??= {
15
+ instances: new Map(),
16
+ migrated: new Map(),
17
+ });
18
+ /**
19
+ * pg.Pool re-emits idle-client errors (backend restart, dropped connection)
20
+ * on itself; with no 'error' listener Node escalates that to an uncaught
21
+ * exception and kills the host process. The pool already discards the dead
22
+ * client — log and keep serving.
23
+ */
24
+ export function createPgPool(connectionString) {
25
+ const pool = new Pool({ connectionString });
26
+ pool.on("error", (err) => console.error("[vendo] postgres pool: idle connection error (recovering)", err));
27
+ return pool;
28
+ }
29
+ export function createVendoDatabase(config = {}) {
30
+ // Precedence: explicit connectionString > explicit pglite > env DATABASE_URL
31
+ // > default PGlite — an explicitly passed `pglite` config must not lose to
32
+ // an ambient DATABASE_URL env var.
33
+ // `||` (not `??`): a set-but-empty DATABASE_URL means "no connection string",
34
+ // and must not become a shared "" cache key across different PGlite dirs.
35
+ const conn = config.connectionString || (config.pglite ? undefined : process.env["DATABASE_URL"] || undefined);
36
+ const dataDir = config.pglite?.dataDir ?? process.env["VENDO_DATA_DIR"] ?? ".vendo/data";
37
+ const cacheKey = conn ?? `pglite:${dataDir}`;
38
+ const existing = registry.instances.get(cacheKey);
39
+ if (existing)
40
+ return existing;
41
+ const created = (async () => {
42
+ if (conn)
43
+ return { kind: "pg", db: drizzlePg(createPgPool(conn)), cacheKey };
44
+ const onServerless = SERVERLESS_ENVS.find((e) => process.env[e]);
45
+ if (onServerless) {
46
+ throw new Error(`[vendo] PGlite (the zero-config store) cannot run on ${onServerless} — filesystems there are ephemeral. ` +
47
+ `Set DATABASE_URL to a hosted Postgres (Supabase, Neon, …) instead.`);
48
+ }
49
+ if (!dataDir.startsWith("memory://")) {
50
+ try {
51
+ fs.mkdirSync(dataDir, { recursive: true });
52
+ fs.accessSync(dataDir, fs.constants.W_OK);
53
+ }
54
+ catch (err) {
55
+ // Fail boot loudly — never fall back to a silent ephemeral store.
56
+ throw new Error(`[vendo] PGlite data directory "${dataDir}" is not writable — ` +
57
+ `fix its permissions or point VENDO_DATA_DIR elsewhere. Cause: ${err instanceof Error ? err.message : String(err)}`);
58
+ }
59
+ }
60
+ const client = await PGlite.create(dataDir);
61
+ return { kind: "pglite", db: drizzlePglite(client), cacheKey };
62
+ })();
63
+ created.catch(() => registry.instances.delete(cacheKey)); // failed boots retry
64
+ registry.instances.set(cacheKey, created);
65
+ return created;
66
+ }
67
+ /** Idempotent, race-safe (advisory lock on real PG), memoized per handle. */
68
+ export function migrateVendoDatabase(handle) {
69
+ const memo = registry.migrated.get(handle.cacheKey);
70
+ if (memo)
71
+ return memo;
72
+ const run = (async () => {
73
+ if (handle.kind === "pglite") {
74
+ await migratePglite(handle.db, { migrationsFolder: MIGRATIONS_DIR, migrationsSchema: "vendo" });
75
+ return;
76
+ }
77
+ // Advisory locks are SESSION-scoped, and the handle's db wraps a pg.Pool:
78
+ // lock, migrate, and unlock could each run on a DIFFERENT pooled session,
79
+ // so the lock would protect nothing (and the unlock could target a session
80
+ // that never held it, leaking the lock on the one that did). Run the whole
81
+ // lock → migrate → unlock sequence on ONE dedicated connection instead.
82
+ // For the pg kind, cacheKey IS the connection string (see createVendoDatabase).
83
+ const client = new Client({ connectionString: handle.cacheKey });
84
+ await client.connect();
85
+ try {
86
+ const db = drizzlePg(client);
87
+ await db.execute(sql `select pg_advisory_lock(${ADVISORY_LOCK_KEY})`);
88
+ try {
89
+ await migratePg(db, { migrationsFolder: MIGRATIONS_DIR, migrationsSchema: "vendo" });
90
+ }
91
+ finally {
92
+ await db.execute(sql `select pg_advisory_unlock(${ADVISORY_LOCK_KEY})`);
93
+ }
94
+ }
95
+ finally {
96
+ await client.end();
97
+ }
98
+ })().catch((err) => {
99
+ throw new Error(`[vendo] migration failed — if this is a permissions error, grant the role CREATE on the database ` +
100
+ `or run migrations out-of-band (autoMigrate: false + migrateVendoDatabase). Cause: ${err instanceof Error ? err.message : String(err)}`);
101
+ });
102
+ run.catch(() => registry.migrated.delete(handle.cacheKey));
103
+ registry.migrated.set(handle.cacheKey, run);
104
+ return run;
105
+ }
@@ -0,0 +1,8 @@
1
+ import type { Principal } from "@vendoai/core";
2
+ import type { DecisionStore } from "@vendoai/runtime";
3
+ import type { VendoDb } from "./db.js";
4
+ /** `createDrizzleDecisionStore(handle, scope)` — one store per Principal,
5
+ * mirroring how `rememberDecisions` is wired per-request. */
6
+ export declare function createDrizzleDecisionStore(handle: VendoDb, scope: Principal, opts?: {
7
+ now?: () => string;
8
+ }): DecisionStore;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * DrizzleDecisionStore — durable port of the runtime `DecisionStore` seam
3
+ * (packages/vendo-runtime/src/policy/remember.ts): memoised ask-once
4
+ * approval decisions, keyed by an opaque canonical key and scoped per
5
+ * Principal. Not a junk drawer — see the `decisions` table doc in schema.ts.
6
+ */
7
+ import { and, eq } from "drizzle-orm";
8
+ import { decisions } from "./schema.js";
9
+ /** `createDrizzleDecisionStore(handle, scope)` — one store per Principal,
10
+ * mirroring how `rememberDecisions` is wired per-request. */
11
+ export function createDrizzleDecisionStore(handle, scope, opts = {}) {
12
+ const db = handle.db;
13
+ const now = opts.now ?? (() => new Date().toISOString());
14
+ return {
15
+ async get(canonicalKey) {
16
+ const rows = await db
17
+ .select()
18
+ .from(decisions)
19
+ .where(and(eq(decisions.tenantId, scope.tenantId), eq(decisions.subject, scope.subject), eq(decisions.canonicalKey, canonicalKey)));
20
+ const row = rows[0];
21
+ return row ? row.decision : undefined;
22
+ },
23
+ async set(canonicalKey, decision) {
24
+ await db
25
+ .insert(decisions)
26
+ .values({
27
+ tenantId: scope.tenantId,
28
+ subject: scope.subject,
29
+ canonicalKey,
30
+ decision,
31
+ createdAt: now(),
32
+ })
33
+ .onConflictDoUpdate({
34
+ target: [decisions.tenantId, decisions.subject, decisions.canonicalKey],
35
+ set: { decision },
36
+ });
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,11 @@
1
+ export { and, desc, eq } from "drizzle-orm";
2
+ export { createVendoDatabase, migrateVendoDatabase } from "./db.js";
3
+ export type { VendoDatabaseConfig, VendoDb } from "./db.js";
4
+ export { vendo, automations, automationVersions, automationRuns, decisions, threads, threadMessages, savedVendos, connections, meta, } from "./schema.js";
5
+ export { getMeta, setMeta } from "./meta.js";
6
+ export { DrizzleAutomationStore, toIso } from "./automation-store.js";
7
+ export { createDrizzleDecisionStore } from "./decision-store.js";
8
+ export { createDrizzleThreadStore } from "./thread-store.js";
9
+ export { createDrizzleSavedVendoStore } from "./vendo-registry.js";
10
+ export { createDrizzleConnectionsStore } from "./connections-store.js";
11
+ export type { DurableConnectionsStore, IntegrationCatalogEntry } from "./connections-store.js";
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // Re-exported so consumers (e.g. vendo/server's vendos.ts) build queries
2
+ // against the SAME drizzle-orm instance this package resolved — a consumer
3
+ // declaring its own `drizzle-orm` dependency can land on a different
4
+ // peer-hashed copy in pnpm's node_modules, which breaks structurally (the
5
+ // `Column`/`SQL` classes carry a private field, so TS sees two incompatible
6
+ // nominal types even though the runtime code is identical).
7
+ export { and, desc, eq } from "drizzle-orm";
8
+ export { createVendoDatabase, migrateVendoDatabase } from "./db.js";
9
+ export { vendo, automations, automationVersions, automationRuns, decisions, threads, threadMessages, savedVendos, connections, meta, } from "./schema.js";
10
+ export { getMeta, setMeta } from "./meta.js";
11
+ export { DrizzleAutomationStore, toIso } from "./automation-store.js";
12
+ export { createDrizzleDecisionStore } from "./decision-store.js";
13
+ export { createDrizzleThreadStore } from "./thread-store.js";
14
+ export { createDrizzleSavedVendoStore } from "./vendo-registry.js";
15
+ export { createDrizzleConnectionsStore } from "./connections-store.js";
package/dist/meta.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { VendoDb } from "./db.js";
2
+ /** Read one meta value; `undefined` on a miss. */
3
+ export declare function getMeta(handle: VendoDb, key: string): Promise<unknown>;
4
+ /** Upsert one meta value (last write wins). */
5
+ export declare function setMeta(handle: VendoDb, key: string, value: unknown): Promise<void>;
package/dist/meta.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `meta` helpers — the tiny operational KV over the `vendo.meta` table
3
+ * (scheduler heartbeat, future flags). NOT for domain data; see the table
4
+ * doc in schema.ts.
5
+ */
6
+ import { eq } from "drizzle-orm";
7
+ import { meta } from "./schema.js";
8
+ /** Read one meta value; `undefined` on a miss. */
9
+ export async function getMeta(handle, key) {
10
+ const rows = await handle.db.select().from(meta).where(eq(meta.key, key));
11
+ return rows[0]?.value;
12
+ }
13
+ /** Upsert one meta value (last write wins). */
14
+ export async function setMeta(handle, key, value) {
15
+ const updatedAt = new Date().toISOString();
16
+ await handle.db
17
+ .insert(meta)
18
+ .values({ key, value, updatedAt })
19
+ .onConflictDoUpdate({ target: meta.key, set: { value, updatedAt } });
20
+ }