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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
embed model registry, degrade metrics, FTS helpers), the test fakes, and
`resolveGrantConfig` are no longer exported. The distiller stays at
`@corbits/memory/distiller` and migrations at `@corbits/memory/migrations`.
- `runMemoryMigrations(config, { schema, ftsLanguage })` takes the same
`DBConfig` as Interchange `runMigrations` instead of a database URL.
`schema` names the host schema holding Interchange's `tenant` and
`principal` tables (the value passed to `runMigrations`, e.g. `"public"`);
memory's tables stay in the `memory` schema. `ftsLanguage` is required, and
the runner no longer reads `FTS_LANGUAGE` from the environment or accepts a `log` option.
Every migration file is idempotent and replayed on each run. The
`memory._migrations` ledger is dropped.

### Added

Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ bun run typecheck && bun run test

`bun run typecheck` (`tsc --noEmit`) must be clean before any commit.

## Migrations

`runMemoryMigrations` replays every file in `migrations/` on each run, so
every file must be idempotent. Never change a shipped file's effect on an
existing database: a changed constraint or a new column goes in a new
numbered file, because a guarded `ADD CONSTRAINT` keeps the old definition.

## Branch and PR conventions

- Branch off `main`; open PRs against `main`.
Expand Down
12 changes: 6 additions & 6 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ src/
http-client.ts # host-side HTTP client for tenant routes (imperative distill tick)

