From b712b5a1542d64bd175ced437050c663c1ba5931 Mon Sep 17 00:00:00 2001 From: Hary Ahuja Date: Thu, 22 Oct 2026 18:55:33 +0530 Subject: [PATCH 1/8] chore: backfill tfy.source=ui metadata on existing interactive sessions Co-authored-by: Cursor --- .changeset/session-tfy-source-backfill.md | 5 ++ ...1022_000001_session_tfy_source_backfill.ts | 26 ++++++ ...1022_000001_session_tfy_source_backfill.ts | 23 +++++ ...000001_session_tfy_source_backfill.test.ts | 37 +++++++++ .../tests/db/sessionTfySourceBackfillSuite.ts | 83 +++++++++++++++++++ ...000001_session_tfy_source_backfill.test.ts | 28 +++++++ 6 files changed, 202 insertions(+) create mode 100644 .changeset/session-tfy-source-backfill.md create mode 100644 packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.ts create mode 100644 packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.ts create mode 100644 packages/trueforge/tests/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.test.ts create mode 100644 packages/trueforge/tests/db/sessionTfySourceBackfillSuite.ts create mode 100644 packages/trueforge/tests/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.test.ts diff --git a/.changeset/session-tfy-source-backfill.md b/.changeset/session-tfy-source-backfill.md new file mode 100644 index 0000000000..f3d3eed57e --- /dev/null +++ b/.changeset/session-tfy-source-backfill.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge": patch +--- + +Backfill `tfy.source: ui` session metadata on existing non-scheduled sessions so they stay in Chat History once it filters on that key. diff --git a/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.ts b/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.ts new file mode 100644 index 0000000000..3b62da93ce --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.ts @@ -0,0 +1,26 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * Stamp `metadata["tfy.source"] = "ui"` on pre-existing interactive sessions so Chat History, + * which now lists only sessions carrying that key, keeps showing them. + * + * Schedule runs are the only sessions with `external_id`; checking it alongside `source` also + * skips schedule runs created before `source` existed. `updated_at` is left untouched so list + * ordering does not change. + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql` + UPDATE session + SET metadata = metadata || jsonb_build_object('tfy.source', 'ui') + WHERE source IS NULL + AND external_id IS NULL + AND metadata->'tfy.source' IS NULL + `.execute(db); +} + +/** Data backfill — not reversed: backfilled rows are indistinguishable from UI-stamped ones. */ +export function down(db: Kysely): Promise { + void db; + return Promise.reject(new Error('20261022_000001_session_tfy_source_backfill is irreversible')); +} diff --git a/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.ts b/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.ts new file mode 100644 index 0000000000..e9fd258978 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.ts @@ -0,0 +1,23 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * SQLite mirror of postgres/migrations/20261022_000001_session_tfy_source_backfill.ts. + * The key contains a dot, so JSON paths must quote it. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql` + UPDATE session + SET metadata = jsonb_set(metadata, '$."tfy.source"', 'ui') + WHERE source IS NULL + AND external_id IS NULL + AND json_extract(metadata, '$."tfy.source"') IS NULL + `.execute(trx); + }); +} + +/** Data backfill — not reversed: backfilled rows are indistinguishable from UI-stamped ones. */ +export function down(db: Kysely): Promise { + void db; + return Promise.reject(new Error('20261022_000001_session_tfy_source_backfill is irreversible')); +} diff --git a/packages/trueforge/tests/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.test.ts b/packages/trueforge/tests/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.test.ts new file mode 100644 index 0000000000..50f39657b8 --- /dev/null +++ b/packages/trueforge/tests/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.test.ts @@ -0,0 +1,37 @@ +import { up } from '../../../../src/db/postgres/migrations/20261022_000001_session_tfy_source_backfill'; +import { PostgresSessionStore } from '../../../../src/db/postgres/session-store/PostgresSessionStore'; +import { runSessionTfySourceBackfillSuite } from '../../sessionTfySourceBackfillSuite'; +import { createPostgresTestDatabase, type PostgresTestDatabase } from '../testDatabase'; + +const describePg = process.env['PG_STORE_TESTS_ENABLED'] === '1' ? describe : describe.skip; +const PREVIOUS_MIGRATION = '20261018_000001_session_last_turn_details'; + +describePg('Postgres session tfy.source backfill migration', () => { + let env: PostgresTestDatabase | undefined; + + beforeEach(async () => { + env = await createPostgresTestDatabase(PREVIOUS_MIGRATION); + if (env === undefined) { + throw new Error('Postgres test environment unavailable despite globalSetup probe'); + } + }, 120_000); + + afterEach(async () => { + await env?.teardown(); + }); + + function requireEnv(): PostgresTestDatabase { + if (env === undefined) { + throw new Error('Postgres test environment not initialized'); + } + return env; + } + + runSessionTfySourceBackfillSuite({ + getStore: () => new PostgresSessionStore(requireEnv().db), + runUp: () => + requireEnv() + .db.transaction() + .execute(trx => up(trx)), + }); +}); diff --git a/packages/trueforge/tests/db/sessionTfySourceBackfillSuite.ts b/packages/trueforge/tests/db/sessionTfySourceBackfillSuite.ts new file mode 100644 index 0000000000..f62fd6ec49 --- /dev/null +++ b/packages/trueforge/tests/db/sessionTfySourceBackfillSuite.ts @@ -0,0 +1,83 @@ +import type { CreatedBySubject, SessionMetadata, SessionSource } from '@truefoundry/trueforge-core/agent-session'; +import type { ISessionStore } from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; + +const TENANT = 'default'; +const USER_SUBJECT: CreatedBySubject = { + subject_id: 'user-1', + subject_type: 'user', + subject_display_name: 'user-1', +}; + +/** Shared cases for the `tfy.source` backfill; each backend supplies its store and `up`. */ +export function runSessionTfySourceBackfillSuite(deps: { + getStore: () => ISessionStore; + runUp: () => Promise; +}): void { + async function seed(input: { + session_id: string; + metadata: SessionMetadata; + external_id: string | null; + source: SessionSource | null; + }): Promise { + await deps.getStore().createSession({ + tenant_id: TENANT, + created_by_subject: USER_SUBJECT, + agent: { type: 'reference', id: 'agent-abc', name: null }, + custom: null, + ...input, + }); + } + + async function metadataOf(sessionId: string): Promise { + const session = await deps.getStore().getSession({ tenant_id: TENANT, session_id: sessionId }); + return session?.metadata; + } + + it('stamps interactive sessions and preserves their existing metadata', async () => { + await seed({ session_id: 'chat', metadata: {}, external_id: null, source: null }); + await seed({ + session_id: 'builder', + metadata: { is_create_agent: 'true' }, + external_id: null, + source: null, + }); + + await deps.runUp(); + + expect(await metadataOf('chat')).toEqual({ 'tfy.source': 'ui' }); + expect(await metadataOf('builder')).toEqual({ is_create_agent: 'true', 'tfy.source': 'ui' }); + }); + + it('leaves schedule runs untouched, including ones created before source existed', async () => { + await seed({ + session_id: 'scheduled', + metadata: {}, + external_id: 'run-1', + source: { type: 'schedule', id: 'schedule-1', run_id: 'run-1' }, + }); + await seed({ session_id: 'legacy-scheduled', metadata: {}, external_id: 'run-0', source: null }); + + await deps.runUp(); + + expect(await metadataOf('scheduled')).toEqual({}); + expect(await metadataOf('legacy-scheduled')).toEqual({}); + }); + + it('does not overwrite an existing tfy.source value', async () => { + await seed({ session_id: 'stamped', metadata: { 'tfy.source': 'other' }, external_id: null, source: null }); + + await deps.runUp(); + + expect(await metadataOf('stamped')).toEqual({ 'tfy.source': 'other' }); + }); + + it('does not change updated_at', async () => { + await seed({ session_id: 'ordered', metadata: {}, external_id: null, source: null }); + const before = await deps.getStore().getSession({ tenant_id: TENANT, session_id: 'ordered' }); + + await deps.runUp(); + + const after = await deps.getStore().getSession({ tenant_id: TENANT, session_id: 'ordered' }); + expect(after?.updated_at).toEqual(before?.updated_at); + }); +} diff --git a/packages/trueforge/tests/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.test.ts b/packages/trueforge/tests/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.test.ts new file mode 100644 index 0000000000..2413b059ea --- /dev/null +++ b/packages/trueforge/tests/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.test.ts @@ -0,0 +1,28 @@ +import { up } from '../../../../src/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill'; +import { SqliteSessionStore } from '../../../../src/db/sqlite/session-store/SqliteSessionStore'; +import { runSessionTfySourceBackfillSuite } from '../../sessionTfySourceBackfillSuite'; +import { createSqliteTestDatabase, type SqliteTestDatabase } from '../testDatabase'; + +describe('SQLite session tfy.source backfill migration', () => { + let env: SqliteTestDatabase | undefined; + + beforeEach(async () => { + env = await createSqliteTestDatabase(); + }, 120_000); + + afterEach(async () => { + await env?.teardown(); + }); + + function requireEnv(): SqliteTestDatabase { + if (env === undefined) { + throw new Error('SQLite test environment not initialized'); + } + return env; + } + + runSessionTfySourceBackfillSuite({ + getStore: () => new SqliteSessionStore(requireEnv().db), + runUp: () => up(requireEnv().db), + }); +}); From 847097c4c6858faa5bba66411c5aefaa64c7dfb5 Mon Sep 17 00:00:00 2001 From: Hary Ahuja Date: Thu, 22 Oct 2026 19:29:09 +0530 Subject: [PATCH 2/8] chore: drop backfill migration tests Co-authored-by: Cursor --- ...000001_session_tfy_source_backfill.test.ts | 37 --------- .../tests/db/sessionTfySourceBackfillSuite.ts | 83 ------------------- ...000001_session_tfy_source_backfill.test.ts | 28 ------- 3 files changed, 148 deletions(-) delete mode 100644 packages/trueforge/tests/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.test.ts delete mode 100644 packages/trueforge/tests/db/sessionTfySourceBackfillSuite.ts delete mode 100644 packages/trueforge/tests/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.test.ts diff --git a/packages/trueforge/tests/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.test.ts b/packages/trueforge/tests/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.test.ts deleted file mode 100644 index 50f39657b8..0000000000 --- a/packages/trueforge/tests/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { up } from '../../../../src/db/postgres/migrations/20261022_000001_session_tfy_source_backfill'; -import { PostgresSessionStore } from '../../../../src/db/postgres/session-store/PostgresSessionStore'; -import { runSessionTfySourceBackfillSuite } from '../../sessionTfySourceBackfillSuite'; -import { createPostgresTestDatabase, type PostgresTestDatabase } from '../testDatabase'; - -const describePg = process.env['PG_STORE_TESTS_ENABLED'] === '1' ? describe : describe.skip; -const PREVIOUS_MIGRATION = '20261018_000001_session_last_turn_details'; - -describePg('Postgres session tfy.source backfill migration', () => { - let env: PostgresTestDatabase | undefined; - - beforeEach(async () => { - env = await createPostgresTestDatabase(PREVIOUS_MIGRATION); - if (env === undefined) { - throw new Error('Postgres test environment unavailable despite globalSetup probe'); - } - }, 120_000); - - afterEach(async () => { - await env?.teardown(); - }); - - function requireEnv(): PostgresTestDatabase { - if (env === undefined) { - throw new Error('Postgres test environment not initialized'); - } - return env; - } - - runSessionTfySourceBackfillSuite({ - getStore: () => new PostgresSessionStore(requireEnv().db), - runUp: () => - requireEnv() - .db.transaction() - .execute(trx => up(trx)), - }); -}); diff --git a/packages/trueforge/tests/db/sessionTfySourceBackfillSuite.ts b/packages/trueforge/tests/db/sessionTfySourceBackfillSuite.ts deleted file mode 100644 index f62fd6ec49..0000000000 --- a/packages/trueforge/tests/db/sessionTfySourceBackfillSuite.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { CreatedBySubject, SessionMetadata, SessionSource } from '@truefoundry/trueforge-core/agent-session'; -import type { ISessionStore } from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; - -const TENANT = 'default'; -const USER_SUBJECT: CreatedBySubject = { - subject_id: 'user-1', - subject_type: 'user', - subject_display_name: 'user-1', -}; - -/** Shared cases for the `tfy.source` backfill; each backend supplies its store and `up`. */ -export function runSessionTfySourceBackfillSuite(deps: { - getStore: () => ISessionStore; - runUp: () => Promise; -}): void { - async function seed(input: { - session_id: string; - metadata: SessionMetadata; - external_id: string | null; - source: SessionSource | null; - }): Promise { - await deps.getStore().createSession({ - tenant_id: TENANT, - created_by_subject: USER_SUBJECT, - agent: { type: 'reference', id: 'agent-abc', name: null }, - custom: null, - ...input, - }); - } - - async function metadataOf(sessionId: string): Promise { - const session = await deps.getStore().getSession({ tenant_id: TENANT, session_id: sessionId }); - return session?.metadata; - } - - it('stamps interactive sessions and preserves their existing metadata', async () => { - await seed({ session_id: 'chat', metadata: {}, external_id: null, source: null }); - await seed({ - session_id: 'builder', - metadata: { is_create_agent: 'true' }, - external_id: null, - source: null, - }); - - await deps.runUp(); - - expect(await metadataOf('chat')).toEqual({ 'tfy.source': 'ui' }); - expect(await metadataOf('builder')).toEqual({ is_create_agent: 'true', 'tfy.source': 'ui' }); - }); - - it('leaves schedule runs untouched, including ones created before source existed', async () => { - await seed({ - session_id: 'scheduled', - metadata: {}, - external_id: 'run-1', - source: { type: 'schedule', id: 'schedule-1', run_id: 'run-1' }, - }); - await seed({ session_id: 'legacy-scheduled', metadata: {}, external_id: 'run-0', source: null }); - - await deps.runUp(); - - expect(await metadataOf('scheduled')).toEqual({}); - expect(await metadataOf('legacy-scheduled')).toEqual({}); - }); - - it('does not overwrite an existing tfy.source value', async () => { - await seed({ session_id: 'stamped', metadata: { 'tfy.source': 'other' }, external_id: null, source: null }); - - await deps.runUp(); - - expect(await metadataOf('stamped')).toEqual({ 'tfy.source': 'other' }); - }); - - it('does not change updated_at', async () => { - await seed({ session_id: 'ordered', metadata: {}, external_id: null, source: null }); - const before = await deps.getStore().getSession({ tenant_id: TENANT, session_id: 'ordered' }); - - await deps.runUp(); - - const after = await deps.getStore().getSession({ tenant_id: TENANT, session_id: 'ordered' }); - expect(after?.updated_at).toEqual(before?.updated_at); - }); -} diff --git a/packages/trueforge/tests/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.test.ts b/packages/trueforge/tests/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.test.ts deleted file mode 100644 index 2413b059ea..0000000000 --- a/packages/trueforge/tests/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { up } from '../../../../src/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill'; -import { SqliteSessionStore } from '../../../../src/db/sqlite/session-store/SqliteSessionStore'; -import { runSessionTfySourceBackfillSuite } from '../../sessionTfySourceBackfillSuite'; -import { createSqliteTestDatabase, type SqliteTestDatabase } from '../testDatabase'; - -describe('SQLite session tfy.source backfill migration', () => { - let env: SqliteTestDatabase | undefined; - - beforeEach(async () => { - env = await createSqliteTestDatabase(); - }, 120_000); - - afterEach(async () => { - await env?.teardown(); - }); - - function requireEnv(): SqliteTestDatabase { - if (env === undefined) { - throw new Error('SQLite test environment not initialized'); - } - return env; - } - - runSessionTfySourceBackfillSuite({ - getStore: () => new SqliteSessionStore(requireEnv().db), - runUp: () => up(requireEnv().db), - }); -}); From eed57fa811a4425f3d336946655458773b12419f Mon Sep 17 00:00:00 2001 From: Hary Ahuja Date: Thu, 22 Oct 2026 23:09:49 +0530 Subject: [PATCH 3/8] chore: rename backfill metadata key to tfg.source Co-authored-by: Cursor --- ...-source-backfill.md => session-tfg-source-backfill.md} | 2 +- ....ts => 20261022_000001_session_tfg_source_backfill.ts} | 8 ++++---- ....ts => 20261022_000001_session_tfg_source_backfill.ts} | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) rename .changeset/{session-tfy-source-backfill.md => session-tfg-source-backfill.md} (63%) rename packages/trueforge/src/db/postgres/migrations/{20261022_000001_session_tfy_source_backfill.ts => 20261022_000001_session_tfg_source_backfill.ts} (78%) rename packages/trueforge/src/db/sqlite/migrations/{20261022_000001_session_tfy_source_backfill.ts => 20261022_000001_session_tfg_source_backfill.ts} (74%) diff --git a/.changeset/session-tfy-source-backfill.md b/.changeset/session-tfg-source-backfill.md similarity index 63% rename from .changeset/session-tfy-source-backfill.md rename to .changeset/session-tfg-source-backfill.md index f3d3eed57e..27c3c650b1 100644 --- a/.changeset/session-tfy-source-backfill.md +++ b/.changeset/session-tfg-source-backfill.md @@ -2,4 +2,4 @@ "@truefoundry/trueforge": patch --- -Backfill `tfy.source: ui` session metadata on existing non-scheduled sessions so they stay in Chat History once it filters on that key. +Backfill `tfg.source: ui` session metadata on existing non-scheduled sessions so they stay in Chat History once it filters on that key. diff --git a/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.ts b/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfg_source_backfill.ts similarity index 78% rename from packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.ts rename to packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfg_source_backfill.ts index 3b62da93ce..b204415512 100644 --- a/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfy_source_backfill.ts +++ b/packages/trueforge/src/db/postgres/migrations/20261022_000001_session_tfg_source_backfill.ts @@ -1,7 +1,7 @@ import { sql, type Kysely } from 'kysely'; /** - * Stamp `metadata["tfy.source"] = "ui"` on pre-existing interactive sessions so Chat History, + * Stamp `metadata["tfg.source"] = "ui"` on pre-existing interactive sessions so Chat History, * which now lists only sessions carrying that key, keeps showing them. * * Schedule runs are the only sessions with `external_id`; checking it alongside `source` also @@ -12,15 +12,15 @@ export async function up(db: Kysely): Promise { await sql`SET LOCAL lock_timeout = '5s'`.execute(db); await sql` UPDATE session - SET metadata = metadata || jsonb_build_object('tfy.source', 'ui') + SET metadata = metadata || jsonb_build_object('tfg.source', 'ui') WHERE source IS NULL AND external_id IS NULL - AND metadata->'tfy.source' IS NULL + AND metadata->'tfg.source' IS NULL `.execute(db); } /** Data backfill — not reversed: backfilled rows are indistinguishable from UI-stamped ones. */ export function down(db: Kysely): Promise { void db; - return Promise.reject(new Error('20261022_000001_session_tfy_source_backfill is irreversible')); + return Promise.reject(new Error('20261022_000001_session_tfg_source_backfill is irreversible')); } diff --git a/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.ts b/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts similarity index 74% rename from packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.ts rename to packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts index e9fd258978..d72ff033dd 100644 --- a/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfy_source_backfill.ts +++ b/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts @@ -1,17 +1,17 @@ import { sql, type Kysely } from 'kysely'; /** - * SQLite mirror of postgres/migrations/20261022_000001_session_tfy_source_backfill.ts. + * SQLite mirror of postgres/migrations/20261022_000001_session_tfg_source_backfill.ts. * The key contains a dot, so JSON paths must quote it. */ export async function up(db: Kysely): Promise { await db.transaction().execute(async trx => { await sql` UPDATE session - SET metadata = jsonb_set(metadata, '$."tfy.source"', 'ui') + SET metadata = jsonb_set(metadata, '$."tfg.source"', 'ui') WHERE source IS NULL AND external_id IS NULL - AND json_extract(metadata, '$."tfy.source"') IS NULL + AND json_extract(metadata, '$."tfg.source"') IS NULL `.execute(trx); }); } @@ -19,5 +19,5 @@ export async function up(db: Kysely): Promise { /** Data backfill — not reversed: backfilled rows are indistinguishable from UI-stamped ones. */ export function down(db: Kysely): Promise { void db; - return Promise.reject(new Error('20261022_000001_session_tfy_source_backfill is irreversible')); + return Promise.reject(new Error('20261022_000001_session_tfg_source_backfill is irreversible')); } From 8d8a71d0e77b4c1348df2a0ac6ba3096e52e19ff Mon Sep 17 00:00:00 2001 From: Hary Ahuja Date: Fri, 23 Oct 2026 13:14:51 +0530 Subject: [PATCH 4/8] chore: add partial index for Chat History tfg.source listing Co-authored-by: Cursor --- packages/trueforge/src/db/indexes.ts | 6 ++++++ ...2_000002_session_tfg_source_ui_list_idx.ts | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_ui_list_idx.ts diff --git a/packages/trueforge/src/db/indexes.ts b/packages/trueforge/src/db/indexes.ts index e69c9757af..cd7b4af5f1 100644 --- a/packages/trueforge/src/db/indexes.ts +++ b/packages/trueforge/src/db/indexes.ts @@ -16,6 +16,12 @@ export const AGENT_EXTERNAL_ID_UQ = 'agent_external_id_uq'; /** GIN index for `session.metadata` containment queries (`@>`). */ export const SESSION_METADATA_GIN = 'session_metadata_gin'; +/** + * `(tenant_id, created_by_subject.subject_id, updated_at, session_id)` on session, partial + * WHERE metadata @> {"tfg.source": "ui"} — Chat History listing in keyset order. + */ +export const SESSION_TFG_SOURCE_UI_LIST_IDX = 'session_tfg_source_ui_list_idx'; + /** `(tenant_id, created_by_subject.subject_id)` on agent. */ export const AGENT_CREATED_BY_SUBJECT_ID_IDX = 'agent_created_by_subject_id_idx'; diff --git a/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_ui_list_idx.ts b/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_ui_list_idx.ts new file mode 100644 index 0000000000..ac7e6e70c8 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_ui_list_idx.ts @@ -0,0 +1,21 @@ +import { sql, type Kysely } from 'kysely'; +import { SESSION_TFG_SOURCE_UI_LIST_IDX } from '../../indexes'; + +/** + * Chat History lists a caller's `tfg.source = ui` sessions in `(updated_at, session_id)` keyset + * order. The planner only uses this partial index when the query's metadata filter is exactly + * `{"tfg.source": "ui"}`; extra filter keys fall back to the GIN index. + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql` + CREATE INDEX ${sql.raw(SESSION_TFG_SOURCE_UI_LIST_IDX)} + ON session (tenant_id, (created_by_subject->>'subject_id'), updated_at, session_id) + WHERE metadata @> '{"tfg.source": "ui"}'::jsonb + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql`DROP INDEX IF EXISTS ${sql.raw(SESSION_TFG_SOURCE_UI_LIST_IDX)}`.execute(db); +} From 805ab35cacdcac13382306749c836c811ad9a135 Mon Sep 17 00:00:00 2001 From: Hary Ahuja Date: Fri, 23 Oct 2026 16:05:47 +0530 Subject: [PATCH 5/8] chore: composite tfg.source list index and per-key metadata conditions Co-authored-by: Cursor --- packages/trueforge/src/db/indexes.ts | 6 ++--- ...1022_000002_session_tfg_source_list_idx.ts | 25 +++++++++++++++++++ ...2_000002_session_tfg_source_ui_list_idx.ts | 21 ---------------- .../session-store/queries/sessions.ts | 5 ++++ 4 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts delete mode 100644 packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_ui_list_idx.ts diff --git a/packages/trueforge/src/db/indexes.ts b/packages/trueforge/src/db/indexes.ts index cd7b4af5f1..94542d48c0 100644 --- a/packages/trueforge/src/db/indexes.ts +++ b/packages/trueforge/src/db/indexes.ts @@ -17,10 +17,10 @@ export const AGENT_EXTERNAL_ID_UQ = 'agent_external_id_uq'; export const SESSION_METADATA_GIN = 'session_metadata_gin'; /** - * `(tenant_id, created_by_subject.subject_id, updated_at, session_id)` on session, partial - * WHERE metadata @> {"tfg.source": "ui"} — Chat History listing in keyset order. + * `(tenant_id, created_by_subject.subject_id, metadata.tfg.source, updated_at, session_id)` on + * session — a caller's sessions for one `tfg.source` value (or none) in keyset order. */ -export const SESSION_TFG_SOURCE_UI_LIST_IDX = 'session_tfg_source_ui_list_idx'; +export const SESSION_TFG_SOURCE_LIST_IDX = 'session_tfg_source_list_idx'; /** `(tenant_id, created_by_subject.subject_id)` on agent. */ export const AGENT_CREATED_BY_SUBJECT_ID_IDX = 'agent_created_by_subject_id_idx'; diff --git a/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts b/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts new file mode 100644 index 0000000000..cc4676f8ca --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts @@ -0,0 +1,25 @@ +import { sql, type Kysely } from 'kysely'; +import { SESSION_TFG_SOURCE_LIST_IDX } from '../../indexes'; + +/** + * Equality columns first, then the keyset sort columns, so listing a caller's sessions for one + * `tfg.source` value (or a missing key) reads them in order without a sort. + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql` + CREATE INDEX ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)} + ON session ( + tenant_id, + (created_by_subject->>'subject_id'), + (metadata->>'tfg.source'), + updated_at, + session_id + ) + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await sql`DROP INDEX IF EXISTS ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)}`.execute(db); +} diff --git a/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_ui_list_idx.ts b/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_ui_list_idx.ts deleted file mode 100644 index ac7e6e70c8..0000000000 --- a/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_ui_list_idx.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { sql, type Kysely } from 'kysely'; -import { SESSION_TFG_SOURCE_UI_LIST_IDX } from '../../indexes'; - -/** - * Chat History lists a caller's `tfg.source = ui` sessions in `(updated_at, session_id)` keyset - * order. The planner only uses this partial index when the query's metadata filter is exactly - * `{"tfg.source": "ui"}`; extra filter keys fall back to the GIN index. - */ -export async function up(db: Kysely): Promise { - await sql`SET LOCAL lock_timeout = '5s'`.execute(db); - await sql` - CREATE INDEX ${sql.raw(SESSION_TFG_SOURCE_UI_LIST_IDX)} - ON session (tenant_id, (created_by_subject->>'subject_id'), updated_at, session_id) - WHERE metadata @> '{"tfg.source": "ui"}'::jsonb - `.execute(db); -} - -export async function down(db: Kysely): Promise { - await sql`SET LOCAL lock_timeout = '5s'`.execute(db); - await sql`DROP INDEX IF EXISTS ${sql.raw(SESSION_TFG_SOURCE_UI_LIST_IDX)}`.execute(db); -} diff --git a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts index 2b998e226d..6918ba2183 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts @@ -285,6 +285,11 @@ export async function listSessions( query = query.where(sql`metadata = '{}'::jsonb`); } else { query = query.where(sql`metadata @> ${json(input.metadata)}`); + // Values are strings, so these repeat the containment check; they let the planner use + // per-key expression indexes (`metadata->>'key'`), which `@>` alone cannot. + for (const [key, value] of Object.entries(input.metadata)) { + query = query.where(sql`metadata->>${key}::text`, '=', value); + } } } if (input.source_type !== undefined) { From 2e91752a055221eb2d51ad9d12698c15ebb13d7e Mon Sep 17 00:00:00 2001 From: Hary Ahuja Date: Fri, 23 Oct 2026 16:15:26 +0530 Subject: [PATCH 6/8] cleanup comments --- .../src/db/postgres/session-store/queries/sessions.ts | 5 +++-- .../20261022_000001_session_tfg_source_backfill.ts | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts index 6918ba2183..2911353736 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts @@ -285,8 +285,9 @@ export async function listSessions( query = query.where(sql`metadata = '{}'::jsonb`); } else { query = query.where(sql`metadata @> ${json(input.metadata)}`); - // Values are strings, so these repeat the containment check; they let the planner use - // per-key expression indexes (`metadata->>'key'`), which `@>` alone cannot. + // Matches the same rows as the `@>` filter above (metadata values are always strings). + // Postgres can only use the GIN index for `@>`; spelling each pair as `metadata->>'key' = value` + // also lets it use single-key indexes. for (const [key, value] of Object.entries(input.metadata)) { query = query.where(sql`metadata->>${key}::text`, '=', value); } diff --git a/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts b/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts index d72ff033dd..3441ab7191 100644 --- a/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts +++ b/packages/trueforge/src/db/sqlite/migrations/20261022_000001_session_tfg_source_backfill.ts @@ -2,7 +2,6 @@ import { sql, type Kysely } from 'kysely'; /** * SQLite mirror of postgres/migrations/20261022_000001_session_tfg_source_backfill.ts. - * The key contains a dot, so JSON paths must quote it. */ export async function up(db: Kysely): Promise { await db.transaction().execute(async trx => { From 46c5bcdf9fe04888907222bd5ccb9542f1846f6f Mon Sep 17 00:00:00 2001 From: Hary Ahuja Date: Fri, 23 Oct 2026 16:18:48 +0530 Subject: [PATCH 7/8] chore: mirror tfg.source list index in SQLite Co-authored-by: Cursor --- ...1022_000002_session_tfg_source_list_idx.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts diff --git a/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts b/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts new file mode 100644 index 0000000000..f8ea762212 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts @@ -0,0 +1,27 @@ +import { sql, type Kysely } from 'kysely'; +import { SESSION_TFG_SOURCE_LIST_IDX } from '../../indexes'; + +/** + * SQLite mirror of postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts. + * The key contains a dot, so the JSON path must quote it. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql` + CREATE INDEX ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)} + ON session ( + tenant_id, + json_extract(created_by_subject, '$.subject_id'), + json_extract(metadata, '$."tfg.source"'), + updated_at, + session_id + ) + `.execute(trx); + }); +} + +export async function down(db: Kysely): Promise { + await db.transaction().execute(async trx => { + await sql`DROP INDEX IF EXISTS ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)}`.execute(trx); + }); +} From ee970ea9588cfce89c170ad911345ff1b07e0129 Mon Sep 17 00:00:00 2001 From: Hary Ahuja Date: Fri, 23 Oct 2026 21:20:42 +0530 Subject: [PATCH 8/8] chore: put updated_at before tfg.source in list index; inline metadata key for index matching Co-authored-by: Cursor --- packages/trueforge/src/db/indexes.ts | 4 ++-- .../20261022_000002_session_tfg_source_list_idx.ts | 8 ++++---- .../src/db/postgres/session-store/queries/sessions.ts | 4 ++-- .../20261022_000002_session_tfg_source_list_idx.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/trueforge/src/db/indexes.ts b/packages/trueforge/src/db/indexes.ts index 94542d48c0..d848e51560 100644 --- a/packages/trueforge/src/db/indexes.ts +++ b/packages/trueforge/src/db/indexes.ts @@ -17,8 +17,8 @@ export const AGENT_EXTERNAL_ID_UQ = 'agent_external_id_uq'; export const SESSION_METADATA_GIN = 'session_metadata_gin'; /** - * `(tenant_id, created_by_subject.subject_id, metadata.tfg.source, updated_at, session_id)` on - * session — a caller's sessions for one `tfg.source` value (or none) in keyset order. + * `(tenant_id, created_by_subject.subject_id, updated_at, session_id, metadata.tfg.source)` on + * session — a caller's sessions in keyset order, optionally filtered by `tfg.source`. */ export const SESSION_TFG_SOURCE_LIST_IDX = 'session_tfg_source_list_idx'; diff --git a/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts b/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts index cc4676f8ca..eab4bf28cb 100644 --- a/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts +++ b/packages/trueforge/src/db/postgres/migrations/20261022_000002_session_tfg_source_list_idx.ts @@ -2,8 +2,8 @@ import { sql, type Kysely } from 'kysely'; import { SESSION_TFG_SOURCE_LIST_IDX } from '../../indexes'; /** - * Equality columns first, then the keyset sort columns, so listing a caller's sessions for one - * `tfg.source` value (or a missing key) reads them in order without a sort. + * A caller's sessions in keyset order, so their newest-first listing needs no sort with or without + * a `tfg.source` filter; the filter is checked on index entries without visiting the table. */ export async function up(db: Kysely): Promise { await sql`SET LOCAL lock_timeout = '5s'`.execute(db); @@ -12,9 +12,9 @@ export async function up(db: Kysely): Promise { ON session ( tenant_id, (created_by_subject->>'subject_id'), - (metadata->>'tfg.source'), updated_at, - session_id + session_id, + (metadata->>'tfg.source') ) `.execute(db); } diff --git a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts index 2911353736..b6f5e890a8 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/sessions.ts @@ -287,9 +287,9 @@ export async function listSessions( query = query.where(sql`metadata @> ${json(input.metadata)}`); // Matches the same rows as the `@>` filter above (metadata values are always strings). // Postgres can only use the GIN index for `@>`; spelling each pair as `metadata->>'key' = value` - // also lets it use single-key indexes. + // also lets it use single-key indexes. Those match on the literal key, so it is inlined, not bound. for (const [key, value] of Object.entries(input.metadata)) { - query = query.where(sql`metadata->>${key}::text`, '=', value); + query = query.where(sql`metadata->>${sql.lit(key)}`, '=', value); } } } diff --git a/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts b/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts index f8ea762212..18b5e9fbf1 100644 --- a/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts +++ b/packages/trueforge/src/db/sqlite/migrations/20261022_000002_session_tfg_source_list_idx.ts @@ -12,9 +12,9 @@ export async function up(db: Kysely): Promise { ON session ( tenant_id, json_extract(created_by_subject, '$.subject_id'), - json_extract(metadata, '$."tfg.source"'), updated_at, - session_id + session_id, + json_extract(metadata, '$."tfg.source"') ) `.execute(trx); });