feat(giellie): chat store and giellie_chats migration

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-30 23:44:55 +02:00
parent 8500a08482
commit b35159d859
4 changed files with 350 additions and 0 deletions

View file

@ -19,6 +19,7 @@
"pb:migrate:pos-slip-layouts": "npx tsx scripts/migrations/2026-09-04-create-pos-slip-layouts.ts",
"pb:migrate:audit-log": "npx tsx scripts/migrations/2026-09-25-create-audit-log.ts",
"pb:migrate:rep-trip-legs": "npx tsx scripts/migrations/2026-09-28-create-rep-trip-legs.ts",
"pb:migrate:giellie-chats": "npx tsx scripts/migrations/2026-09-30-create-giellie-chats.ts",
"pb:migrate:place-overrides": "npx tsx scripts/migrations/2026-09-28-create-place-overrides.ts",
"pb:migrate:drop-orders-status": "npx tsx scripts/migrations/2026-06-27-drop-orders-status-and-wipe.ts",
"pb:migrate:add-order-quote-match": "npx tsx scripts/migrations/2026-06-27-add-order-quote-match-fields.ts",

View file

@ -0,0 +1,79 @@
/**
* One-shot migration: create the `giellie_chats` collection (Giellie agent
* chat history — ONE collection; transcript lives in the `messages` json).
*
* Run with:
*
* npm run pb:migrate:giellie-chats
*
* Reads/writes go through the server's admin client; the API rules below
* only matter for direct PB access: a user can list/view their own chats,
* nobody can create/update/delete via the public API.
*
* Idempotent — exits early if the collection exists.
* Rollback: delete the `giellie_chats` collection in the PB admin UI.
*/
import "./_env-loader";
import PocketBase from "pocketbase";
const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
async function main() {
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD (e.g. from .env.local).");
}
const pb = new PocketBase(PB_URL);
await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
console.log(`[giellie-chats] Authenticated against ${PB_URL}`);
const all = await pb.collections.getFullList();
if (all.find((c) => c.name === "giellie_chats")) {
console.log("[giellie-chats] giellie_chats already exists — skipping.");
return;
}
const users = all.find((c) => c.name === "users");
if (!users) throw new Error("users collection not found");
await pb.collections.create({
name: "giellie_chats",
type: "base",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "user",
type: "relation",
required: true,
collectionId: users.id,
maxSelect: 1,
cascadeDelete: true,
},
// Explicit max: PB treats max 0 as a 5000-char cap, not unlimited.
{ name: "title", type: "text", required: false, max: 200 },
{ name: "messages", type: "json", required: false, maxSize: 5000000 },
{ name: "pending_action", type: "json", required: false, maxSize: 1000000 },
{
name: "attachments",
type: "file",
required: false,
maxSelect: 50,
maxSize: 10485760,
mimeTypes: [],
},
{ name: "created", type: "autodate", onCreate: true, onUpdate: false },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE INDEX idx_giellie_chats_user ON giellie_chats (user, updated)"],
});
console.log("[giellie-chats] Created giellie_chats collection.");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const getOne = vi.fn();
const getFullList = vi.fn();
const create = vi.fn();
const update = vi.fn();
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: (name: string) => {
if (name !== "giellie_chats") throw new Error(`unexpected collection ${name}`);
return { getOne, getFullList, create, update };
},
}),
}));
import {
createChat,
listChats,
getChat,
saveChat,
appendAttachments,
compactMessages,
} from "./giellie-chat-store";
const ID = "abcde12345fghij"; // 15 chars
beforeEach(() => {
getOne.mockReset();
getFullList.mockReset();
create.mockReset();
update.mockReset();
});
const row = (over: Record<string, unknown> = {}) => ({
id: ID,
user: "user1",
title: "T",
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], ts: "t" }],
pending_action: null,
attachments: [],
updated: "2026-09-30 10:00:00.000Z",
...over,
});
describe("createChat", () => {
it("creates an empty chat owned by the user", async () => {
create.mockResolvedValue(row({ title: "", messages: [] }));
const chat = await createChat("user1");
expect(create).toHaveBeenCalledWith({ user: "user1", title: "", messages: [], pending_action: null });
expect(chat.messages).toEqual([]);
});
});
describe("listChats", () => {
it("filters by user id and sorts newest first", async () => {
getFullList.mockResolvedValue([row()]);
const out = await listChats("user1");
expect(getFullList).toHaveBeenCalledWith(
expect.objectContaining({ filter: 'user = "user1"', sort: "-updated" }),
);
expect(out).toEqual([{ id: ID, title: "T", updated: "2026-09-30 10:00:00.000Z" }]);
});
it("rejects a malformed user id (filter injection)", async () => {
await expect(listChats('x" || 1=1 || "')).rejects.toMatchObject({ status: 404 });
});
});
describe("getChat", () => {
it("returns the chat for its owner", async () => {
getOne.mockResolvedValue(row());
const chat = await getChat(ID, "user1");
expect(chat.id).toBe(ID);
expect(chat.messages).toHaveLength(1);
});
it("404s for a different user", async () => {
getOne.mockResolvedValue(row({ user: "someoneelse" }));
await expect(getChat(ID, "user1")).rejects.toMatchObject({ status: 404 });
});
it("404s when PB cannot find it or the id is malformed", async () => {
getOne.mockRejectedValue(new Error("not found"));
await expect(getChat(ID, "user1")).rejects.toMatchObject({ status: 404 });
await expect(getChat("../x", "user1")).rejects.toMatchObject({ status: 404 });
});
it("tolerates null json fields", async () => {
getOne.mockResolvedValue(row({ messages: null, pending_action: "" }));
const chat = await getChat(ID, "user1");
expect(chat.messages).toEqual([]);
expect(chat.pending_action).toBeNull();
});
});
describe("compactMessages", () => {
it("truncates oversized tool results without mutating the input", () => {
const big = "y".repeat(20_000);
const input = [
{ role: "user" as const, ts: "t", content: [{ type: "tool_result", tool_use_id: "a", content: big }] },
];
const out = compactMessages(input);
expect((out[0].content[0].content as string).length).toBeLessThan(9_000);
expect(String(out[0].content[0].content)).toContain("truncated");
expect(input[0].content[0].content).toBe(big);
});
});
describe("saveChat", () => {
it("writes compacted messages and pending_action", async () => {
update.mockResolvedValue({});
await saveChat(ID, { title: "New", messages: [], pending_action: null });
expect(update).toHaveBeenCalledWith(ID, { title: "New", messages: [], pending_action: null });
});
it("omits undefined patch keys", async () => {
update.mockResolvedValue({});
await saveChat(ID, { title: "Only" });
expect(update).toHaveBeenCalledWith(ID, { title: "Only" });
});
});
describe("appendAttachments", () => {
it("uploads using the PB append modifier", async () => {
update.mockResolvedValue({});
await appendAttachments(ID, [
{ filename: "a.jpg", contentType: "image/jpeg", bytes: Buffer.from("x") },
]);
const [id, body] = update.mock.calls[0];
expect(id).toBe(ID);
expect(body).toBeInstanceOf(FormData);
expect((body as FormData).getAll("attachments+")).toHaveLength(1);
});
it("does nothing for an empty list", async () => {
await appendAttachments(ID, []);
expect(update).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,137 @@
/**
* Persistence for Giellie chats — ONE PB collection, `giellie_chats`
* (see docs/superpowers/specs/2026-09-30-giellie-agent-design.md).
*
* All access goes through the admin client; ownership is enforced here
* (`user` must equal the verified session user) because the PB API rules
* are never exercised by these server calls.
*/
import { getAdminPocketBase } from "./pocketbase-admin";
import type { UploadedFile } from "./giellie-attachments";
import type {
GielieChat,
GielieChatSummary,
PendingAction,
StoredMessage,
} from "@/lib/giellie-types";
const COLLECTION = "giellie_chats";
const ID_RE = /^[a-z0-9]{15}$/i;
// User ids are interpolated into a PB filter string, so restrict them to a
// safe alphabet (no quotes / operators). Looser than ID_RE on length only.
const USER_ID_RE = /^[a-z0-9_-]{1,64}$/i;
export const STORED_RESULT_MAX_CHARS = 8_000;
function notFound(): Error & { status: number } {
return Object.assign(new Error("Chat not found."), { status: 404 });
}
function assertId(id: string): void {
if (!ID_RE.test(id)) throw notFound();
}
function assertUserId(id: string): void {
if (!USER_ID_RE.test(id)) throw notFound();
}
interface Row {
id: string;
user: string;
title?: string;
messages?: unknown;
pending_action?: unknown;
attachments?: unknown;
updated?: string;
}
function toChat(row: Row): GielieChat {
return {
id: row.id,
title: row.title ?? "",
updated: row.updated ?? "",
messages: Array.isArray(row.messages) ? (row.messages as StoredMessage[]) : [],
pending_action:
row.pending_action && typeof row.pending_action === "object"
? (row.pending_action as PendingAction)
: null,
attachments: Array.isArray(row.attachments) ? (row.attachments as string[]) : [],
};
}
/** Pure: shorten huge tool results for storage. Never mutates the input. */
export function compactMessages(messages: StoredMessage[]): StoredMessage[] {
return messages.map((m) => ({
...m,
content: m.content.map((b) => {
if (
b.type === "tool_result" &&
typeof b.content === "string" &&
b.content.length > STORED_RESULT_MAX_CHARS
) {
return {
...b,
content:
b.content.slice(0, STORED_RESULT_MAX_CHARS) +
`\n[... truncated for storage: ${b.content.length} characters; re-run the query if you need the rest ...]`,
};
}
return b;
}),
}));
}
export async function createChat(userId: string): Promise<GielieChat> {
assertUserId(userId);
const pb = await getAdminPocketBase();
const row = (await pb
.collection(COLLECTION)
.create({ user: userId, title: "", messages: [], pending_action: null })) as unknown as Row;
return toChat(row);
}
export async function listChats(userId: string): Promise<GielieChatSummary[]> {
assertUserId(userId);
const pb = await getAdminPocketBase();
const rows = (await pb.collection(COLLECTION).getFullList({
filter: `user = "${userId}"`,
sort: "-updated",
fields: "id,title,updated",
})) as unknown as Row[];
return rows.map((r) => ({ id: r.id, title: r.title ?? "", updated: r.updated ?? "" }));
}
export async function getChat(id: string, userId: string): Promise<GielieChat> {
assertId(id);
const pb = await getAdminPocketBase();
let row: Row;
try {
row = (await pb.collection(COLLECTION).getOne(id)) as unknown as Row;
} catch {
throw notFound();
}
if (row.user !== userId) throw notFound();
return toChat(row);
}
export async function saveChat(
id: string,
patch: { title?: string; messages?: StoredMessage[]; pending_action?: PendingAction | null },
): Promise<void> {
assertId(id);
const body: Record<string, unknown> = {};
if (patch.title !== undefined) body.title = patch.title;
if (patch.messages !== undefined) body.messages = compactMessages(patch.messages);
if (patch.pending_action !== undefined) body.pending_action = patch.pending_action;
const pb = await getAdminPocketBase();
await pb.collection(COLLECTION).update(id, body);
}
/** Uploads originals to the multi-file `attachments` field (append modifier). */
export async function appendAttachments(id: string, files: UploadedFile[]): Promise<void> {
assertId(id);
if (files.length === 0) return;
const fd = new FormData();
for (const f of files) {
fd.append("attachments+", new Blob([new Uint8Array(f.bytes)], { type: f.contentType }), f.filename);
}
const pb = await getAdminPocketBase();
await pb.collection(COLLECTION).update(id, fd);
}