[codex] fix cross-database JSON boundary handling (#283)

* fix: normalize cross-database json boundaries

* fix: address json boundary review comments

---------

Co-authored-by: apple <apple@appledeMacBook-Pro.local>
This commit is contained in:
Cita
2026-03-26 20:21:19 +08:00
committed by GitHub
parent 7e1fb8eb1b
commit b3387dfba7
30 changed files with 1134 additions and 97 deletions
+47 -2
View File
@@ -1,7 +1,7 @@
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
type DbModule = typeof import('./index.js');
@@ -14,6 +14,10 @@ describe('db proxy query wrapper', () => {
testUtils = dbModule.__dbProxyTestUtils;
});
beforeEach(() => {
testUtils.resetPostgresJsonTextParsersInstallStateForTests();
});
it('wraps thenable query builders and provides all/get shims', async () => {
const execute = vi.fn(async () => [{ id: 1, name: 'demo' }]);
const queryLike = {
@@ -53,5 +57,46 @@ describe('db proxy query wrapper', () => {
expect(runResult).toEqual({ changes: 3, lastInsertRowid: 9 });
expect(testUtils.shouldWrapObject(Promise.resolve())).toBe(false);
});
});
it('builds mysql pool options with jsonStrings enabled', () => {
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', false)).toMatchObject({
uri: 'mysql://root:pass@db.example.com:3306/metapi',
jsonStrings: true,
});
expect(testUtils.buildMysqlPoolOptions('mysql://root:pass@db.example.com:3306/metapi', true)).toMatchObject({
uri: 'mysql://root:pass@db.example.com:3306/metapi',
jsonStrings: true,
ssl: { rejectUnauthorized: false },
});
});
it('builds postgres pool options with ssl when requested', () => {
expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', false)).toEqual({
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
});
expect(testUtils.buildPostgresPoolOptions('postgres://user:pass@db.example.com:5432/metapi', true)).toMatchObject({
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
ssl: { rejectUnauthorized: false },
});
});
it('installs postgres JSON text parsers idempotently', () => {
const setTypeParser = vi.fn();
const fakeTypes = {
builtins: {
JSON: 114,
JSONB: 3802,
},
setTypeParser,
};
testUtils.installPostgresJsonTextParsers(fakeTypes as any);
testUtils.installPostgresJsonTextParsers(fakeTypes as any);
expect(setTypeParser).toHaveBeenCalledTimes(2);
expect(setTypeParser).toHaveBeenNthCalledWith(1, 114, 'text', expect.any(Function));
expect(setTypeParser).toHaveBeenNthCalledWith(2, 3802, 'text', expect.any(Function));
const parser = setTypeParser.mock.calls[0][2] as (value: string) => string;
expect(parser('{"ok":true}')).toBe('{"ok":true}');
});
});
+38 -9
View File
@@ -5,6 +5,10 @@ import { drizzle as drizzleSqliteProxy } from 'drizzle-orm/sqlite-proxy';
import { drizzle as drizzleMysqlProxy } from 'drizzle-orm/mysql-proxy';
import { drizzle as drizzlePgProxy } from 'drizzle-orm/pg-proxy';
import * as schema from './schema.js';
import {
installPostgresJsonTextParsers,
resetPostgresJsonTextParsersInstallStateForTests,
} from './postgresJsonTextParsers.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js';
import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js';
@@ -46,6 +50,31 @@ let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null;
let proxyLogClientColumnsAvailable: boolean | null = null;
function buildMysqlPoolOptions(
connectionString = config.dbUrl,
sslEnabled = config.dbSsl,
): mysql.PoolOptions {
const poolOptions: mysql.PoolOptions = {
uri: connectionString,
jsonStrings: true,
};
if (sslEnabled) {
poolOptions.ssl = { rejectUnauthorized: false };
}
return poolOptions;
}
function buildPostgresPoolOptions(
connectionString = config.dbUrl,
sslEnabled = config.dbSsl,
): pg.PoolConfig {
const poolOptions: pg.PoolConfig = { connectionString };
if (sslEnabled) {
poolOptions.ssl = { rejectUnauthorized: false };
}
return poolOptions;
}
function resolveSqlitePath(): string {
const raw = (config.dbUrl || '').trim();
if (!raw) {
@@ -1238,11 +1267,7 @@ function initMysqlDb(): AppDb {
if (!config.dbUrl) {
throw new Error('DB_URL is required when DB_TYPE=mysql');
}
const poolOptions: mysql.PoolOptions = { uri: config.dbUrl };
if (config.dbSsl) {
poolOptions.ssl = { rejectUnauthorized: false };
}
mysqlPool = mysql.createPool(poolOptions);
mysqlPool = mysql.createPool(buildMysqlPoolOptions());
const rawDb = drizzleMysqlProxy(
(sqlText, params, method) => mysqlProxyQuery(mysqlPool!, sqlText, params, method as SqlMethod),
@@ -1274,10 +1299,8 @@ function initPostgresDb(): AppDb {
if (!config.dbUrl) {
throw new Error('DB_URL is required when DB_TYPE=postgres');
}
const poolOptions: pg.PoolConfig = { connectionString: config.dbUrl };
if (config.dbSsl) {
poolOptions.ssl = { rejectUnauthorized: false };
}
installPostgresJsonTextParsers();
const poolOptions = buildPostgresPoolOptions();
pgPool = new pg.Pool(poolOptions);
const rawDb = drizzlePgProxy(
@@ -1375,4 +1398,10 @@ export const __dbProxyTestUtils = {
shouldWrapObject,
resolveSqlitePath,
resolveVitestSqlitePath,
buildMysqlPoolOptions,
buildPostgresPoolOptions,
installPostgresJsonTextParsers,
ensurePostgresJsonTextParsers: installPostgresJsonTextParsers,
resetPostgresJsonTextParsersInstallStateForTests,
pg,
};
+23
View File
@@ -0,0 +1,23 @@
import pg from 'pg';
export type PgTypesLike = {
builtins: {
JSON: number;
JSONB: number;
};
setTypeParser(oid: number, format: 'text', parser: (value: string) => string): void;
};
let postgresJsonTextParsersInstalled = false;
export function installPostgresJsonTextParsers(typesRegistry: PgTypesLike = pg.types as PgTypesLike): void {
if (postgresJsonTextParsersInstalled) return;
const identity = (value: string) => value;
typesRegistry.setTypeParser(typesRegistry.builtins.JSON, 'text', identity);
typesRegistry.setTypeParser(typesRegistry.builtins.JSONB, 'text', identity);
postgresJsonTextParsersInstalled = true;
}
export function resetPostgresJsonTextParsersInstallStateForTests(): void {
postgresJsonTextParsersInstalled = false;
}
+2
View File
@@ -12,6 +12,7 @@ import {
generateUpgradeSql,
type MysqlIndexPrefixRequirementMap,
} from './schemaArtifactGenerator.js';
import { installPostgresJsonTextParsers } from './postgresJsonTextParsers.js';
import { introspectLiveSchema } from './schemaIntrospection.js';
import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js';
@@ -324,6 +325,7 @@ async function createPostgresClient(connectionString: string, ssl: boolean): Pro
if (ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
installPostgresJsonTextParsers();
const client = new pg.Client(clientOptions);
await client.connect();
+3
View File
@@ -19,6 +19,7 @@ import type {
SchemaContractUnique,
} from './schemaContract.js';
import { resolveMigrationsFolder } from './schemaContract.js';
import { installPostgresJsonTextParsers } from './postgresJsonTextParsers.js';
import {
normalizeLogicalColumnType,
type LogicalColumnType,
@@ -535,6 +536,7 @@ async function introspectPostgresSchema(input: SchemaIntrospectionInput): Promis
if (input.ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
installPostgresJsonTextParsers();
const client = new pg.Client(clientOptions);
await client.connect();
@@ -785,6 +787,7 @@ async function applyPostgresStatements(
if (ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
installPostgresJsonTextParsers();
const client = new pg.Client(clientOptions);
await client.connect();
try {