From 3263e57705612345ee3ff959a9ac755fe3009857 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 21 Oct 2026 14:53:29 +0300 Subject: [PATCH 1/4] Say which column default is invalid when creating a table fails A numeric default that parseInt or parseFloat can't read is now rejected up front with a 400 that names the column and the value, instead of reaching Postgres as NaN or Infinity and failing with an unrelated error. A date default Postgres rejects is matched back to its column from the quoted value in the error, so it gets the same 400. Defaults that work today are left alone. --- forge/ee/routes/tables/index.js | 40 ++++++++++++ .../unit/forge/ee/routes/tables/index_spec.js | 65 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/forge/ee/routes/tables/index.js b/forge/ee/routes/tables/index.js index 1c5785e036..ceb80418cd 100644 --- a/forge/ee/routes/tables/index.js +++ b/forge/ee/routes/tables/index.js @@ -1,3 +1,35 @@ +// The drivers turn numeric defaults into SQL with parseInt/parseFloat. A value that comes out as +// NaN or Infinity lands in the query as a bare word, which Postgres rejects with an unrelated error. +function findInvalidNumericDefault (columns) { + return columns.find(col => { + if (!col.name || !col.default) { + return false + } + if (col.type === 'bigint') { + return isNaN(parseInt(col.default)) + } + if (col.type === 'real' || col.type === 'double precision') { + return !Number.isFinite(parseFloat(col.default)) + } + return false + }) +} + +// Postgres quotes a date it rejects but not the column it was for, so match the value back to it +const INVALID_DATE = /(?:invalid input syntax for type date|date\/time field value out of range): "(.*)"/ + +function findInvalidDateDefault (columns, err) { + const match = err.message.match(INVALID_DATE) + if (!match) { + return null + } + return columns.find(col => col.type === 'date' && col.default === match[1]) +} + +function invalidDefaultError (col) { + return { code: 'invalid_column_default', error: `Invalid default value "${col.default}" for ${col.type} column "${col.name}"` } +} + module.exports = async function (app) { // All routes are relative to /api/v1/teams/:teamId/databases @@ -327,6 +359,10 @@ module.exports = async function (app) { if (tables.tables.filter((t) => t.name === request.body.name && t.schema === request.body.schema).length === 1) { return reply.status(409).send({ code: 'table_exists', error: 'Table already exists' }) } + const invalidNumericDefault = findInvalidNumericDefault(request.body.columns) + if (invalidNumericDefault) { + return reply.status(400).send(invalidDefaultError(invalidNumericDefault)) + } try { await app.tables.createTable(request.team, request.params.databaseId, request.body.name, request.body.columns, request.body.schema) reply.status(201).send({ name: request.body.name, schema: request.body.schema }) @@ -335,6 +371,10 @@ module.exports = async function (app) { if (err.message.includes('Unsupported column type')) { return reply.status(400).send({ code: 'invalid_column_type', error: 'Unsupported column type. Supported types: bigint, bigserial, boolean, date, timestamptz, real, double precision, text' }) } + const invalidDateDefault = findInvalidDateDefault(request.body.columns, err) + if (invalidDateDefault) { + return reply.status(400).send(invalidDefaultError(invalidDateDefault)) + } app.log.error(`Create FF tables table error ${err.toString()}`) reply.status(500).send({ code: 'unexpected_error', error: 'Failed to create table' }) } diff --git a/test/unit/forge/ee/routes/tables/index_spec.js b/test/unit/forge/ee/routes/tables/index_spec.js index 7623da3974..b2f774d4e4 100644 --- a/test/unit/forge/ee/routes/tables/index_spec.js +++ b/test/unit/forge/ee/routes/tables/index_spec.js @@ -318,6 +318,71 @@ describe('Tables API', function () { response.statusCode.should.equal(400) }) + it('Fail to create a table with a numeric default that is not a number, naming the column', async function () { + const cases = [ + { name: 'count', type: 'bigint', default: 'abc' }, + { name: 'ratio', type: 'real', default: 'abc' }, + { name: 'ratio', type: 'double precision', default: 'Infinity' } + ] + for (const column of cases) { + const spy = sinon.spy(app.tables, 'createTable') + const response = await createTable({ name: 'baddefault', columns: [column] }) + spy.restore() + response.statusCode.should.equal(400, `${column.type} default '${column.default}' should be rejected`) + response.json().should.eql({ + code: 'invalid_column_default', + error: `Invalid default value "${column.default}" for ${column.type} column "${column.name}"` + }) + spy.called.should.be.false() + } + }) + + it('Create a table with numeric defaults the driver can already use', async function () { + // parseInt/parseFloat accept a leading number, so these work today and must keep working + const spy = sinon.spy(app.tables, 'createTable') + const response = await createTable({ + name: 'numericdefaults', + columns: [ + { name: 'count', type: 'bigint', default: '12abc' }, + { name: 'ratio', type: 'real', default: '1.5' } + ] + }) + spy.restore() + response.statusCode.should.equal(201) + spy.calledOnce.should.be.true() + }) + + it('Fail to create a table with a date default Postgres rejects, naming the column', async function () { + const messages = [ + 'invalid input syntax for type date: "not-a-date"', + 'date/time field value out of range: "not-a-date"' + ] + for (const message of messages) { + const stub = sinon.stub(app.tables, 'createTable').rejects(new Error(`Failed to create table baddate for team ${TestObjects.team.hashid}: ${message}`)) + const response = await createTable({ + name: 'baddate', + columns: [ + { name: 'id', type: 'bigserial' }, + { name: 'created', type: 'date', default: 'not-a-date' } + ] + }) + stub.restore() + response.statusCode.should.equal(400) + response.json().should.eql({ + code: 'invalid_column_default', + error: 'Invalid default value "not-a-date" for date column "created"' + }) + } + }) + + it('Keeps the generic error when a driver failure is not about a column default', async function () { + const stub = sinon.stub(app.tables, 'createTable').rejects(new Error('Failed to create table other: connection refused')) + const response = await createTable({ name: 'other', columns }) + stub.restore() + response.statusCode.should.equal(500) + response.json().should.have.property('error', 'Failed to create table') + }) + it('Create a table with an empty columns list', async function () { const response = await createTable({ name: 'emptycolumns', columns: [] }) response.statusCode.should.equal(201) From 6fb453ce49f1fa8e1f17edeed14fd13e0d0d1117 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 21 Oct 2026 15:53:27 +0300 Subject: [PATCH 2/4] Reject bigint column defaults outside the bigint range parseInt rounds a large bigint default into a value Postgres stores without complaint, so the table gets created and every insert that relies on the default then fails with bigint out of range. Such defaults now get the same 400 naming the column and value, before the driver runs. Values that only lose precision still create the table as they do today. --- forge/ee/routes/tables/index.js | 4 +++- test/unit/forge/ee/routes/tables/index_spec.js | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/forge/ee/routes/tables/index.js b/forge/ee/routes/tables/index.js index ceb80418cd..917a256006 100644 --- a/forge/ee/routes/tables/index.js +++ b/forge/ee/routes/tables/index.js @@ -1,12 +1,14 @@ // The drivers turn numeric defaults into SQL with parseInt/parseFloat. A value that comes out as // NaN or Infinity lands in the query as a bare word, which Postgres rejects with an unrelated error. +// A bigint outside its range still creates the table, but every insert relying on the default then fails. function findInvalidNumericDefault (columns) { return columns.find(col => { if (!col.name || !col.default) { return false } if (col.type === 'bigint') { - return isNaN(parseInt(col.default)) + const value = parseInt(col.default) + return isNaN(value) || value >= 2 ** 63 || value < -(2 ** 63) } if (col.type === 'real' || col.type === 'double precision') { return !Number.isFinite(parseFloat(col.default)) diff --git a/test/unit/forge/ee/routes/tables/index_spec.js b/test/unit/forge/ee/routes/tables/index_spec.js index b2f774d4e4..4480160989 100644 --- a/test/unit/forge/ee/routes/tables/index_spec.js +++ b/test/unit/forge/ee/routes/tables/index_spec.js @@ -321,6 +321,8 @@ describe('Tables API', function () { it('Fail to create a table with a numeric default that is not a number, naming the column', async function () { const cases = [ { name: 'count', type: 'bigint', default: 'abc' }, + { name: 'count', type: 'bigint', default: '99999999999999999999999' }, + { name: 'count', type: 'bigint', default: '9223372036854775807' }, { name: 'ratio', type: 'real', default: 'abc' }, { name: 'ratio', type: 'double precision', default: 'Infinity' } ] @@ -344,6 +346,7 @@ describe('Tables API', function () { name: 'numericdefaults', columns: [ { name: 'count', type: 'bigint', default: '12abc' }, + { name: 'big', type: 'bigint', default: '9007199254740993' }, { name: 'ratio', type: 'real', default: '1.5' } ] }) From 7fc0cccd96b0969bdd0858c4c0f1078b19a7e2ef Mon Sep 17 00:00:00 2001 From: Andrea Palmieri <76187074+andypalmi@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:35:15 +0200 Subject: [PATCH 3/4] Update forge/ee/routes/tables/index.js --- forge/ee/routes/tables/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/ee/routes/tables/index.js b/forge/ee/routes/tables/index.js index 917a256006..cd0c5a1a06 100644 --- a/forge/ee/routes/tables/index.js +++ b/forge/ee/routes/tables/index.js @@ -8,7 +8,7 @@ function findInvalidNumericDefault (columns) { } if (col.type === 'bigint') { const value = parseInt(col.default) - return isNaN(value) || value >= 2 ** 63 || value < -(2 ** 63) + return isNaN(value) || value >= 2 ** 63 || value <= -(2 ** 63) } if (col.type === 'real' || col.type === 'double precision') { return !Number.isFinite(parseFloat(col.default)) From 8772ff5447726d1c440b762fa3445cbddc887978 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Thu, 22 Oct 2026 14:37:03 +0200 Subject: [PATCH 4/4] test(tables): reject the bigint minimum as a column default --- test/unit/forge/ee/routes/tables/index_spec.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/forge/ee/routes/tables/index_spec.js b/test/unit/forge/ee/routes/tables/index_spec.js index 4480160989..a4a6829261 100644 --- a/test/unit/forge/ee/routes/tables/index_spec.js +++ b/test/unit/forge/ee/routes/tables/index_spec.js @@ -323,6 +323,7 @@ describe('Tables API', function () { { name: 'count', type: 'bigint', default: 'abc' }, { name: 'count', type: 'bigint', default: '99999999999999999999999' }, { name: 'count', type: 'bigint', default: '9223372036854775807' }, + { name: 'count', type: 'bigint', default: '-9223372036854775808' }, { name: 'ratio', type: 'real', default: 'abc' }, { name: 'ratio', type: 'double precision', default: 'Infinity' } ]