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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **BREAKING: deployment modes are gone; hooks storage is a two-value data-backend switch.** `StorageMode = "local" | "hybrid" | "remote"` described *where* something ran, which was never a property of the data layer, and nothing in the codebase ever branched on it — it was reported by `hooks storage status` and the `storage_status` MCP tool and otherwise decorative. It is replaced by `StorageBackend = "sqlite" | "postgresql"`.
- `HASNA_HOOKS_STORAGE_MODE` and `HOOKS_STORAGE_MODE` are retired and are **no longer read**. Setting either now raises an error naming the replacement variable and the backend to use, instead of being quietly ignored: `local` became `sqlite`, and `hybrid` / `remote` / `self_hosted` / `self-hosted` / `cloud` all became `postgresql`.
- New `HASNA_HOOKS_STORAGE_BACKEND` (fallback `HOOKS_STORAGE_BACKEND`) accepts `sqlite` or `postgresql` (`sqlite3`, `postgres` and `pg` are accepted aliases). **An unrecognised value now throws.** Previously any unknown value — including a typo — fell through `normalizeStorageMode` to `undefined` and then silently to `local`, so a misconfigured mode looked like a working local one. That silent normalisation was the actual defect; the vocabulary was its symptom.
- Backend inference is unchanged: with the variable unset, a configured `HASNA_HOOKS_DATABASE_URL` / `HOOKS_DATABASE_URL` yields `postgresql` (previously reported as `hybrid`) and its absence yields `sqlite` (previously `local`).
- `StorageStatus.mode` is renamed to `StorageStatus.backend`, and `hooks storage status` prints `Backend:` in place of `Mode:`. Removed from the package's public exports: `StorageMode`, `getStorageMode`, `HOOKS_STORAGE_MODE_ENV`, `HOOKS_STORAGE_MODE_FALLBACK_ENV`, `STORAGE_MODE_ENV`. Added: `StorageBackend`, `getStorageBackend`, `STORAGE_BACKENDS`, `HOOKS_STORAGE_BACKEND_ENV`, `HOOKS_STORAGE_BACKEND_FALLBACK_ENV`, `STORAGE_BACKEND_ENV`, `RETIRED_STORAGE_MODE_ENV`.
- Hook evaluation is untouched: no hook, and no part of the prompt path, reads the backend. `getStorageBackend()` is reached only from `getStorageStatus()`.

### Fixed

- **`pre-bash` / `worktree-guard` destructive-shell guard no longer lets a filesystem-root wipe through.** `rm -rf /*` and `rm -rf "$(cmd)"/*` both returned `{"continue":true}` before this change; only `rm -rf /` blocked, and only incidentally, because `~/.hasna` sits under it. Two complementary rules close the class:
Expand Down
36 changes: 28 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ hooks install knowledge-context --target codewith --apply-codewith --codewith-co
Hooks stores data locally by default in `~/.hasna/hooks/` and uses SQLite
directly for hook event history. The package owns its database schema and
migrations; it does not depend on the deprecated shared runtime or its CLI.
The repo includes its own PostgreSQL migration definitions for optional remote
storage deployments. Use the `hooks log` commands to inspect local hook event
data.
The repo includes its own PostgreSQL migration definitions for the optional
`hooks storage push|pull|sync` commands. Use the `hooks log` commands to inspect
local hook event data.

```bash
hooks storage status --json
Expand All @@ -107,14 +107,34 @@ hooks storage sync --json
```

Configure database storage with `HASNA_HOOKS_DATABASE_URL` or fallback
`HOOKS_DATABASE_URL`. Optional storage mode env vars are
`HASNA_HOOKS_STORAGE_MODE` and `HOOKS_STORAGE_MODE`, with `local`, `hybrid`, or
`remote` values.
`HOOKS_DATABASE_URL`.

### Storage backend

Hooks storage has one setting with two values: **which data backend**, not where
anything is deployed.

| `HASNA_HOOKS_STORAGE_BACKEND` (fallback `HOOKS_STORAGE_BACKEND`) | meaning |
| --- | --- |
| `sqlite` | the on-box SQLite file in `~/.hasna/hooks/` (default) |
| `postgresql` | the PostgreSQL database named by `HASNA_HOOKS_DATABASE_URL` |

Leave it unset and the backend is inferred exactly as before: `postgresql` when a
database URL is configured, `sqlite` otherwise. An unrecognised value is an
error, not a silent fall back to SQLite.

The former deployment-mode variables `HASNA_HOOKS_STORAGE_MODE` and
`HOOKS_STORAGE_MODE`, and their `local` / `hybrid` / `remote` / `self-hosted` /
`cloud` values, are **retired**. They are not read; setting one raises an error
naming the replacement variable and the backend to use (`local` became `sqlite`,
everything else became `postgresql`). Deployment location was never a property of
the data layer, so it is no longer expressed as one.

