diff --git a/packages/cloudflare/test/utils/internalSqlQuery.test.ts b/packages/cloudflare/test/utils/internalSqlQuery.test.ts index 7d119eb8a98f..1d001b4a5150 100644 --- a/packages/cloudflare/test/utils/internalSqlQuery.test.ts +++ b/packages/cloudflare/test/utils/internalSqlQuery.test.ts @@ -20,6 +20,17 @@ describe('targetsCloudflareInternalTable', () => { ['cf_ai_ prefix', 'INSERT INTO cf_ai_chat_stream_chunks (id) VALUES (?)'], ['cf_mcp_ prefix', 'SELECT * FROM cf_mcp_agent_event'], ['schema version', 'SELECT version FROM cf_schema_version'], + // SQLite upsert forms used by the agents framework for state/schedule/MCP persistence + ['INSERT OR REPLACE', 'INSERT OR REPLACE INTO cf_agents_state (id, state) VALUES (?, ?)'], + [ + 'INSERT OR REPLACE with column list', + `INSERT OR REPLACE INTO cf_agents_mcp_servers ( id, name, server_url, client_id, auth_url, + callback_url, server_options ) + VALUES ( ?, ?, ?, ?, ?, ?, ? )`, + ], + ['INSERT OR IGNORE', 'INSERT OR IGNORE INTO cf_agents_sub_agents (class, name) VALUES (?, ?)'], + ['REPLACE INTO', 'REPLACE INTO cf_agents_queues (id, payload) VALUES (?, ?)'], + ['UPDATE OR REPLACE', 'UPDATE OR REPLACE cf_agents_state SET state = ? WHERE id = ?'], ])('returns true for %s on internal tables', (_label, query) => { expect(targetsCloudflareInternalTable(summarize(query))).toBe(true); }); @@ -55,6 +66,9 @@ describe('targetsCloudflareInternalTable', () => { ['CREATE TABLE', 'CREATE TABLE users (id TEXT PRIMARY KEY)'], ['table with cf in the middle', 'SELECT * FROM my_cf_table'], ['table starting with cfg', 'SELECT * FROM cfg_settings'], + ['INSERT OR REPLACE', 'INSERT OR REPLACE INTO users (id, name) VALUES (?, ?)'], + ['REPLACE INTO', 'REPLACE INTO sessions (id, token) VALUES (?, ?)'], + ['UPDATE OR IGNORE', 'UPDATE OR IGNORE products SET price = ? WHERE id = ?'], ])('returns false for %s on user tables', (_label, query) => { expect(targetsCloudflareInternalTable(summarize(query))).toBe(false); }); @@ -69,6 +83,14 @@ describe('targetsCloudflareInternalTable', () => { expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_reports_daily'), [/^cf_reports_/])).toBe(false); }); + it('returns false for an allowlisted table targeted by an upsert', () => { + expect( + targetsCloudflareInternalTable(summarize('INSERT OR REPLACE INTO cf_my_table (id) VALUES (?)'), [ + 'cf_my_table', + ]), + ).toBe(false); + }); + it('requires an exact match for string entries', () => { // Substring matches must not opt a table back in, otherwise `cf_` would allowlist everything. expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_agents'])).toBe(true); diff --git a/packages/core/src/utils/sql.ts b/packages/core/src/utils/sql.ts index 930cf08cfb5e..45c5c4fd19aa 100644 --- a/packages/core/src/utils/sql.ts +++ b/packages/core/src/utils/sql.ts @@ -8,8 +8,18 @@ const DDL_RE = new RegExp( 'i', ); -const INSERT_RE = new RegExp(`^\\s*(?INSERT)\\s+INTO\\s+(?${TABLE_NAME})`, 'i'); -const UPDATE_RE = new RegExp(`^\\s*(?UPDATE)\\s+(?
${TABLE_NAME})`, 'i'); +// SQLite upserts insert an optional conflict clause between operation and INTO +// (`INSERT OR REPLACE INTO`, https://sqlite.org/lang_insert.html), with `REPLACE INTO` as the +// standalone shorthand. The clause is filler like INTO — stripping it keeps upserts on the same +// low-cardinality summary as plain inserts. +const INSERT_RE = new RegExp( + `^\\s*(?INSERT|REPLACE)(?:\\s+OR\\s+(?:ROLLBACK|ABORT|FAIL|IGNORE|REPLACE))?\\s+INTO\\s+(?
${TABLE_NAME})`, + 'i', +); +const UPDATE_RE = new RegExp( + `^\\s*(?UPDATE)(?:\\s+OR\\s+(?:ROLLBACK|ABORT|FAIL|IGNORE|REPLACE))?\\s+(?
${TABLE_NAME})`, + 'i', +); const DELETE_RE = new RegExp(`^\\s*(?DELETE)\\s+FROM\\s+(?
${TABLE_NAME})`, 'i'); const SELECT_RE = /^\s*\(?\s*(?SELECT)\b/i; diff --git a/packages/core/test/lib/utils/sql.test.ts b/packages/core/test/lib/utils/sql.test.ts index 4cebd5c423b8..7e5bb2140c38 100644 --- a/packages/core/test/lib/utils/sql.test.ts +++ b/packages/core/test/lib/utils/sql.test.ts @@ -75,12 +75,39 @@ describe('getSqlQuerySummary', () => { 'INSERT shipping_details SELECT orders', ); }); + + it.each([ + ['INSERT OR REPLACE INTO users (id) VALUES (?)', 'INSERT users'], + ['INSERT OR IGNORE INTO users (id) VALUES (?)', 'INSERT users'], + ['INSERT OR ABORT INTO users (id) VALUES (?)', 'INSERT users'], + ['INSERT OR FAIL INTO users (id) VALUES (?)', 'INSERT users'], + ['INSERT OR ROLLBACK INTO users (id) VALUES (?)', 'INSERT users'], + ['insert or replace into orders (id) values (?)', 'insert orders'], + ])('strips the SQLite conflict clause: %j => %j', (input, expected) => { + expect(getSqlQuerySummary(input)).toBe(expected); + }); + + it.each([ + ['REPLACE INTO users (id) VALUES (?)', 'REPLACE users'], + ['replace into orders (id) values (?)', 'replace orders'], + ['REPLACE INTO shipping_details SELECT * FROM orders', 'REPLACE shipping_details SELECT orders'], + ])('handles the REPLACE INTO shorthand: %j => %j', (input, expected) => { + expect(getSqlQuerySummary(input)).toBe(expected); + }); + + it('captures INSERT OR REPLACE...SELECT with both targets', () => { + expect(getSqlQuerySummary('INSERT OR REPLACE INTO shipping_details SELECT * FROM orders')).toBe( + 'INSERT shipping_details SELECT orders', + ); + }); }); describe('UPDATE', () => { it.each([ ['UPDATE users SET name = ? WHERE id = ?', 'UPDATE users'], ['update orders SET status = ? WHERE created_at < ?', 'update orders'], + ['UPDATE OR REPLACE users SET name = ? WHERE id = ?', 'UPDATE users'], + ['UPDATE OR IGNORE orders SET status = ?', 'UPDATE orders'], ])('%j => %j', (input, expected) => { expect(getSqlQuerySummary(input)).toBe(expected); });