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
1 change: 1 addition & 0 deletions .github/workflows/cloud-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,5 @@ jobs:
path: |
packages/test/cloud-e2e/.logs
retention-days: 7
include-hidden-files: true
if-no-files-found: ignore
1 change: 1 addition & 0 deletions bun.lock

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

43 changes: 40 additions & 3 deletions packages/cloud-shared/src/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ type Database = NodePgDatabase<typeof schema>;
/** Transaction handle for `writeTransaction` callbacks. */
type DbTransaction = NodePgTransaction<typeof schema, SchemaTables>;

type DatabaseCloser = () => Promise<void> | void;

const databaseClosers = new WeakMap<Database, DatabaseCloser>();

function registerDatabaseCloser(database: Database, closer: DatabaseCloser): Database {
databaseClosers.set(database, closer);
return database;
}

/**
* Get the primary database URL (always required)
*/
Expand Down Expand Up @@ -90,7 +99,9 @@ function createPGliteClient(dataDir: string): Database {
extensions: { vector },
});
const database: PgliteDatabase<typeof schema> = drizzlePGlite({ client, schema });
return database as Database;
return registerDatabaseCloser(database as Database, async () => {
await client.close();
});
}

function isLocalTcpPostgresUrl(url: string): boolean {
Expand Down Expand Up @@ -164,11 +175,11 @@ function createConnection(url: string): Database {
neonConfig.webSocketConstructor = ws;
}
const pool = new NeonPool({ connectionString: url });
return drizzleNeon(pool, { schema }) as Database;
return registerDatabaseCloser(drizzleNeon(pool, { schema }) as Database, () => pool.end());
}

const pool = createPgPool(url);
return drizzleNode(pool, { schema }) as Database;
return registerDatabaseCloser(drizzleNode(pool, { schema }) as Database, () => pool.end());
}

// ============================================================================
Expand Down Expand Up @@ -265,6 +276,25 @@ class DatabaseConnectionManager {
databaseUrlConfigured: !!applyDatabaseUrlFallback(env),
};
}

/**
* Close process-level cached connections.
*
* Used by local test/dev harnesses that bring up and tear down ephemeral
* Postgres/PGlite servers in the same Node/Bun process. Workers use
* request-scoped caches and do not share this singleton pool.
*/
async closeAll(): Promise<void> {
const databases = Array.from(this.connections.values());
this.connections.clear();
const requestCache = dbCacheAls.getStore();
requestCache?.clear();
await Promise.all(
databases.map(async (database) => {
await databaseClosers.get(database)?.();
}),
);
}
}

const connectionManager = new DatabaseConnectionManager();
Expand Down Expand Up @@ -329,6 +359,13 @@ export function getDbConnectionInfo() {
return connectionManager.getConnectionInfo();
}

/**
* Close cached process-local DB pools for local test/dev teardown.
*/
export async function closeDatabaseConnectionsForTests(): Promise<void> {
await connectionManager.closeAll();
}

/**
* Execute a read-intent query.
*/
Expand Down
125 changes: 125 additions & 0 deletions packages/scripts/cloud/admin/dev/cloud-api-hono-dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env bun
/**
* Local Hono server for the mock cloud E2E harness.
*
* The production Cloud API still runs as a Cloudflare Worker. This launcher is
* intentionally scoped to local/mock tests where we need deterministic process
* startup and the same Hono route graph, but not Wrangler's dev proxy/runtime.
*/

import { createApp } from "../../../../cloud-api/src/bootstrap-app";

type StoredObject = {
bytes: Uint8Array;
httpMetadata?: { contentType?: string };
customMetadata?: Record<string, string>;
};

const encoder = new TextEncoder();
const store = new Map<string, StoredObject>();

async function toBytes(
value: string | ArrayBuffer | ArrayBufferView | Blob | null,
): Promise<Uint8Array> {
if (value === null) return new Uint8Array();
if (typeof value === "string") return encoder.encode(value);
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
return new Uint8Array(await value.arrayBuffer());
}

const blobBinding = {
async get(key: string) {
const object = store.get(key);
if (!object) return null;
return {
httpMetadata: object.httpMetadata,
customMetadata: object.customMetadata,
async text() {
return new TextDecoder().decode(object.bytes);
},
async arrayBuffer() {
return object.bytes.buffer.slice(
object.bytes.byteOffset,
object.bytes.byteOffset + object.bytes.byteLength,
);
},
};
},
async put(
key: string,
value: string | ArrayBuffer | ArrayBufferView | Blob | null,
options?: {
httpMetadata?: { contentType?: string };
customMetadata?: Record<string, string>;
},
) {
store.set(key, {
bytes: await toBytes(value),
httpMetadata: options?.httpMetadata,
customMetadata: options?.customMetadata,
});
},
async delete(key: string) {
store.delete(key);
},
};

function executionContext(): ExecutionContext {
return {
waitUntil(promise) {
Promise.resolve(promise).catch((error) => {
console.error("[cloud-api-hono-dev] waitUntil failed", error);
});
},
passThroughOnException() {},
} as ExecutionContext;
}

const port = Number.parseInt(process.env.API_DEV_PORT || "8787", 10);
const hostname = process.env.API_DEV_HOST || "127.0.0.1";
const app = createApp();
const env = {
...process.env,
BLOB: blobBinding,
};

const server = Bun.serve({
hostname,
port,
async fetch(request) {
try {
const url = new URL(request.url);
if (url.pathname === "/api/health") {
return Response.json(
{
status: "ok",
timestamp: Date.now(),
region: "local-hono",
},
{ headers: { "Cache-Control": "no-store, max-age=0" } },
);
}
return await app.fetch(request, env, executionContext());
} catch (error) {
console.error("[cloud-api-hono-dev] unhandled request error", error);
return Response.json(
{ success: false, error: "internal_error" },
{ status: 500 },
);
}
},
});

console.log(
`[cloud-api-hono-dev] listening on http://${hostname}:${server.port}`,
);

const shutdown = () => {
server.stop(true);
process.exit(0);
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
Loading
Loading