## Runtime model

This package is an npm/local CLI, MCP server, and static dashboard package. It
does not require a deployed cloud or self-hosted runtime to install or run hooks.
This package is an npm CLI, MCP server, and static dashboard package. Installing
and running hooks needs nothing deployed anywhere — the SQLite backend is the
default and requires no server.

## Data Directory

Expand Down
2 changes: 1 addition & 1 deletion src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1227,7 +1227,7 @@ storageCmd
}
console.log(chalk.bold("\n Storage Status\n"));
console.log(` Configured: ${status.configured ? chalk.green(`yes (${status.activeEnv})`) : chalk.red("no")}`);
console.log(` Mode: ${status.mode}`);
console.log(` Backend: ${status.backend}`);
console.log(` Tables: ${status.tables.join(", ")}`);
console.log(` Sync rows: ${status.sync.length}`);
});
Expand Down
2 changes: 1 addition & 1 deletion src/db/pg-migrations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* PostgreSQL migrations for open-hooks remote storage and sync.
* PostgreSQL migrations for the open-hooks PostgreSQL storage backend and sync.
*
* Equivalent to the SQLite schema in schema.ts, migrations/, and index.ts,
* translated for PostgreSQL.
Expand Down
110 changes: 95 additions & 15 deletions src/db/storage-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@ export const HOOKS_STORAGE_TABLES = STORAGE_TABLES;
type StorageTable = (typeof STORAGE_TABLES)[number];
type Row = Record<string, unknown>;

export type StorageMode = "local" | "hybrid" | "remote";
/**
* The storage backend is a two-value data-backend switch, NOT a deployment mode.
*
* `local | hybrid | remote` (and the wider fleet's `self_hosted | cloud`) described *where*
* something ran, which is not a property of the data layer. They are retired: local collapses
* to `sqlite`, and every server-backed placement collapses to `postgresql`.
*/
export const STORAGE_BACKENDS = ["sqlite", "postgresql"] as const;

export type StorageBackend = (typeof STORAGE_BACKENDS)[number];

