mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
@@ -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
@@ -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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user