Skip to content
Open
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
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,18 +250,20 @@ resolved deterministically with suffixes.
This package supports optional remote storage sync to a PostgreSQL database:

```bash
export HASNA_CONVERSATIONS_DATABASE_URL="<value from hasna/xyz/opensource/conversations/prod/rds>"
export HASNA_CONVERSATIONS_DATABASE_URL="<your PostgreSQL connection string>"
conversations storage status
conversations storage push
conversations storage pull
```

Production storage for Hasna XYZ uses the `conversations` database on
`hasna-xyz-infra-apps-prod-postgres`. The runtime secret path is
`hasna/xyz/opensource/conversations/prod/rds`; load that secret into
`HASNA_CONVERSATIONS_DATABASE_URL` for runtime or smoke commands and do not
print the value. `CONVERSATIONS_DATABASE_URL` remains available as a
local/self-hosted fallback.
This package ships no default database, cluster, or secret-manager identifier —
`HASNA_CONVERSATIONS_DATABASE_URL` (fallback: `CONVERSATIONS_DATABASE_URL`) is
the only required configuration, and its value is never logged or printed.
Operators running a self-hosted deployment may optionally set
`HASNA_CONVERSATIONS_RDS_CLUSTER` and `HASNA_CONVERSATIONS_RDS_SECRET_PATH` to
surface their own cluster name / secret path in `conversations storage
status` output; both are informational only and default to "not configured"
when unset.

Before cutover, verify `conversations storage status`, run a read-only smoke
against the canonical database, and keep legacy sources read-only until the
Expand Down
204 changes: 0 additions & 204 deletions docs/CUTOVER-RUNBOOK.md

This file was deleted.

39 changes: 34 additions & 5 deletions src/cli/storage-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const retiredCloudCommand = ["program.command(\"", "cloud", "\""].join("");
const retiredCloudExport = ["register", "Cloud", "Commands"].join("");

