diff --git a/chat/db/migrations/0014_phase45_schema.sql b/chat/db/migrations/0014_phase45_schema.sql new file mode 100644 index 0000000..0d7d491 --- /dev/null +++ b/chat/db/migrations/0014_phase45_schema.sql @@ -0,0 +1,25 @@ +-- 0014_phase45_schema.sql — Phase 4.5 Wave 2 schema bump (T109). +-- +-- Two schema concerns are bundled into this migration: +-- +-- 1. ``embeddings.memory_id`` FK should ideally carry ``ON DELETE CASCADE`` +-- (T88 review nit). DEFERRED to Phase 5: ``embeddings`` rows are only ever +-- deleted when the parent ``memories`` row is deleted, and ``memories`` +-- rows are never deleted today (memory hide is a soft flag; the surgical +-- ``deindex_event`` path operates on ``event_log`` and does NOT cascade +-- to projection rows). The CASCADE constraint therefore can't fire under +-- current usage — adding the SQLite table-rebuild dance (rename, recreate, +-- copy, drop, reindex) for a defensive constraint is unwarranted bloat +-- in a polish wave. Revisit during the broader Phase 5 migration cleanup +-- when other table reshapes make the rebuild worthwhile. +-- +-- 2. Add ``memories.event_id`` (NULLABLE INTEGER, references ``event_log.id``) +-- so cross-chat search results can deep-link back to the originating +-- turn (foundation for T111). The column is nullable so historical +-- memory rows projected before 0014 ran continue to round-trip cleanly; +-- new rows are populated by the ``memory_written`` projector handler +-- from the projecting event's id. This is a pure additive change — no +-- backfill is performed. Older rows simply read NULL until/unless a +-- later migration backfills them; T111 surfaces are coded to accept +-- NULL gracefully (no deep-link rendered). +ALTER TABLE memories ADD COLUMN event_id INTEGER REFERENCES event_log(id); diff --git a/chat/state/memory.py b/chat/state/memory.py index a9d62df..9816256 100644 --- a/chat/state/memory.py +++ b/chat/state/memory.py @@ -13,13 +13,18 @@ def _row_to_dict(conn: Connection, row: tuple) -> dict: @on("memory_written") def _apply_memory_written(conn: Connection, e: Event) -> None: + # T109 (schema 0014): persist the projecting event's id on the memory + # row so cross-chat search results can deep-link back to the + # originating turn (T111). Older memory rows projected before 0014 + # ran read NULL here — the column is nullable for that reason. p = e.payload conn.execute( "INSERT INTO memories (" "owner_id, chat_id, scene_id, pov_summary, " "witness_you, witness_host, witness_guest, " - "chat_clock_at, source, reliability, significance, pinned, auto_pinned" - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "chat_clock_at, source, reliability, significance, pinned, auto_pinned, " + "event_id" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( p["owner_id"], p["chat_id"], @@ -34,6 +39,7 @@ def _apply_memory_written(conn: Connection, e: Event) -> None: int(p.get("significance", 1)), int(p.get("pinned", 0)), int(p.get("auto_pinned", 0)), + e.id, ), ) diff --git a/tests/test_memory_write.py b/tests/test_memory_write.py index 3c135a5..0ee9a51 100644 --- a/tests/test_memory_write.py +++ b/tests/test_memory_write.py @@ -586,3 +586,59 @@ def test_record_turn_memory_enqueues_embedding_job(tmp_path): assert {job.memory_id for job in captured} == expected_ids for job in captured: assert job.text == "Both bots witness this beat." + + +# --------------------------------------------------------------------------- +# T109: memories.event_id deep-link column populated by the projector. +# --------------------------------------------------------------------------- + + +def test_memory_written_populates_event_id(tmp_path): + """Schema 0014 added ``memories.event_id`` referencing ``event_log.id``. + + The ``memory_written`` projector handler must populate the column with + the projecting event's id so T111 can deep-link cross-chat search hits + back to the originating turn. + """ + db = tmp_path / "t.db" + apply_migrations(db) + _seed_minimal(db) + with open_db(db) as conn: + result = record_turn_memory_for_present( + conn, + chat_id="chat_bot_a", + host_bot_id="bot_a", + guest_bot_id=None, + narrative_text="BotA shrugs.", + ) + eid, mid = result["bot_a"] + assert eid > 0 and mid is not None + + row = conn.execute( + "SELECT event_id FROM memories WHERE id = ?", (mid,) + ).fetchone() + assert row is not None + assert row[0] == eid + + +def test_memory_event_id_column_is_nullable_for_backfill(tmp_path): + """Backward compat: the ``event_id`` column is nullable so historical + memory rows projected before 0014 ran (or rows synthesised by tests + that bypass the projector) don't break the schema. A direct INSERT + omitting the column must succeed and read back NULL.""" + db = tmp_path / "t.db" + apply_migrations(db) + _seed_minimal(db) + with open_db(db) as conn: + conn.execute( + "INSERT INTO memories (" + "owner_id, chat_id, pov_summary, " + "witness_you, witness_host, witness_guest" + ") VALUES (?, ?, ?, ?, ?, ?)", + ("bot_a", "chat_bot_a", "legacy row", 1, 1, 0), + ) + row = conn.execute( + "SELECT event_id FROM memories WHERE pov_summary = 'legacy row'" + ).fetchone() + assert row is not None + assert row[0] is None diff --git a/tests/test_world.py b/tests/test_world.py index 688b38f..c852852 100644 --- a/tests/test_world.py +++ b/tests/test_world.py @@ -324,11 +324,11 @@ def test_get_scene_returns_none_for_missing(tmp_path): assert active_scene(conn, "chat_missing") is None -def test_schema_version_after_migration_is_13(tmp_path): +def test_schema_version_after_migration_is_14(tmp_path): db = tmp_path / "t.db" apply_migrations(db) with open_db(db) as conn: row = conn.execute( "SELECT value FROM meta WHERE key = 'schema_version'" ).fetchone() - assert int(row[0]) == 13 + assert int(row[0]) == 14