export interface StorageEnv {
name: string;
Expand All @@ -36,14 +45,21 @@ export interface SyncMeta {

export const HOOKS_STORAGE_ENV = "HASNA_HOOKS_DATABASE_URL";
export const HOOKS_STORAGE_FALLBACK_ENV = "HOOKS_DATABASE_URL";
export const HOOKS_STORAGE_MODE_ENV = "HASNA_HOOKS_STORAGE_MODE";
export const HOOKS_STORAGE_MODE_FALLBACK_ENV = "HOOKS_STORAGE_MODE";
export const HOOKS_STORAGE_BACKEND_ENV = "HASNA_HOOKS_STORAGE_BACKEND";
export const HOOKS_STORAGE_BACKEND_FALLBACK_ENV = "HOOKS_STORAGE_BACKEND";
export const STORAGE_DATABASE_ENV = [HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV] as const;
export const STORAGE_MODE_ENV = [HOOKS_STORAGE_MODE_ENV, HOOKS_STORAGE_MODE_FALLBACK_ENV] as const;
export const STORAGE_BACKEND_ENV = [HOOKS_STORAGE_BACKEND_ENV, HOOKS_STORAGE_BACKEND_FALLBACK_ENV] as const;

/**
* Deployment-mode env vars that no longer exist. Reading one is an error rather than a no-op:
* an operator who set `HASNA_HOOKS_STORAGE_MODE=hybrid` believed they had configured something,
* and silently ignoring it is how a config change appears to work and does not.
*/
export const RETIRED_STORAGE_MODE_ENV = ["HASNA_HOOKS_STORAGE_MODE", "HOOKS_STORAGE_MODE"] as const;

export interface StorageStatus {
configured: boolean;
mode: StorageMode;
backend: StorageBackend;
env: typeof STORAGE_DATABASE_ENV;
activeEnv: string | null;
service: "hooks";
Expand All @@ -63,10 +79,67 @@ function readEnv(name: string): string | undefined {
return value || undefined;
}

function normalizeStorageMode(value: string | undefined): StorageMode | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "local" || normalized === "hybrid" || normalized === "remote") return normalized;
return undefined;
/**
* Retired deployment-mode values mapped to the backend that replaced them. `local` was the
* on-box SQLite file; every other placement — hybrid, remote, self-hosted, cloud — was a
* server holding the data in PostgreSQL.
*/
const RETIRED_MODE_REPLACEMENT: Record<string, StorageBackend> = {
local: "sqlite",
hybrid: "postgresql",
remote: "postgresql",
self_hosted: "postgresql",
"self-hosted": "postgresql",
selfhosted: "postgresql",
cloud: "postgresql",
};

const BACKEND_ALIASES: Record<string, StorageBackend> = {
sqlite: "sqlite",
sqlite3: "sqlite",
postgresql: "postgresql",
postgres: "postgresql",
pg: "postgresql",
};

function assertNoRetiredModeEnv(): void {
for (const name of RETIRED_STORAGE_MODE_ENV) {
const value = readEnv(name);
if (!value) continue;
const replacement = RETIRED_MODE_REPLACEMENT[value.trim().toLowerCase()];
const mapping = replacement
? `${value} maps to ${replacement}`
: `use one of ${STORAGE_BACKENDS.join(", ")}`;
throw new Error(
`${name} is a retired deployment-mode variable and is no longer read. `
+ `Hooks storage is a data-backend switch, not a deployment mode: `
+ `set ${HOOKS_STORAGE_BACKEND_ENV} to ${STORAGE_BACKENDS.join(" or ")} instead (${mapping}), `
+ `then unset ${name}.`,
);
}
}

function normalizeStorageBackend(value: string | undefined, envName: string): StorageBackend | undefined {
if (value === undefined) return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === "") return undefined;

const backend = BACKEND_ALIASES[normalized];
if (backend) return backend;

const replacement = RETIRED_MODE_REPLACEMENT[normalized];
if (replacement) {
throw new Error(
`${envName}=${value} names a retired deployment mode. `
+ `local/hybrid/remote/self_hosted/cloud were removed: hooks storage now selects a data `
+ `backend only. Set ${envName}=${replacement} instead.`,
);
}

throw new Error(
`${envName}=${value} is not a known hooks storage backend. `
+ `Set ${HOOKS_STORAGE_BACKEND_ENV} to one of ${STORAGE_BACKENDS.join(", ")}.`,
);
}

export function getStorageDatabaseEnvName(): (typeof STORAGE_DATABASE_ENV)[number] | null {
Expand All @@ -86,11 +159,18 @@ export function getStorageDatabaseUrl(): string | null {
return env ? readEnv(env.name) ?? null : null;
}

export function getStorageMode(): StorageMode {
const mode = normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_ENV))
?? normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_FALLBACK_ENV));
if (mode) return mode;
return getStorageDatabaseUrl() ? "hybrid" : "local";
/**
* Which data backend hooks storage talks to. Explicit configuration wins; otherwise the
* presence of a database URL is the answer, exactly as before.
*
* Throws — never silently falls back — on an unknown value or a retired deployment-mode name.
*/
export function getStorageBackend(): StorageBackend {
assertNoRetiredModeEnv();
const backend = normalizeStorageBackend(readEnv(HOOKS_STORAGE_BACKEND_ENV), HOOKS_STORAGE_BACKEND_ENV)
?? normalizeStorageBackend(readEnv(HOOKS_STORAGE_BACKEND_FALLBACK_ENV), HOOKS_STORAGE_BACKEND_FALLBACK_ENV);
if (backend) return backend;
return getStorageDatabaseUrl() ? "postgresql" : "sqlite";
}

export async function getStoragePg(): Promise<PgAdapterAsync> {
Expand Down Expand Up @@ -154,7 +234,7 @@ export function getStorageStatus(): StorageStatus {
const activeEnv = getStorageDatabaseEnv();
return {
configured: Boolean(activeEnv),
mode: getStorageMode(),
backend: getStorageBackend(),
env: STORAGE_DATABASE_ENV,
activeEnv: activeEnv?.name ?? null,
service: "hooks",
Expand Down
12 changes: 7 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,16 +190,18 @@ export {
export {
HOOKS_STORAGE_ENV,
HOOKS_STORAGE_FALLBACK_ENV,
HOOKS_STORAGE_MODE_ENV,
HOOKS_STORAGE_MODE_FALLBACK_ENV,
HOOKS_STORAGE_BACKEND_ENV,
HOOKS_STORAGE_BACKEND_FALLBACK_ENV,
HOOKS_STORAGE_TABLES,
RETIRED_STORAGE_MODE_ENV,
STORAGE_BACKENDS,
STORAGE_BACKEND_ENV,
STORAGE_DATABASE_ENV,
STORAGE_MODE_ENV,
STORAGE_TABLES,
getStorageBackend,
getStorageDatabaseEnv,
getStorageDatabaseEnvName,
getStorageDatabaseUrl,
getStorageMode,
getStoragePg,
getStorageStatus,
getSyncMetaAll,
Expand All @@ -210,4 +212,4 @@ export {
storagePush,
storageSync,
} from "./storage.js";
export type { StorageEnv, StorageMode, StorageStatus, SyncMeta, SyncResult } from "./storage.js";
export type { StorageBackend, StorageEnv, StorageStatus, SyncMeta, SyncResult } from "./storage.js";
Loading
Loading