Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/session-tfg-source-backfill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@truefoundry/trueforge": patch
---

Backfill `tfg.source: ui` session metadata on existing non-scheduled sessions so they stay in Chat History once it filters on that key.
6 changes: 6 additions & 0 deletions packages/trueforge/src/db/indexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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';

/** `(tenant_id, created_by_subject.subject_id)` on agent. */
export const AGENT_CREATED_BY_SUBJECT_ID_IDX = 'agent_created_by_subject_id_idx';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { sql, type Kysely } from 'kysely';

/**
* 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
* skips schedule runs created before `source` existed. `updated_at` is left untouched so list
* ordering does not change.
*/
export async function up<TDatabase>(db: Kysely<TDatabase>): Promise<void> {
await sql`SET LOCAL lock_timeout = '5s'`.execute(db);
await sql`
UPDATE session
SET metadata = metadata || jsonb_build_object('tfg.source', 'ui')
WHERE source IS NULL
AND external_id 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<unknown>): Promise<void> {
void db;
return Promise.reject(new Error('20261022_000001_session_tfg_source_backfill is irreversible'));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { sql, type Kysely } from 'kysely';
import { SESSION_TFG_SOURCE_LIST_IDX } from '../../indexes';

/**
* 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<unknown>): Promise<void> {
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'),
updated_at,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. why do we have updated at? when listing sessions do we use updated_at or created_at
  2. Also it should be before (metadata->>'tfg.source'), I expect people to query by time range always with optionally metadata filtering

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we have updated at? when listing sessions do we use updated_at or created_at

Yes, when listing we order by updated_at DESC

Also it should be before (metadata->>'tfg.source'), I expect people to query by time range always with optionally metadata filtering

the start / end params in the list API filter on created_at, not updated_at - so moving it up won't help for time range queries. We already have an index for supporting these queries session_list_idx (tenant_id, created_at, session_id)

But even if we don't supported range queries on updated_at, it makes sense to move updated_at above in the case where a user would just want to see their recent sessions. The current index performs better on the chat view for users which have a lot of scheduled sessions, but moving updated_at above will create a more generic index.

session_id,
(metadata->>'tfg.source')
)
`.execute(db);
}

export async function down(db: Kysely<unknown>): Promise<void> {
await sql`SET LOCAL lock_timeout = '5s'`.execute(db);
await sql`DROP INDEX IF EXISTS ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)}`.execute(db);
}
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@ export async function listSessions(
query = query.where(sql<boolean>`metadata = '{}'::jsonb`);
} else {
query = query.where(sql<boolean>`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. 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<string>`metadata->>${sql.lit(key)}`, '=', value);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
if (input.source_type !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { sql, type Kysely } from 'kysely';

/**
* SQLite mirror of postgres/migrations/20261022_000001_session_tfg_source_backfill.ts.
*/
export async function up<TDatabase>(db: Kysely<TDatabase>): Promise<void> {
await db.transaction().execute(async trx => {
await sql`
UPDATE session
SET metadata = jsonb_set(metadata, '$."tfg.source"', 'ui')
WHERE source IS NULL
AND external_id IS NULL
AND json_extract(metadata, '$."tfg.source"') IS NULL
`.execute(trx);
});
}

/** Data backfill — not reversed: backfilled rows are indistinguishable from UI-stamped ones. */
export function down(db: Kysely<unknown>): Promise<void> {
void db;
return Promise.reject(new Error('20261022_000001_session_tfg_source_backfill is irreversible'));
}
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<void> {
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'),
updated_at,
session_id,
json_extract(metadata, '$."tfg.source"')
)
`.execute(trx);
});
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.transaction().execute(async trx => {
await sql`DROP INDEX IF EXISTS ${sql.raw(SESSION_TFG_SOURCE_LIST_IDX)}`.execute(trx);
});
}
Loading