describe("conversations storage CLI contract", () => {
it("reports canonical RDS metadata in status output without printing a URL", () => {
it("reports canonical RDS metadata as unconfigured when no env override is set (no OSS-baked default)", () => {
const result = Bun.spawnSync({
cmd: ["bun", "src/cli/index.tsx", "storage", "status", "--json"],
cwd: join(import.meta.dir, "../.."),
Expand All @@ -16,33 +16,62 @@ describe("conversations storage CLI contract", () => {
CONVERSATIONS_DB_PATH: ":memory:",
HASNA_CONVERSATIONS_STORAGE_MODE: "local",
NO_COLOR: "1",
// Explicitly unset so this test is deterministic even if the ambient
// environment (e.g. a real deployment host) has these configured.
HASNA_CONVERSATIONS_RDS_CLUSTER: "",
HASNA_CONVERSATIONS_RDS_SECRET_PATH: "",
},
});

expect(result.exitCode).toBe(0);
const stdout = new TextDecoder().decode(result.stdout);
const info = JSON.parse(stdout) as {
canonical: {
cluster: string;
cluster: string | null;
database: string;
runtimeSecretPath: string;
runtimeSecretPath: string | null;
env: string;
fallbackEnv: string;
};
configured: boolean;
};

expect(info.canonical).toEqual({
cluster: "hasna-xyz-infra-apps-prod-postgres",
cluster: null,
database: "conversations",
runtimeSecretPath: "hasna/xyz/opensource/conversations/prod/rds",
runtimeSecretPath: null,
env: "HASNA_CONVERSATIONS_DATABASE_URL",
fallbackEnv: "CONVERSATIONS_DATABASE_URL",
});
expect(info.configured).toBe(false);
expect(stdout).not.toContain("postgres://");
});

it("reports operator-supplied canonical RDS metadata from env, never a package default", () => {
const result = Bun.spawnSync({
cmd: ["bun", "src/cli/index.tsx", "storage", "status", "--json"],
cwd: join(import.meta.dir, "../.."),
env: {
...process.env,
HASNA_CONVERSATIONS_DB_PATH: ":memory:",
CONVERSATIONS_DB_PATH: ":memory:",
HASNA_CONVERSATIONS_STORAGE_MODE: "local",
NO_COLOR: "1",
HASNA_CONVERSATIONS_RDS_CLUSTER: "example-cluster",
HASNA_CONVERSATIONS_RDS_SECRET_PATH: "example/secret/path",
},
});

expect(result.exitCode).toBe(0);
const stdout = new TextDecoder().decode(result.stdout);
const info = JSON.parse(stdout) as {
canonical: { cluster: string | null; runtimeSecretPath: string | null };
};
expect(info.canonical.cluster).toBe("example-cluster");
expect(info.canonical.runtimeSecretPath).toBe("example/secret/path");
expect(stdout).not.toContain("postgres://");
});

it("shows storage command in help without a cloud alias", () => {
const result = Bun.spawnSync({
cmd: ["bun", "src/cli/index.tsx", "--help"],
Expand Down
10 changes: 6 additions & 4 deletions src/cli/storage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Command } from "commander";
import chalk from "chalk";
import {
CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV,
CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV,
DEFAULT_STORAGE_TABLES,
getCanonicalConversationsRdsConfig,
getStorageConfig,
Expand Down Expand Up @@ -47,9 +49,9 @@ function installStorageSubcommands(storage: Command): void {
if (opts.json) { printJson(info); return; }
console.log(`Mode: ${info.mode}`);
console.log("Service: conversations");
console.log(`Canonical RDS cluster: ${canonical.cluster}`);
console.log(`Canonical RDS cluster: ${canonical.cluster ?? `(not configured — set ${CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV})`}`);
console.log(`Canonical database: ${canonical.database}`);
console.log(`Runtime secret path: ${canonical.runtimeSecretPath}`);
console.log(`Runtime secret path: ${canonical.runtimeSecretPath ?? `(not configured — set ${CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV})`}`);
console.log(`Database env: ${canonical.env} (fallback: ${canonical.fallbackEnv})`);
console.log(`Remote storage configured: ${info.configured ? "yes" : "no"}`);
console.log(`Tables: ${info.tables.join(", ")}`);
Expand Down Expand Up @@ -80,8 +82,8 @@ function installStorageSubcommands(storage: Command): void {

// CUTOVER: gate off. Bidirectional sync is forbidden under Storage Amendment
// A1 (pure remote). The flip lane must remove this `sync` subcommand (and the
// push/pull data-path subcommands) before conversations goes remote. See
// docs/CUTOVER-RUNBOOK.md.
// push/pull data-path subcommands) before conversations goes remote. The flip
// procedure lives in Hasna's internal ops documentation (not shipped here).
storage.command("sync").description("Bidirectional sync: pull then push").option("--tables <tables>", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts: { tables?: string; json?: boolean }) => {
try {
const result = await storageSync({ tables: opts.tables });
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ export {

export {
CANONICAL_CONVERSATIONS_DATABASE_ENV,
CANONICAL_CONVERSATIONS_RDS_CLUSTER,
CANONICAL_CONVERSATIONS_RDS_CLUSTER_ENV,
CANONICAL_CONVERSATIONS_RDS_DATABASE,
CANONICAL_CONVERSATIONS_RDS_SECRET_PATH,
CANONICAL_CONVERSATIONS_RDS_SECRET_PATH_ENV,
CONVERSATIONS_DATABASE_FALLBACK_ENV,
DEFAULT_STORAGE_TABLES,
SYNC_EXCLUDED,
Expand Down
6 changes: 3 additions & 3 deletions src/lib/cloud-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from "./cloud-store.js";

const CLOUD_ENV = {
HASNA_CONVERSATIONS_API_URL: "https://conversations.hasna.xyz",
HASNA_CONVERSATIONS_API_URL: "https://conversations.md",
HASNA_CONVERSATIONS_API_KEY: "hasna_conversations_testkey_00000000",
};

Expand Down Expand Up @@ -40,7 +40,7 @@ describe("resolveConversationsCloud", () => {
test("cloud client when url + key set", () => {
const client = resolveConversationsCloud({ ...CLOUD_ENV });
expect(client).not.toBeNull();
expect(client!.baseUrl).toBe("https://conversations.hasna.xyz/v1");
expect(client!.baseUrl).toBe("https://conversations.md/v1");
expect(isCloudMode({ ...CLOUD_ENV })).toBe(true);
});
test("null (local) when unset", () => {
Expand Down Expand Up @@ -115,7 +115,7 @@ describe("routed message CRUD hits the cloud API when self_hosted", () => {
const msg = await sendMessage({ from: "alice", to: "bob", content: "hi" }, { ...CLOUD_ENV });
expect(msg.id).toBe(42);
const post = calls.find((c) => c.method === "POST");
expect(post?.url).toBe("https://conversations.hasna.xyz/v1/messages");
expect(post?.url).toBe("https://conversations.md/v1/messages");
expect(post?.auth).toBe(`Bearer ${CLOUD_ENV.HASNA_CONVERSATIONS_API_KEY}`);
expect(JSON.parse(post!.body!)).toMatchObject({ from: "alice", to: "bob", content: "hi" });
});
Expand Down
2 changes: 1 addition & 1 deletion src/lib/cloud-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* When the client-flip contract resolves to `cloud-http` — i.e. mode is
* cloud/self_hosted AND `HASNA_CONVERSATIONS_API_URL` +
* `HASNA_CONVERSATIONS_API_KEY` are set — the routed message reads/writes below
* go to `https://conversations.hasna.xyz/v1` with the bearer key instead of the
* go to `https://conversations.md/v1` with the bearer key instead of the
* local SQLite store. Otherwise they fall through to the local implementation in
* `messages.ts`.
*
Expand Down
Loading