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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,10 @@ 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>"
conversations storage status
conversations storage readiness
conversations storage push
conversations storage pull
conversations storage sync --tables cloud-runtime
```

Production storage for Hasna XYZ uses the `conversations` database on
Expand All @@ -179,6 +181,25 @@ central rollback window closes.

By default, sync only includes
text-key/global tables to avoid local integer ID collisions across machines.
The default table group is `metadata`: projects, channels, channel membership,
channel subscriptions, presence, locks, graph edges, and feedback.

Use `conversations storage readiness --json` to inspect the explicit runtime
path before any remote operation. CLI and MCP commands use local SQLite as the
live store by default. `--tables cloud-runtime` is an opt-in sync group that
adds `messages`, `message_read_receipts`, `channel_notification_reads`,
`message_mentions`, and `reactions`. Message rows are upserted by UUID, and
dependent read-state rows are translated through message UUIDs so local SQLite
integer ids do not become cross-machine identifiers.

Digest cursors and local search continue to use SQLite message ids and FTS5.
The PostgreSQL migration creates a `tsvector` search index for remote readiness,
but live query commands do not switch to remote search unless a remote query
adapter is selected. Attachment files remain local under the conversations
attachments directory; `cloud-runtime` sync omits local attachment metadata and
file paths until an object-store migration exists. S3/AWS object storage,
production writes, secret creation, data migration, terraform apply, or
spend-affecting changes require a separate approval task.

## Data Directory

Expand Down
35 changes: 35 additions & 0 deletions src/cli/storage-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ describe("conversations storage CLI contract", () => {
fallbackEnv: string;
};
configured: boolean;
table_groups: {
cloudRuntime: string[];
localOnly: string[];
};
message_uuid_duplicates: Array<{ uuid: string; count: number }>;
};

expect(info.canonical).toEqual({
Expand All @@ -40,6 +45,35 @@ describe("conversations storage CLI contract", () => {
fallbackEnv: "CONVERSATIONS_DATABASE_URL",
});
expect(info.configured).toBe(false);
expect(info.table_groups.cloudRuntime).toContain("messages");
expect(info.table_groups.cloudRuntime).toContain("message_read_receipts");
expect(info.table_groups.localOnly).toContain("messages_fts");
expect(info.message_uuid_duplicates).toEqual([]);
expect(stdout).not.toContain("postgres://");
});

it("reports storage readiness without printing a URL", () => {
const result = Bun.spawnSync({
cmd: ["bun", "src/cli/index.tsx", "storage", "readiness", "--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",
},
});

expect(result.exitCode).toBe(0);
const stdout = new TextDecoder().decode(result.stdout);
const info = JSON.parse(stdout) as {
runtimePaths: Array<{ surface: string; status: string }>;
privacyAndMigrationGates: string[];
};

expect(info.runtimePaths.find((path) => path.surface === "attachments")?.status).toBe("local_only");
expect(info.privacyAndMigrationGates.join(" ")).toContain("Never print database URLs");
expect(stdout).not.toContain("postgres://");
});

Expand All @@ -66,6 +100,7 @@ describe("conversations storage CLI contract", () => {

expect(source).toContain("registerStorageCommands");
expect(source).toContain('program.command("storage")');
expect(source).toContain('storage.command("readiness")');
expect(source).not.toContain(retiredCloudCommand);
expect(source).not.toContain(retiredCloudExport);
});
Expand Down
33 changes: 32 additions & 1 deletion src/cli/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
getStorageConfig,
getStorageDatabaseUrl,
getStoragePg,
getStorageReadiness,
listConflicts,
listDuplicateMessageUuids,
runStorageMigrations,
storagePull,
storagePush,
Expand All @@ -33,16 +35,22 @@ function installStorageSubcommands(storage: Command): void {
storage.command("status").description("Show remote storage config and sync state").option("--json", "Output as JSON").action((opts: { json?: boolean }) => {
const config = getStorageConfig();
const canonical = getCanonicalConversationsRdsConfig();
const readiness = getStorageReadiness();
const local = getDb();
const unresolved = listConflicts(local, { resolved: false });
const resolved = listConflicts(local, { resolved: true });
const duplicateMessageUuids = listDuplicateMessageUuids(local);
const info = {
mode: config.mode,
configured: Boolean(getStorageDatabaseUrl()),
service: "conversations",
canonical,
tables: DEFAULT_STORAGE_TABLES,
table_groups: readiness.tableGroups,
runtime_paths: readiness.runtimePaths,
privacy_and_migration_gates: readiness.privacyAndMigrationGates,
conflicts: { unresolved: unresolved.length, resolved: resolved.length },
message_uuid_duplicates: duplicateMessageUuids,
};
if (opts.json) { printJson(info); return; }
console.log(`Mode: ${info.mode}`);
Expand All @@ -52,8 +60,31 @@ function installStorageSubcommands(storage: Command): void {
console.log(`Runtime secret path: ${canonical.runtimeSecretPath}`);
console.log(`Database env: ${canonical.env} (fallback: ${canonical.fallbackEnv})`);
console.log(`Remote storage configured: ${info.configured ? "yes" : "no"}`);
console.log(`Tables: ${info.tables.join(", ")}`);
console.log(`Default sync group: metadata (${info.tables.join(", ")})`);
console.log(`Cloud runtime group: cloud-runtime (${readiness.tableGroups.cloudRuntime.join(", ")})`);
console.log("Attachments: local files only; S3 object storage is an approval-gated follow-up.");
console.log(`Sync conflicts: ${info.conflicts.unresolved} unresolved, ${info.conflicts.resolved} resolved`);
console.log(`Message UUID duplicates: ${duplicateMessageUuids.length}`);
});

storage.command("readiness").description("Show local/remote storage runtime readiness and migration gates").option("--json", "Output as JSON").action((opts: { json?: boolean }) => {
const readiness = getStorageReadiness();
if (opts.json) { printJson(readiness); return; }
console.log(`Mode: ${readiness.mode}`);
console.log(`Remote storage configured: ${readiness.configured ? "yes" : "no"}`);
console.log(`Local SQLite: ${readiness.local.sqlite}`);
console.log(`Local attachments: ${readiness.local.attachments}`);
console.log(`Default sync group: metadata (${readiness.tableGroups.default.join(", ")})`);
console.log(`Cloud runtime group: cloud-runtime (${readiness.tableGroups.cloudRuntime.join(", ")})`);
for (const path of readiness.runtimePaths) {
console.log(`\n${path.surface}: ${path.status}`);
console.log(` local: ${path.local}`);
console.log(` remote: ${path.remote}`);
if (path.tables.length > 0) console.log(` tables: ${path.tables.join(", ")}`);
if (path.gates.length > 0) console.log(` gates: ${path.gates.join("; ")}`);
}
console.log("\nPrivacy and migration gates:");
for (const gate of readiness.privacyAndMigrationGates) console.log(` - ${gate}`);
});

storage.command("push").description("Push safe local conversations tables to remote PostgreSQL storage").option("--tables <tables>", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts: { tables?: string; json?: boolean }) => {
Expand Down
29 changes: 29 additions & 0 deletions src/lib/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { getDb, getDbPath, closeDb } from "./db";
import { createChannel, getChannel } from "./channels";
import { sendMessage } from "./messages";
import { listDuplicateMessageUuids } from "./storage-sync";
import { unlinkSync } from "fs";
import { Database } from "bun:sqlite";
import { tmpdir } from "os";
Expand Down Expand Up @@ -95,6 +96,34 @@ describe("db", () => {
expect(colNames).toContain("channel");
});

test("keeps duplicate UUID preflight diagnosable instead of crashing on unique index creation", () => {
closeDb();
const legacyDb = new Database(TEST_DB);
legacyDb.exec(`
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
from_agent TEXT NOT NULL,
to_agent TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
uuid TEXT
);
INSERT INTO messages (session_id, from_agent, to_agent, content, uuid)
VALUES
('dm:a:b', 'a', 'b', 'first', 'duplicate-uuid'),
('dm:a:b', 'a', 'b', 'second', 'duplicate-uuid');
`);
legacyDb.close();

const db = getDb();
expect(listDuplicateMessageUuids(db)).toEqual([{ uuid: "duplicate-uuid", count: 2 }]);
const uniqueIndex = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_messages_uuid'").get();
expect(uniqueIndex).toBeNull();
const preflightIndex = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_messages_uuid_preflight'").get() as { name: string } | null;
expect(preflightIndex?.name).toBe("idx_messages_uuid_preflight");
});

test("migrates legacy channel messages before creating channel indexes", () => {
closeDb();
const legacyDb = new Database(TEST_DB);
Expand Down
20 changes: 20 additions & 0 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,27 @@ export function getDb(): Database {
db.exec("ALTER TABLE messages ADD COLUMN uuid TEXT");
// Backfill existing rows with unique UUIDs
db.exec("UPDATE messages SET uuid = lower(hex(randomblob(16))) WHERE uuid IS NULL");
}
const missingUuidCount = db.get<{ count: number }>(
"SELECT COUNT(*) AS count FROM messages WHERE uuid IS NULL OR uuid = ''"
)?.count ?? 0;
if (missingUuidCount > 0) {
db.exec("UPDATE messages SET uuid = lower(hex(randomblob(16))) WHERE uuid IS NULL OR uuid = ''");
}
const duplicateUuidCount = db.get<{ count: number }>(`
SELECT COUNT(*) AS count
FROM (
SELECT uuid
FROM messages
WHERE uuid IS NOT NULL AND uuid <> ''
GROUP BY uuid
HAVING COUNT(*) > 1
)
`)?.count ?? 0;
if (duplicateUuidCount === 0) {
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid)");
} else {
db.exec("CREATE INDEX IF NOT EXISTS idx_messages_uuid_preflight ON messages(uuid)");
}

// Migrate agent_presence: add id, session_id, role, created_at columns
Expand Down
2 changes: 2 additions & 0 deletions src/lib/pg-migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ describe("PG_MIGRATIONS", () => {
const sql = PG_MIGRATIONS[0].toLowerCase();
const pgcrypto = sql.indexOf("create extension if not exists pgcrypto");
const uuidDefault = sql.indexOf("gen_random_uuid()");
const uuidIndex = sql.indexOf("idx_messages_uuid");
expect(pgcrypto).toBeGreaterThan(-1);
expect(uuidDefault).toBeGreaterThan(pgcrypto);
expect(uuidIndex).toBeGreaterThan(uuidDefault);
});

test("legacy PostgreSQL spaces are imported before legacy storage is dropped", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/pg-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export const PG_MIGRATIONS: string[] = [
read_at TEXT
);
ALTER TABLE messages ADD COLUMN IF NOT EXISTS uuid TEXT DEFAULT gen_random_uuid()::text;
UPDATE messages SET uuid = gen_random_uuid()::text WHERE uuid IS NULL OR uuid = '';
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uuid ON messages(uuid);
ALTER TABLE messages ADD COLUMN IF NOT EXISTS channel TEXT;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS project_id TEXT;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS edited_at TEXT;
Expand Down
Loading