fix(files): normalize bigint sizes before JSON serialization (#472)

Postgres returns BIGINT file sizes as native BigInt values. Returning those values directly from file_list makes JSON serialization fail, and using them in CLI arithmetic can also throw.

Convert size_bytes to Number at the operation boundary and in the CLI display path. File sizes remain exact well beyond any practical attachment size.

Add a unit regression that exercises a native BigInt row and proves the operation result is JSON-serializable, plus a real-Postgres E2E assertion for the file_list response.
This commit is contained in:
vinsew
2026-07-16 16:27:16 -07:00
committed by GitHub
parent 1e1b9a9441
commit d0447a597b
4 changed files with 51 additions and 7 deletions
+1 -2
View File
@@ -116,8 +116,7 @@ async function listFiles(engine: BrainEngine, slug?: string) {
console.log(`${rows.length} file(s):`);
for (const row of rows) {
const sizeBytes = row.size_bytes as number | null;
const size = sizeBytes ? `${Math.round(sizeBytes / 1024)}KB` : '?';
const size = row.size_bytes ? `${Math.round(Number(row.size_bytes) / 1024)}KB` : '?';
console.log(` ${row.page_slug || '(unlinked)'} / ${row.filename} [${size}, ${row.mime_type || '?'}]`);
}
}
+10 -4
View File
@@ -2686,10 +2686,16 @@ const file_list: Operation = {
handler: async (_ctx, p) => {
const sql = db.getConnection();
const slug = p.slug as string | undefined;
if (slug) {
return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files WHERE page_slug = ${slug} ORDER BY filename LIMIT ${FILE_LIST_LIMIT}`;
}
return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files ORDER BY page_slug, filename LIMIT ${FILE_LIST_LIMIT}`;
const rows = slug
? await sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files WHERE page_slug = ${slug} ORDER BY filename LIMIT ${FILE_LIST_LIMIT}`
: await sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files ORDER BY page_slug, filename LIMIT ${FILE_LIST_LIMIT}`;
// Postgres returns size_bytes (BIGINT) as native BigInt — JSON.stringify
// throws on those, breaking MCP callers. PGLite returns Number already.
// 9 PB ceiling (2^53 bytes) is far above any plausible file size.
return rows.map((r: Record<string, unknown>) => ({
...r,
size_bytes: r.size_bytes == null ? null : Number(r.size_bytes),
}));
},
};
+5
View File
@@ -582,6 +582,11 @@ describeE2E('E2E: Files', () => {
const files = await callOp('file_list', {}) as any[];
expect(files.length).toBe(1);
// Regression: Postgres BIGINT(size_bytes) returned native BigInt before
// v0.22.5 so the MCP serializer threw and CLI listFiles div-by-1024 threw.
expect(typeof files[0].size_bytes).toBe('number');
expect(() => JSON.stringify(files)).not.toThrow();
// Verify file_url returns URI format
const url = await callOp('file_url', { storage_path: result.storage_path }) as any;
expect(url.url).toContain('gbrain:files/');
+35 -1
View File
@@ -1,10 +1,12 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { describe, test, expect, beforeAll, afterAll, spyOn } from 'bun:test';
import { writeFileSync, mkdirSync, rmSync, symlinkSync, mkdtempSync } from 'fs';
import { join, basename } from 'path';
import { createHash } from 'crypto';
import { extname } from 'path';
import { tmpdir } from 'os';
import { collectFiles } from '../src/commands/files.ts';
import { operationsByName } from '../src/core/operations.ts';
import * as db from '../src/core/db.ts';
const TMP = join(import.meta.dir, '.tmp-files-test');
@@ -183,6 +185,38 @@ describe('collectFiles (production import)', () => {
}
});
test('file_list normalizes BigInt size_bytes for JSON serialization', async () => {
// Postgres BIGINT(size_bytes) returns native BigInt under postgres.js's
// {bigint: postgres.BigInt} type map. Both JSON.stringify (MCP) and the
// CLI's `size_bytes / 1024` divide trip on it. Regression for the bug
// openclaw's agent surfaced in v0.22.4.
const fakeRows = [
{ id: 1, page_slug: 'a', filename: 'f1', storage_path: 'a/f1',
mime_type: 'text/plain', size_bytes: 4096n, content_hash: 'h1',
created_at: '2026-04-27' },
{ id: 2, page_slug: 'a', filename: 'f2', storage_path: 'a/f2',
mime_type: null, size_bytes: null, content_hash: 'h2',
created_at: '2026-04-27' },
];
const fakeSql: any = (..._: unknown[]) => Promise.resolve(fakeRows);
const spy = spyOn(db, 'getConnection').mockReturnValue(fakeSql);
try {
const op = operationsByName['file_list'];
const ctx: any = { engine: null, config: {}, logger: { info() {}, warn() {}, error() {} }, dryRun: false, remote: true };
const result = await op.handler(ctx, {}) as Array<Record<string, unknown>>;
expect(result.length).toBe(2);
expect(typeof result[0].size_bytes).toBe('number');
expect(result[0].size_bytes).toBe(4096);
expect(result[1].size_bytes).toBeNull();
// The exact failure mode openclaw reported.
expect(() => JSON.stringify(result)).not.toThrow();
} finally {
spy.mockRestore();
}
});
test('collectFiles skips node_modules', () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-nodemod-'));
try {