Skip to content

Commit 33dd583

Browse files
dmealingclaude
andcommitted
test(ai): STI shared-table real-Postgres round-trip (two callTypes, one table)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2a76e57 commit 33dd583

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// ai-trace #1c — shared-table STI (TPH) real-Postgres round-trip.
2+
//
3+
// Proves the shared-table single-table-inheritance contract: two different
4+
// trace `callType`s land in ONE shared `prompt_llm_call` table and read back,
5+
// each carrying its OWN typed `voResponse`.
6+
//
7+
// PromptTrace (concrete TPH base) — @discriminator: callType,
8+
// source.rdb table prompt_llm_call, identity.primary spanId, and the full
9+
// recorder envelope (the 17 keys recordLlmCall writes).
10+
// ClassifyCall extends PromptTrace — @discriminatorValue "classify",
11+
// voResponse → ClassifyRes ({ label, score }).
12+
// SummarizeCall extends PromptTrace — @discriminatorValue "summarize",
13+
// voResponse → SummarizeRes ({ summary }).
14+
//
15+
// Mirrors llm-call-persistence.test.ts: load metadata, synthesise DDL via the
16+
// migrate-ts helpers, start a fresh Postgres container, exercise via
17+
// recordLlmCall + ObjectManager, then tear the container down.
18+
19+
import { describe, expect, test } from "bun:test";
20+
import { Kysely, PostgresDialect } from "kysely";
21+
import { Pool } from "pg";
22+
23+
import { MetaDataLoader } from "@metaobjectsdev/metadata";
24+
import { buildExpectedSchema, diff, emit } from "@metaobjectsdev/migrate-ts";
25+
import {
26+
Format,
27+
LlmCallDbRecorder,
28+
ObjectManager,
29+
recordLlmCall,
30+
} from "@metaobjectsdev/runtime-ts";
31+
import { kyselyDriver } from "@metaobjectsdev/runtime-ts/drivers";
32+
33+
import { startPostgres } from "../src/postgres-container.ts";
34+
import { executeSql } from "../src/postgres-sql.ts";
35+
36+
const META = JSON.stringify({
37+
"metadata.root": {
38+
package: "test::ai",
39+
children: [
40+
{
41+
"object.value": {
42+
name: "ClassifyRes",
43+
children: [
44+
{ "field.string": { name: "label", "@required": true } },
45+
{ "field.int": { name: "score" } },
46+
],
47+
},
48+
},
49+
{
50+
"object.value": {
51+
name: "SummarizeRes",
52+
children: [
53+
{ "field.string": { name: "summary", "@required": true } },
54+
],
55+
},
56+
},
57+
{
58+
"object.entity": {
59+
name: "PromptTrace",
60+
"@discriminator": "callType",
61+
children: [
62+
{ "source.rdb": { "@table": "prompt_llm_call" } },
63+
{ "field.uuid": { name: "spanId" } },
64+
{ "field.uuid": { name: "traceId" } },
65+
{ "field.uuid": { name: "parentSpanId" } },
66+
{ "field.string": { name: "sessionId" } },
67+
{ "field.string": { name: "callType" } },
68+
{ "field.string": { name: "requestModel" } },
69+
{ "field.string": { name: "responseModel" } },
70+
{ "field.int": { name: "inputTokens" } },
71+
{ "field.int": { name: "outputTokens" } },
72+
{ "field.currency": { name: "costMinor", "@currency": "USD" } },
73+
{ "field.int": { name: "latencyMs" } },
74+
{ "field.string": { name: "finishReason" } },
75+
{ "field.string": { name: "status" } },
76+
{ "field.string": { name: "errorDetail" } },
77+
{ "field.string": { name: "startedAt" } },
78+
{ "field.string": { name: "llmRequest", "@dbColumnType": "jsonb" } },
79+
{ "identity.primary": { "@fields": "spanId" } },
80+
],
81+
},
82+
},
83+
{
84+
"object.entity": {
85+
name: "ClassifyCall",
86+
extends: "PromptTrace",
87+
"@discriminatorValue": "classify",
88+
children: [
89+
{ "field.object": { name: "voResponse", "@objectRef": "ClassifyRes", "@storage": "jsonb" } },
90+
],
91+
},
92+
},
93+
{
94+
"object.entity": {
95+
name: "SummarizeCall",
96+
extends: "PromptTrace",
97+
"@discriminatorValue": "summarize",
98+
children: [
99+
{ "field.object": { name: "voResponse", "@objectRef": "SummarizeRes", "@storage": "jsonb" } },
100+
],
101+
},
102+
},
103+
],
104+
},
105+
});
106+
107+
const C_SPAN = "11111111-1111-4111-8111-111111111111";
108+
const C_TRACE = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
109+
const S_SPAN = "22222222-2222-4222-8222-222222222222";
110+
const S_TRACE = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
111+
112+
describe("ai-trace #1c — shared-table persistence (real Postgres)", () => {
113+
test("two callTypes round-trip through one prompt_llm_call table", async () => {
114+
const result = await MetaDataLoader.fromString(META, "json");
115+
expect(result.errors).toHaveLength(0);
116+
const root = result.root;
117+
const classifyRes = root.findObject("ClassifyRes")!;
118+
const summarizeRes = root.findObject("SummarizeRes")!;
119+
120+
// ONE-TABLE assertion: the STI subtypes do NOT each get their own table —
121+
// exactly one prompt_llm_call table is synthesised for the whole hierarchy.
122+
const expected = buildExpectedSchema(root, { columnNamingStrategy: "literal" });
123+
expect(expected.tables.filter((t) => t.name === "prompt_llm_call").length).toBe(1);
124+
125+
const diffResult = await diff({ expected, actual: { tables: [], views: [] } });
126+
const { up: ddl } = emit(diffResult.changes, { dialect: "postgres" });
127+
128+
const pgc = await startPostgres();
129+
let kysely: Kysely<Record<string, never>> | null = null;
130+
try {
131+
await executeSql(pgc.connectionUri, ddl);
132+
kysely = new Kysely<Record<string, never>>({
133+
dialect: new PostgresDialect({
134+
pool: new Pool({ connectionString: pgc.connectionUri, options: "-c timezone=UTC" }),
135+
}),
136+
});
137+
const driver = kyselyDriver({ db: kysely as never, dialect: "postgres" });
138+
const om = new ObjectManager({ metadata: root, driver, columnNamingStrategy: "literal" });
139+
140+
await recordLlmCall(
141+
{
142+
spanId: C_SPAN,
143+
traceId: C_TRACE,
144+
callType: "classify",
145+
startedAt: "2026-06-05T00:00:00.000Z",
146+
llmRequest: { text: "hi" },
147+
llmResponseText: '{"label":"greeting","score":1}',
148+
},
149+
{ recorder: new LlmCallDbRecorder(om, "ClassifyCall"), responseMo: classifyRes, format: Format.JSON },
150+
);
151+
await recordLlmCall(
152+
{
153+
spanId: S_SPAN,
154+
traceId: S_TRACE,
155+
callType: "summarize",
156+
startedAt: "2026-06-05T00:00:00.000Z",
157+
llmRequest: { doc: "..." },
158+
llmResponseText: '{"summary":"short"}',
159+
},
160+
{ recorder: new LlmCallDbRecorder(om, "SummarizeCall"), responseMo: summarizeRes, format: Format.JSON },
161+
);
162+
163+
const c = await om.findById("ClassifyCall", C_SPAN) as Record<string, unknown>;
164+
expect(c.callType).toBe("classify");
165+
expect(c.voResponse).toEqual({ label: "greeting", score: 1 });
166+
const s = await om.findById("SummarizeCall", S_SPAN) as Record<string, unknown>;
167+
expect(s.callType).toBe("summarize");
168+
expect(s.voResponse).toEqual({ summary: "short" });
169+
170+
await kysely.destroy();
171+
kysely = null;
172+
} finally {
173+
if (kysely !== null) await (kysely as Kysely<Record<string, never>>).destroy();
174+
await pgc.stop();
175+
}
176+
}, { timeout: 60_000 });
177+
});

0 commit comments

Comments
 (0)