log.ts # getLogger(["memory"]) from @intx/log
migrations.ts # runMemoryMigrations(url)
migrations.ts # runMemoryMigrations(dbConfig, { schema, ftsLanguage })
ports/ # DocumentStore / SourceProvider + fakes
routes/ # the mounted tenant routes
mount.ts # createMemoryRoutes (HTTP sub-app)
Expand Down Expand Up @@ -49,7 +49,7 @@ src/
# @corbits/supermemory-memory-adapter → github.com/corbitsdev/corbits-supermemory-memory-adapter
# @corbits/linear-tools → github.com/corbitsdev/corbits-linear-tools
migrations/ # pgvector schema, applied in filename order by scripts/db-setup.ts
scripts/db-setup.ts # idempotent migration runner, tracked in `_migrations`
scripts/db-setup.ts # runs the idempotent migrations against DATABASE_URL
compose.yml # pgvector + Ollama + reranker for local dev
```

Expand Down Expand Up @@ -150,8 +150,8 @@ egress control front the endpoints with an allowlisting proxy.
## Data model (`src/db/schema.ts` + `migrations/*.sql`)

All tables are Drizzle-defined in `db/schema.ts`, DDL'd in `migrations/`
(applied by `scripts/db-setup.ts`, tracked in a `_migrations` ledger table so
re-running is a no-op). No memory table has a foreign key into any
(applied by `runMemoryMigrations`; every file is idempotent and replayed on
each run, so there is no ledger). No memory table has a foreign key into any
control-plane table — `tenant_id`/`principal_id`/source refs are plain `text`.

### `memory_document`
Expand Down Expand Up @@ -206,8 +206,8 @@ fresh full insert of its own chunks.

**Changing `FTS_LANGUAGE` on an already-migrated database** (the mismatch
`verifyFtsLanguage` throws on) requires rebuilding the generated column —
`runMemoryMigrations` only applies new files and will not retroactively
alter an existing one. One-time recipe (verified against a live
`runMemoryMigrations` adds the column only if it is missing and will not
retroactively alter an existing one. One-time recipe (verified against a live
`postgres:16` instance):

```sql
Expand Down
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ never creates one; it mounts onto yours.
| `createMemoryRoutes({ memory, requireGrant })` | Hono sub-app the host mounts at `/api/tenants/:tenantId/memory` |
| `mountWorkflowMemory(app, { memory, agentToken })` | Parallel run-scoped `/api/workflow-memory/*` for deployed agents |
| `loadMemoryConfig()` | Config from env |
| `runMemoryMigrations(url)` | Apply pgvector schema |
| `runMemoryMigrations(dbConfig, { schema, ftsLanguage })` | Apply pgvector schema |
| `@corbits/memory/sidecar-bundle` | Deployed-agent factory — no client code, no base URL, no token |
| `@corbits/memory/distiller` | Optional process helpers: `runDistillTick`, `createResidentDistiller` |

Expand Down
15 changes: 5 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,15 @@ Mount `installMemory` below the middleware that sets `principal`/`tenant`
does). Identity comes from `c.get("principal")` — request bodies never
carry tenant or principal. Missing principal → 401, missing grant → 403.

Apply migrations before serving traffic:
Apply migrations before serving traffic, with the same `DBConfig` and
`schema` your hub passes to Interchange `runMigrations`. Memory's tables land
in their own `memory` schema, with foreign keys into that schema's `tenant`
and `principal` tables:

```ts
import { runMemoryMigrations } from "@corbits/memory/migrations";

export async function migrateMemory(databaseUrl: string): Promise<void> {
await runMemoryMigrations(databaseUrl);
}

const databaseUrl = process.env.DATABASE_URL;
if (databaseUrl === undefined) {
throw new Error("DATABASE_URL is required to run memory migrations");
}
await migrateMemory(databaseUrl);
await runMemoryMigrations(dbConfig, { schema: "public", ftsLanguage: "english" });
```

`loadMemoryConfig()` reads `DATABASE_URL` (required — tables live in a
Expand Down
2 changes: 2 additions & 0 deletions bun.lock

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

4 changes: 2 additions & 2 deletions migrations/0002_memory_baseline.sql
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ CREATE TABLE IF NOT EXISTS "memory"."chunk" (
CREATE UNIQUE INDEX IF NOT EXISTS "chunk_version_ordinal_uniq"
ON "memory"."chunk" ("version_id", "ordinal");

-- {{FTS_LANGUAGE}} is substituted by runMemoryMigrations from FTS_LANGUAGE
-- (or opts.ftsLanguage). Must match the language used at query time.
-- {{FTS_LANGUAGE}} is substituted by runMemoryMigrations from its
-- ftsLanguage option. Must match the language used at query time.
ALTER TABLE "memory"."chunk"
ADD COLUMN IF NOT EXISTS "text_fts" tsvector
GENERATED ALWAYS AS (to_tsvector('{{FTS_LANGUAGE}}', "text")) STORED;
Expand Down
17 changes: 11 additions & 6 deletions migrations/0003_claim_bearing.sql
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@
ALTER TABLE "memory"."version"
ADD COLUMN IF NOT EXISTS "provenance" text NOT NULL DEFAULT 'unknown';

ALTER TABLE "memory"."version"
DROP CONSTRAINT IF EXISTS "version_provenance_check";

ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_provenance_check"
CHECK ("provenance" IN ('stated', 'inferred', 'unknown'));
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'version_provenance_check' AND conrelid = '"memory"."version"'::regclass
) THEN
ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_provenance_check"
CHECK ("provenance" IN ('stated', 'inferred', 'unknown'));
END IF;
END $$;
41 changes: 28 additions & 13 deletions migrations/0004_temporal_model.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,39 @@
-- See docs/TEMPORAL.md. No asserted_at — occurred_at is effective time;
-- ingested_at is when the memory plane learned the content.

ALTER TABLE "memory"."version"
ADD COLUMN IF NOT EXISTS "temporal_class" text NOT NULL DEFAULT 'event';

ALTER TABLE "memory"."version"
DROP CONSTRAINT IF EXISTS "version_temporal_class_check";
-- The backfill runs only in the replay that adds the column, so distilled
-- claims (inferred provenance) written before this migration default to
-- state ranking exactly once; later inferred `event` rows are left alone.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'memory' AND table_name = 'version'
AND column_name = 'temporal_class'
) THEN
ALTER TABLE "memory"."version"
ADD COLUMN "temporal_class" text NOT NULL DEFAULT 'event';
UPDATE "memory"."version"
SET "temporal_class" = 'state'
WHERE "provenance" = 'inferred';
END IF;
END $$;

ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_temporal_class_check"
CHECK ("temporal_class" IN ('event', 'deadline', 'state', 'lesson'));
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'version_temporal_class_check' AND conrelid = '"memory"."version"'::regclass
) THEN
ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_temporal_class_check"
CHECK ("temporal_class" IN ('event', 'deadline', 'state', 'lesson'));
END IF;
END $$;

ALTER TABLE "memory"."version"
ADD COLUMN IF NOT EXISTS "valid_from" timestamp;

ALTER TABLE "memory"."version"
ADD COLUMN IF NOT EXISTS "valid_until" timestamp;

-- Distilled claims (inferred provenance) default to state ranking.
UPDATE "memory"."version"
SET "temporal_class" = 'state'
WHERE "provenance" = 'inferred'
AND "temporal_class" = 'event';
17 changes: 11 additions & 6 deletions migrations/0007_retention.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
ALTER TABLE "memory"."version"
ADD COLUMN IF NOT EXISTS "retention_class" text NOT NULL DEFAULT 'standard';

ALTER TABLE "memory"."version"
DROP CONSTRAINT IF EXISTS "version_retention_class_check";

ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_retention_class_check"
CHECK ("retention_class" IN ('durable', 'standard', 'ephemeral', 'source_only'));
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'version_retention_class_check' AND conrelid = '"memory"."version"'::regclass
) THEN
ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_retention_class_check"
CHECK ("retention_class" IN ('durable', 'standard', 'ephemeral', 'source_only'));
END IF;
END $$;

CREATE INDEX IF NOT EXISTS "version_retention_ephemeral_idx"
ON "memory"."version" ("tenant_id", "retention_class", "valid_until")
Expand Down
152 changes: 122 additions & 30 deletions migrations/0008_tenant_principal_fks.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,135 @@
-- not exist. Tenant deletion cascades through this package's data;
-- principal deletion cascades only the attribution column it owns
-- (memory.version.created_by_principal_id), never a whole tenant's memory.
-- Each constraint is added only when missing, so re-running is a no-op; a
-- constraint that points at a different host schema fails the replay loudly.

ALTER TABLE "memory"."document"
ADD CONSTRAINT "document_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'document_tenant_id_fkey' AND conrelid = '"memory"."document"'::regclass
AND confrelid = '"public"."tenant"'::regclass
) THEN
ALTER TABLE "memory"."document"
ADD CONSTRAINT "document_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
END IF;
END $$;

ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'version_tenant_id_fkey' AND conrelid = '"memory"."version"'::regclass
AND confrelid = '"public"."tenant"'::regclass
) THEN
ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
END IF;
END $$;

ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_created_by_principal_id_fkey"
FOREIGN KEY ("created_by_principal_id") REFERENCES "public"."principal"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'version_created_by_principal_id_fkey' AND conrelid = '"memory"."version"'::regclass
AND confrelid = '"public"."principal"'::regclass
) THEN
ALTER TABLE "memory"."version"
ADD CONSTRAINT "version_created_by_principal_id_fkey"
FOREIGN KEY ("created_by_principal_id") REFERENCES "public"."principal"("id") ON DELETE CASCADE;
END IF;
END $$;

ALTER TABLE "memory"."chunk"
ADD CONSTRAINT "chunk_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'chunk_tenant_id_fkey' AND conrelid = '"memory"."chunk"'::regclass
AND confrelid = '"public"."tenant"'::regclass
) THEN
ALTER TABLE "memory"."chunk"
ADD CONSTRAINT "chunk_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
END IF;
END $$;

ALTER TABLE "memory"."entity"
ADD CONSTRAINT "entity_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'entity_tenant_id_fkey' AND conrelid = '"memory"."entity"'::regclass
AND confrelid = '"public"."tenant"'::regclass
) THEN
ALTER TABLE "memory"."entity"
ADD CONSTRAINT "entity_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
END IF;
END $$;

ALTER TABLE "memory"."edge"
ADD CONSTRAINT "edge_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'edge_tenant_id_fkey' AND conrelid = '"memory"."edge"'::regclass
AND confrelid = '"public"."tenant"'::regclass
) THEN
ALTER TABLE "memory"."edge"
ADD CONSTRAINT "edge_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
END IF;
END $$;

ALTER TABLE "memory"."raw_capture"
ADD CONSTRAINT "raw_capture_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'raw_capture_tenant_id_fkey' AND conrelid = '"memory"."raw_capture"'::regclass
AND confrelid = '"public"."tenant"'::regclass
) THEN
ALTER TABLE "memory"."raw_capture"
ADD CONSTRAINT "raw_capture_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
END IF;
END $$;

ALTER TABLE "memory"."embed_model"
ADD CONSTRAINT "embed_model_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'embed_model_tenant_id_fkey' AND conrelid = '"memory"."embed_model"'::regclass
AND confrelid = '"public"."tenant"'::regclass
) THEN
ALTER TABLE "memory"."embed_model"
ADD CONSTRAINT "embed_model_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
END IF;
END $$;

ALTER TABLE "memory"."transform_config"
ADD CONSTRAINT "transform_config_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'transform_config_tenant_id_fkey' AND conrelid = '"memory"."transform_config"'::regclass
AND confrelid = '"public"."tenant"'::regclass
) THEN
ALTER TABLE "memory"."transform_config"
ADD CONSTRAINT "transform_config_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
END IF;
END $$;

ALTER TABLE "memory"."transform_run"
ADD CONSTRAINT "transform_run_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'transform_run_tenant_id_fkey' AND conrelid = '"memory"."transform_run"'::regclass
AND confrelid = '"public"."tenant"'::regclass
) THEN
ALTER TABLE "memory"."transform_run"
ADD CONSTRAINT "transform_run_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE CASCADE;
END IF;
END $$;
2 changes: 2 additions & 0 deletions migrations/0009_drop_migrations_ledger.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Drops the ledger table earlier versions of runMemoryMigrations kept.
DROP TABLE IF EXISTS "memory"."_migrations";
Loading
Loading