// Copyright (c) 2023 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0
import * as assert from 'node:assert';
import { DurableObject } from 'cloudflare:workers';
// A collection of functions that can be triggered by name from the DO, to make
// it easier to keep the DO and test functions together in the test file:
let actorFuncs = {};
async function test(state) {
const storage = state.storage;
const sql = storage.sql;
// Test numeric results
const resultNumber = [...sql.exec('SELECT 123')];
assert.equal(resultNumber.length, 1);
assert.equal(resultNumber[0]['123'], 123);
// Test raw results
const resultNumberRaw = [...sql.exec('SELECT 123').raw()];
assert.equal(resultNumberRaw.length, 1);
assert.equal(resultNumberRaw[0].length, 1);
assert.equal(resultNumberRaw[0][0], 123);
sql.exec('SELECT 123');
sql.exec('SELECT 123');
sql.exec('SELECT 123');
// Test string results
const resultStr = [...sql.exec("SELECT 'hello'")];
assert.equal(resultStr.length, 1);
assert.equal(resultStr[0]["'hello'"], 'hello');
// Test blob results
const resultBlob = [...sql.exec("SELECT x'ff' as blob")];
assert.equal(resultBlob.length, 1);
const blob = new Uint8Array(resultBlob[0].blob);
assert.equal(blob.length, 1);
assert.equal(blob[0], 255);
{
// Test binding values
const result = [...sql.exec('SELECT ?', 456)];
assert.equal(result.length, 1);
assert.equal(result[0]['?'], 456);
}
{
// Test multiple binding values
const result = [...sql.exec('SELECT ? + ?', 123, 456)];
assert.equal(result.length, 1);
assert.equal(result[0]['? + ?'], 579);
}
{
// Test multiple rows
const result = [
...sql.exec(
'SELECT 1 AS value\n' +
'UNION ALL\n' +
'SELECT 2 AS value\n' +
'UNION ALL\n' +
'SELECT 3 AS value;'
),
];
assert.equal(result.length, 3);
assert.equal(result[0]['value'], 1);
assert.equal(result[1]['value'], 2);
assert.equal(result[2]['value'], 3);
}
{
// Test multiple rows, manual iteration with next().
const cursor = sql.exec(
'SELECT 1 AS col\n' +
'UNION ALL\n' +
'SELECT "foo" AS col\n' +
'UNION ALL\n' +
'SELECT 3 AS col;'
);
assert.deepEqual(cursor.next(), { done: false, value: { col: 1 } });
assert.deepEqual(cursor.next(), { done: false, value: { col: 'foo' } });
assert.deepEqual(cursor.next(), { done: false, value: { col: 3 } });
assert.deepEqual(cursor.next(), { done: true });
}
{
// Test multiple rows using .toArray()
const cursor = sql.exec(
'SELECT 1 AS value\n' +
'UNION ALL\n' +
'SELECT "foo" AS value\n' +
'UNION ALL\n' +
'SELECT 3 AS value;'
);
assert.deepEqual(cursor.toArray(), [
{ value: 1 },
{ value: 'foo' },
{ value: 3 },
]);
}
{
// Test one row with .one()
let cursor = sql.exec('SELECT 123 AS foo, "abc" AS bar');
assert.deepEqual(cursor.one(), { foo: 123, bar: 'abc' });
// Cursor has been consumed.
assert.deepEqual([...cursor], []);
// Multiple results throws.
assert.throws(
() =>
sql
.exec(
'SELECT 1 AS value\n' + 'UNION ALL\n' + 'SELECT "foo" AS value;'
)
.one(),
/Expected exactly one result from SQL query, but got multiple results/
);
// No results throws.
sql.exec('CREATE TABLE IF NOT EXISTS empty (x INTEGER)');
assert.throws(
() => sql.exec('SELECT * from empty;').one(),
/Expected exactly one result from SQL query, but got no results/
);
sql.exec('DROP TABLE empty');
}
// Test partial query ingestion
assert.deepEqual(sql.ingest(`SELECT 123; SELECT 456; `).remainder, ' ');
assert.deepEqual(sql.ingest(`SELECT 123; SELECT 456;`).remainder, '');
assert.deepEqual(
sql.ingest(`SELECT 123; SELECT 456`).remainder,
' SELECT 456'
);
assert.deepEqual(sql.ingest(`SELECT 123; SELECT 45`).remainder, ' SELECT 45');
assert.deepEqual(sql.ingest(`SELECT 123; SELECT 4`).remainder, ' SELECT 4');
assert.deepEqual(sql.ingest(`SELECT 123; SELECT `).remainder, ' SELECT ');
assert.deepEqual(sql.ingest(`SELECT 123; SELECT`).remainder, ' SELECT');
assert.deepEqual(sql.ingest(`SELECT 123; SELEC`).remainder, ' SELEC');
assert.deepEqual(sql.ingest(`SELECT 123; SELE`).remainder, ' SELE');
assert.deepEqual(sql.ingest(`SELECT 123; SEL`).remainder, ' SEL');
assert.deepEqual(sql.ingest(`SELECT 123; SE`).remainder, ' SE');
assert.deepEqual(sql.ingest(`SELECT 123; S`).remainder, ' S');
assert.deepEqual(sql.ingest(`SELECT 123; `).remainder, ' ');
assert.deepEqual(sql.ingest(`SELECT 123;`).remainder, '');
assert.deepEqual(sql.ingest(`SELECT 123`).remainder, 'SELECT 123');
assert.deepEqual(sql.ingest(`SELECT 12`).remainder, 'SELECT 12');
assert.deepEqual(sql.ingest(`SELECT 1`).remainder, 'SELECT 1');
// Exec throws with trailing comments
assert.throws(
() => sql.exec('SELECT 123; SELECT 456; -- trailing comment'),
/SQL code did not contain a statement/
);
// Ingest does not
assert.deepEqual(
sql.ingest(`SELECT 123; SELECT 456; -- trailing comment`).remainder,
' -- trailing comment'
);
// Ingest throws if statement looks "complete" but is actually a syntax error:
assert.throws(
() => sql.ingest(`SELECT * bunk;`),
/Error: near "bunk": syntax error at offset/
);
assert.throws(
() => sql.ingest(`INSER INTO xyz VALUES ('a'),('b');`),
/Error: near "INSER": syntax error/
);
assert.throws(
() => sql.ingest(`INSERT INTO xyz VALUES ('a')('b');`),
/Error: near "\(": syntax error/
);
// Test execution of ingested queries by taking an input of 6 INSERT statements, that all
// add 6 rows of data, then splitting that into a bunch of chunks, then ingesting them all
{
sql.exec(`CREATE TABLE streaming(val TEXT);`);
// Convert to binary otherwise .split can cause corruption for multi-byte chars
const inputBytes = new TextEncoder().encode(INSERT_36_ROWS);
const decoder = new TextDecoder();
// Use a chunk size 1, 3, 9, 27, 81, ... bytes
for (let length = 1; length < inputBytes.length; length = length * 3) {
let totalRowsWritten = 0;
let totalSqlStatements = 0;
let buffer = '';
for (let offset = 0; offset < inputBytes.length; offset += length) {
// Simulate a single "chunk" arriving
const chunk = inputBytes.slice(offset, offset + length);
// Append the new chunk to the existing buffer
buffer += decoder.decode(chunk, { stream: true });
// Ingest any complete statements and snip those chars off the buffer
let result = sql.ingest(buffer);
buffer = result.remainder;
totalRowsWritten += result.rowsWritten;
totalSqlStatements += result.statementCount;
// Simulate awaiting next chunk
await scheduler.wait(1);
}
// Verify exactly 36 rows were added
assert.deepEqual(Array.from(sql.exec(`SELECT count(*) FROM streaming`)), [
{ 'count(*)': 36 },
]);
// Ensure our precious emoji were preserved, even if their bytes occur across split points
assert.deepEqual(
Array.from(sql.exec(`SELECT * FROM streaming WHERE val LIKE 'f%'`)),
[
{ val: 'f: 😳' },
{ val: 'f: 🫠' },
{ val: 'f: 🙃' },
{ val: 'f: 🤡' },
{ val: 'f: 🥺' },
{ val: 'f: 🔥😎🔥' },
]
);
// Verify that all 36 rows we inserted were accounted for.
assert.equal(totalRowsWritten, 36);
assert.equal(totalSqlStatements, 6);
sql.exec(`DELETE FROM streaming`);
await scheduler.wait(1);
}
sql.exec(`DROP TABLE streaming;`);
}
// Test count
{
const result = [
...sql.exec(
'SELECT count(value) from (SELECT 1 AS value\n' +
'UNION ALL\n' +
'SELECT 2 AS value\n' +
'UNION ALL\n' +
'SELECT 3 AS value);'
),
];
assert.equal(result.length, 1);
assert.equal(result[0]['count(value)'], 3);
}
// Test sum
{
const result = [
...sql.exec(
'SELECT sum(value) from (SELECT 1 AS value\n' +
'UNION ALL\n' +
'SELECT 2 AS value\n' +
'UNION ALL\n' +
'SELECT 3 AS value);'
),
];
assert.equal(result.length, 1);
assert.equal(result[0]['sum(value)'], 6);
}
// Test math functions enabled
{
const result = [...sql.exec('SELECT cos(0)')];
assert.equal(result.length, 1);
assert.equal(result[0]['cos(0)'], 1);
}
// Empty statements
assert.throws(() => sql.exec(''), 'SQL code did not contain a statement');
assert.throws(() => sql.exec(';'), 'SQL code did not contain a statement');
// Invalid statements
assert.throws(() => sql.exec('SELECT ;'), /syntax error at offset 7/);
assert.throws(() => sql.exec('SELECT -;'), /syntax error at offset 8/);
// Data type mismatch
sql.exec(`CREATE TABLE test_error_codes (name TEXT);`);
assert.throws(
() =>
sql.exec(
`INSERT INTO test_error_codes(rowid, name) values ('yeah','nah');`
),
/Error: datatype mismatch: SQLITE_MISMATCH/
);
sql.exec(`DROP TABLE test_error_codes;`);
// Incorrect number of binding values
assert.throws(
() => sql.exec('SELECT ?'),
'Error: Wrong number of parameter bindings for SQL query.'
);
// Prepared statement
const prepared = sql.prepare('SELECT 789');
const resultPrepared = [...prepared()];
assert.equal(resultPrepared.length, 1);
assert.equal(resultPrepared[0]['789'], 789);
// Running the same query twice, overlapping, works just fine.
let result1 = prepared();
let result2 = prepared();
// Iterate result2 before result1.
assert.equal([...result2][0]['789'], 789);
assert.equal([...result1][0]['789'], 789);
// That said if a cursor was already done before the statement was re-run, it's not considered
// canceled.
prepared();
assert.equal([...result2].length, 0);
// Prepared statement with binding values
const preparedWithBinding = sql.prepare('SELECT ?');
const resultPreparedWithBinding = [...preparedWithBinding(789)];
assert.equal(resultPreparedWithBinding.length, 1);
assert.equal(resultPreparedWithBinding[0]['?'], 789);
// Prepared statement (incorrect number of binding values)
assert.throws(
() => preparedWithBinding(),
'Error: Wrong number of parameter bindings for SQL query.'
);
// Prepared statement with whitespace
const whitespace = [' ', '\t', '\n', '\r', '\v', '\f', '\r\n'];
for (const char of whitespace) {
const prepared = sql.prepare(`SELECT 1;${char}`);
const result = [...prepared()];
assert.equal(result.length, 1);
}
// Prepared statement with multiple statements
assert.deepEqual([...sql.prepare('SELECT 1; SELECT 2;')()], [{ 2: 2 }]);
// Accessing a hidden _cf_ table
assert.throws(
() => sql.exec('CREATE TABLE _cf_invalid (name TEXT)'),
/not authorized/
);
storage.put('blah', 123); // force creation of _cf_KV table
assert.throws(
() => sql.exec('SELECT * FROM _cf_KV'),
/access to _cf_KV.key is prohibited/
);
// Some pragmas are completely not allowed
assert.throws(
() => sql.exec('PRAGMA hard_heap_limit = 1024'),
/not authorized/
);
// Test reading read-only pragmas
{
const result = [...sql.exec('pragma data_version;')];
assert.equal(result.length, 1);
assert.equal(result[0]['data_version'], 2);
}
// Trying to write to read-only pragmas is not allowed
assert.throws(
() => sql.exec('PRAGMA data_version = 5'),
/not authorized: SQLITE_AUTH/
);
assert.throws(
() => sql.exec('PRAGMA max_page_count = 65536'),
/not authorized/
);
assert.throws(
() => sql.exec('PRAGMA page_size = 8192'),
/not authorized: SQLITE_AUTH/
);
// PRAGMA table_info and PRAGMA table_xinfo are allowed.
sql.exec('CREATE TABLE myTable (foo TEXT, bar INTEGER)');
{
let info = [...sql.exec('PRAGMA table_info(myTable)')];
assert.equal(info.length, 2);
assert.equal(info[0].name, 'foo');
assert.equal(info[1].name, 'bar');
let xInfo = [...sql.exec('PRAGMA table_xinfo(myTable)')];
assert.equal(xInfo.length, 2);
assert.equal(xInfo[0].name, 'foo');
assert.equal(xInfo[1].name, 'bar');
}
// Can't get table_info for _cf_KV.
assert.throws(() => sql.exec('PRAGMA table_info(_cf_KV)'), /not authorized/);
// Testing the three valid types of inputs for quick_check
assert.deepEqual(Array.from(sql.exec('pragma quick_check;')), [
{ quick_check: 'ok' },
]);
assert.deepEqual(Array.from(sql.exec('pragma quick_check(1);')), [
{ quick_check: 'ok' },
]);
assert.deepEqual(Array.from(sql.exec('pragma quick_check(100);')), [
{ quick_check: 'ok' },
]);
assert.deepEqual(Array.from(sql.exec('pragma quick_check(myTable);')), [
{ quick_check: 'ok' },
]);
// But that private tables are again restricted
assert.throws(() => sql.exec('PRAGMA quick_check(_cf_KV)'), /not authorized/);
// PRAGMA optimize and ANALYZE are allowed.
//
// The following sequence of calls is mentioned by https://www.sqlite.org/lang_analyze.html as how
// one might optmize the query planner.
{
// Dry-run. SQLite's documentation uses -1 as the debug example.
assert.deepEqual(Array.from(sql.exec('PRAGMA optimize(-1);')), [
{ optimize: 'ANALYZE "main"."myTable"' },
{ optimize: 'ANALYZE "main"."_cf_KV"' },
]);
}
{
// Dry-run. This sets all of the bits except for the sign bit because SQLite's optimize
// function doesn't parse hex numbers into negative integers.
assert.deepEqual(Array.from(sql.exec('PRAGMA optimize(0x7fffffff);')), [
{ optimize: 'ANALYZE "main"."myTable"' },
{ optimize: 'ANALYZE "main"."_cf_KV"' },
]);
}
{
let info = [...sql.exec('PRAGMA optimize=0x10002;')];
assert.equal(info.length, 0);
}
{
let info = [...sql.exec('ANALYZE;')];
assert.equal(info.length, 0);
}
{
let info = [...sql.exec('PRAGMA optimize;')];
assert.equal(info.length, 0);
}
// Basic functions like abs() work.
assert.equal([...sql.exec('SELECT abs(-123)').raw()][0][0], 123);
// We don't permit sqlite_*() functions.
assert.throws(
() => sql.exec('SELECT sqlite_version()'),
/not authorized to use function: sqlite_version/
);
// JSON -> operator works
const jsonResult = [
...sql.exec('SELECT \'{"a":2,"c":[4,5,{"f":7}]}\' -> \'$.c\' AS value'),
][0].value;
assert.equal(jsonResult, '[4,5,{"f":7}]');
// current_{date,time,timestamp} functions work
const resultDate = [...sql.exec('SELECT current_date')];
assert.equal(resultDate.length, 1);
// Should match results in the format "2023-06-01"
assert.match(resultDate[0]['current_date'], /^\d{4}-\d{2}-\d{2}$/);
const resultTime = [...sql.exec('SELECT current_time')];
assert.equal(resultTime.length, 1);
// Should match results in the format "15:30:03"
assert.match(resultTime[0]['current_time'], /^\d{2}:\d{2}:\d{2}$/);
const resultTimestamp = [...sql.exec('SELECT current_timestamp')];
assert.equal(resultTimestamp.length, 1);
// Should match results in the format "2023-06-01 15:30:03"
assert.match(
resultTimestamp[0]['current_timestamp'],
/^\d{4}-\d{2}-\d{2}\s{1}\d{2}:\d{2}:\d{2}$/
);
// Validate that the SQLITE_LIMIT_COMPOUND_SELECT limit is enforced as expected
const compoundWithinLimits = [
...sql.exec(
'SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5'
),
];
assert.equal(compoundWithinLimits.length, 5);
assert.throws(
() =>
sql.exec(
'SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6'
),
/too many terms in compound SELECT/
);
// Can't start transactions or savepoints.
assert.throws(
() => sql.exec('BEGIN TRANSACTION'),
/please use the state.storage.transaction\(\) or state.storage.transactionSync\(\) APIs/
);
assert.throws(
() => sql.exec('SAVEPOINT foo'),
/please use the state.storage.transaction\(\) or state.storage.transactionSync\(\) APIs/
);
// Virtual tables
// Only fts5 and fts5vocab modules are allowed
assert.throws(
() => sql.exec(`CREATE VIRTUAL TABLE test_fts USING fts5abcd(id);`),
/not authorized/
);
// Full text search extension
sql.exec(`
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL
);
`);
// Module names are case-insensitive
sql.exec(`
CREATE VIRTUAL TABLE documents_fts USING FtS5(id, title, content, tokenize = porter);
`);
sql.exec(`
CREATE VIRTUAL TABLE documents_fts_v_col USING fTs5VoCaB(documents_fts, col);
`);
sql.exec(`
CREATE VIRTUAL TABLE documents_fts_v_row USING FtS5vOcAb(documents_fts, row);
`);
sql.exec(`
CREATE VIRTUAL TABLE documents_fts_v_instance USING fTs5VoCaB(documents_fts, instance);
`);
sql.exec(`
CREATE TRIGGER documents_fts_insert
AFTER INSERT ON documents
BEGIN
INSERT INTO documents_fts(id, title, content)
VALUES(new.id, new.title, new.content);
END;
`);
sql.exec(`
CREATE TRIGGER documents_fts_update
AFTER UPDATE ON documents
BEGIN
UPDATE documents_fts SET title=new.title, content=new.content WHERE id=old.id;
END;
`);
sql.exec(`
CREATE TRIGGER documents_fts_delete
AFTER DELETE ON documents
BEGIN
DELETE FROM documents_fts WHERE id=old.id;
END;
`);
sql.exec(`
INSERT INTO documents (title, content) VALUES ('Document 1', 'This is the contents of document 1 (of 2).');
`);
sql.exec(`
INSERT INTO documents (title, content) VALUES ('Document 2', 'This is the content of document 2 (of 2).');
`);
// Porter stemming makes 'contents' and 'content' the same
{
let results = Array.from(
sql.exec(`
SELECT * FROM documents_fts WHERE documents_fts MATCH 'content' ORDER BY rank;
`)
);
assert.equal(results.length, 2);
assert.equal(results[0].id, 1); // Stemming makes doc 1 match first
assert.equal(results[1].id, 2);
}
// Ranking functions
{
let results = Array.from(
sql.exec(`
SELECT *, bm25(documents_fts) FROM documents_fts WHERE documents_fts MATCH '2' ORDER BY rank;
`)
);
assert.equal(results.length, 2);
assert.equal(
results[0]['bm25(documents_fts)'] < results[1]['bm25(documents_fts)'],
true
); // Better matches have lower bm25 (since they're all negative
assert.equal(results[0].id, 2); // Doc 2 comes first (sorted by rank)
assert.equal(results[1].id, 1);
}
// highlight() function
{
let results = Array.from(
sql.exec(`
SELECT highlight(documents_fts, 2, '', '') as output FROM documents_fts WHERE documents_fts MATCH '2' ORDER BY rank;
`)
);
assert.equal(results.length, 2);
assert.equal(
results[0].output,
`This is the content of document 2 (of 2).`
); // two matches, two highlights
assert.equal(
results[1].output,
`This is the contents of document 1 (of 2).`
);
}
// snippet() function
{
let results = Array.from(
sql.exec(`
SELECT snippet(documents_fts, 2, '', '', '...', 4) as output FROM documents_fts WHERE documents_fts MATCH '2' ORDER BY rank;
`)
);
assert.equal(results.length, 2);
assert.equal(results[0].output, `...document 2 (of 2).`); // two matches, two highlights
assert.equal(results[1].output, `...document 1 (of 2).`);
}
// Complex queries
// List table info
{
let result = [
...sql.exec(`
SELECT name as tbl_name,
ncol as num_columns
FROM pragma_table_list
WHERE TYPE = "table"
AND tbl_name NOT LIKE "sqlite_%"
AND tbl_name NOT LIKE "d1_%"
AND tbl_name NOT LIKE "_cf_%"
ORDER BY tbl_name desc`),
];
assert.equal(result.length, 2);
assert.equal(result[0].tbl_name, 'myTable');
assert.equal(result[0].num_columns, 2);
assert.equal(result[1].tbl_name, 'documents');
assert.equal(result[1].num_columns, 3);
}
// Similar query using JSON objects
{
const jsonResult = JSON.parse(
Array.from(
sql.exec(
`SELECT json_group_array(json_object(
'type', type,
'name', name,
'tbl_name', tbl_name,
'rootpage', rootpage,
'sql', sql,
'columns', (SELECT json_group_object(name, type) from pragma_table_info(tbl_name))
)) as data
FROM sqlite_master
WHERE type = "table" AND tbl_name != "_cf_KV";`
)
)[0].data
);
assert.equal(jsonResult.length, 12);
assert.equal(
jsonResult.map((r) => r.name).join(','),
'myTable,sqlite_stat1,documents,documents_fts,documents_fts_data,documents_fts_idx,documents_fts_content,documents_fts_docsize,documents_fts_config,documents_fts_v_col,documents_fts_v_row,documents_fts_v_instance'
);
assert.equal(jsonResult[0].columns.foo, 'TEXT');
assert.equal(jsonResult[0].columns.bar, 'INTEGER');
assert.equal(jsonResult[2].columns.id, 'INTEGER');
assert.equal(jsonResult[2].columns.title, 'TEXT');
assert.equal(jsonResult[2].columns.content, 'TEXT');
}
let assertValidBool = (name, val) => {
sql.exec('PRAGMA defer_foreign_keys = ' + name + ';');
assert.equal(
[...sql.exec('PRAGMA defer_foreign_keys;')][0].defer_foreign_keys,
val
);
};
let assertInvalidBool = (name, msg) => {
assert.throws(
() => sql.exec('PRAGMA defer_foreign_keys = ' + name + ';'),
msg || /not authorized/
);
};
assertValidBool('true', 1);
assertValidBool('false', 0);
assertValidBool('on', 1);
assertValidBool('off', 0);
assertValidBool('yes', 1);
assertValidBool('no', 0);
assertValidBool('1', 1);
assertValidBool('0', 0);
// case-insensitive
assertValidBool('tRuE', 1);
assertValidBool('NO', 0);
// quoted
assertValidBool("'true'", 1);
assertValidBool('"yes"', 1);
assertValidBool('"0"', 0);
// whitespace is trimmed by sqlite before passing to authorizer
assertValidBool(' true ', 1);
// Don't accept anything invalid...
assertInvalidBool('abcd');
assertInvalidBool('"foo"');
assertInvalidBool("'yes", 'unrecognized token');
// Test database size interface.
assert.equal(sql.databaseSize, 40960);
sql.exec(`CREATE TABLE should_make_one_more_page(VALUE text);`);
assert.equal(sql.databaseSize, 40960 + 4096);
sql.exec(`DROP TABLE should_make_one_more_page;`);
assert.equal(sql.databaseSize, 40960);
storage.put('txnTest', 0);
// Try a transaction while no implicit transaction is open.
await scheduler.wait(1); // finish implicit txn
let txnResult = await storage.transaction(async () => {
storage.put('txnTest', 1);
assert.equal(await storage.get('txnTest'), 1);
return 'foo';
});
assert.equal(await storage.get('txnTest'), 1);
assert.equal(txnResult, 'foo');
// Try a transaction while an implicit transaction is open first.
storage.put('txnTest', 2);
await storage.transaction(async () => {
storage.put('txnTest', 3);
assert.equal(await storage.get('txnTest'), 3);
});
assert.equal(await storage.get('txnTest'), 3);
// Try a transaction that is explicitly rolled back.
await storage.transaction(async (txn) => {
storage.put('txnTest', 4);
assert.equal(await storage.get('txnTest'), 4);
txn.rollback();
});
assert.equal(await storage.get('txnTest'), 3);
// Try a transaction that is implicitly rolled back by throwing an exception.
try {
await storage.transaction(async (txn) => {
storage.put('txnTest', 5);
assert.equal(await storage.get('txnTest'), 5);
throw new Error('txn failure');
});
throw new Error('expected error');
} catch (err) {
assert.equal(err.message, 'txn failure');
}
assert.equal(await storage.get('txnTest'), 3);
// Try a nested transaction.
await storage.transaction(async (txn) => {
storage.put('txnTest', 6);
assert.equal(await storage.get('txnTest'), 6);
await storage.transaction(async (txn2) => {
storage.put('txnTest', 7);
assert.equal(await storage.get('txnTest'), 7);
// Let's even do an await in here for good measure.
await scheduler.wait(1);
});
assert.equal(await storage.get('txnTest'), 7);
txn.rollback();
});
assert.equal(await storage.get('txnTest'), 3);
// Test transactionSync, success
{
await scheduler.wait(1);
const result = storage.transactionSync(() => {
sql.exec('CREATE TABLE IF NOT EXISTS should_succeed (VALUE text);');
return 'some data';
});
assert.equal(result, 'some data');
const results = Array.from(
sql.exec(`
SELECT * FROM sqlite_master WHERE tbl_name = 'should_succeed'
`)
);
assert.equal(results.length, 1);
}
// Test transactionSync, failure
{
await scheduler.wait(1);
assert.throws(
() =>
storage.transactionSync(() => {
sql.exec('CREATE TABLE should_be_rolled_back (VALUE text);');
sql.exec('SELECT * FROM misspelled_table_name;');
}),
'Error: no such table: misspelled_table_name'
);
const results = Array.from(
sql.exec(`
SELECT * FROM sqlite_master WHERE tbl_name = 'should_be_rolled_back'
`)
);
assert.equal(results.length, 0);
}
// Test transactionSync, nested
{
sql.exec('CREATE TABLE txnTest (i INTEGER)');
sql.exec('INSERT INTO txnTest VALUES (1)');
let setI = sql.prepare('UPDATE txnTest SET i = ?');
let getIStmt = sql.prepare('SELECT i FROM txnTest');
let getI = () => [...getIStmt()][0].i;
assert.equal(getI(), 1);
storage.transactionSync(() => {
setI(2);
assert.equal(getI(), 2);
assert.throws(
() =>
storage.transactionSync(() => {
setI(3);
assert.equal(getI(), 3);
throw new Error('foo');
}),
'Error: foo'
);
assert.equal(getI(), 2);
});
assert.equal(getI(), 2);
}
// Test joining two tables with overlapping names
{
sql.exec(`CREATE TABLE abc (a INT, b INT, c INT);`);
sql.exec(`CREATE TABLE cde (c INT, d INT, e INT);`);
sql.exec(`INSERT INTO abc VALUES (1,2,3),(4,5,6);`);
sql.exec(`INSERT INTO cde VALUES (7,8,9),(1,2,3);`);
const stmt = sql.prepare(`SELECT * FROM abc, cde`);
// In normal iteration, data is lost
const objResults = Array.from(stmt());
assert.equal(Object.values(objResults[0]).length, 5); // duplicate column 'c' dropped
assert.equal(Object.values(objResults[1]).length, 5); // duplicate column 'c' dropped
assert.equal(Object.values(objResults[2]).length, 5); // duplicate column 'c' dropped
assert.equal(Object.values(objResults[3]).length, 5); // duplicate column 'c' dropped
assert.equal(objResults[0].c, 7); // Value of 'c' is the second in the join
assert.equal(objResults[1].c, 1); // Value of 'c' is the second in the join
assert.equal(objResults[2].c, 7); // Value of 'c' is the second in the join
assert.equal(objResults[3].c, 1); // Value of 'c' is the second in the join
// Iterator has a 'columnNames' property, with .raw() that lets us get the full data
const iterator = stmt();
assert.deepEqual(iterator.columnNames, ['a', 'b', 'c', 'c', 'd', 'e']);
const rawResults = Array.from(iterator.raw());
assert.equal(rawResults.length, 4);
assert.deepEqual(rawResults[0], [1, 2, 3, 7, 8, 9]);
assert.deepEqual(rawResults[1], [1, 2, 3, 1, 2, 3]);
assert.deepEqual(rawResults[2], [4, 5, 6, 7, 8, 9]);
assert.deepEqual(rawResults[3], [4, 5, 6, 1, 2, 3]);
// After an iterator is consumed, columnNames can still be accessed.
assert.deepEqual(iterator.columnNames, ['a', 'b', 'c', 'c', 'd', 'e']);
// Also works with cursors returned from .exec
const execIterator = sql.exec(`SELECT * FROM abc, cde`);
assert.deepEqual(execIterator.columnNames, ['a', 'b', 'c', 'c', 'd', 'e']);
assert.equal(Array.from(execIterator.raw())[0].length, 6);
// Execute some sort of statement that returns no results, check that we can read the column
// names (which is empty).
const oneIterator = sql.exec(`UPDATE abc SET a = 1 WHERE b = 123542`);
assert.deepEqual(oneIterator.columnNames, []);
}
await scheduler.wait(1);
// Test for bug where a cursor constructed from a prepared statement didn't have a strong ref
// to the statement object.
{
sql.exec('CREATE TABLE iteratorTest (i INTEGER)');
sql.exec('INSERT INTO iteratorTest VALUES (0), (1)');
let q = sql.prepare('SELECT * FROM iteratorTest')();
let i = 0;
for (let row of q) {
assert.equal(row.i, i++);
gc();
}
}
{
// Test binding blobs & nulls
sql.exec(`CREATE TABLE test_blob (id INTEGER PRIMARY KEY, data BLOB);`);
sql.prepare(
`INSERT INTO test_blob(data) VALUES(?),(ZEROBLOB(10)),(null),(?);`
)(crypto.getRandomValues(new Uint8Array(12)), null);
const results = Array.from(sql.exec(`SELECT * FROM test_blob`));
assert.equal(results.length, 4);
assert.equal(results[0].data instanceof ArrayBuffer, true);
assert.equal(results[0].data.byteLength, 12);
assert.equal(results[1].data instanceof ArrayBuffer, true);
assert.equal(results[1].data.byteLength, 10);
assert.equal(results[2].data, null);
assert.equal(results[3].data, null);
}
// Can rename tables
sql.exec(`
CREATE TABLE beforerename (
id INTEGER
);
`);
sql.exec(`
ALTER TABLE beforerename
RENAME TO afterrename;
`);
sql.exec(`
CREATE TABLE altercolumns (
meta TEXT
);
`);
// Can add columns
sql.exec(`
ALTER TABLE altercolumns
ADD COLUMN tobedeleted TEXT;
`);
// Can rename columns within a table
sql.exec(`
ALTER TABLE altercolumns
RENAME COLUMN meta TO metadata
`);
// Can drop columns
sql.exec(`
ALTER TABLE altercolumns
DROP COLUMN tobedeleted
`);
// Can add columns with a CHECK
sql.exec(`
ALTER TABLE altercolumns
ADD COLUMN checked_col TEXT CHECK(checked_col IN ('A','B'));
`);
// The CHECK is enforced unless `ignore_check_constraints` is on
sql.exec(`INSERT INTO altercolumns(checked_col) VALUES ('A')`);
assert.throws(
() => sql.exec(`INSERT INTO altercolumns(checked_col) VALUES ('C')`),
/Error: CHECK constraint failed: checked_col IN \('A','B'\)/
);
// Because there's already a row, adding another column with a CHECK
// but no default value will fail
assert.throws(
() =>
sql.exec(`
ALTER TABLE altercolumns
ADD COLUMN second_col TEXT CHECK(second_col IS NOT NULL);
`),
/Error: CHECK constraint failed/
);
// ignore_check_constraints lets us bypass this for adding bad data
sql.exec(`PRAGMA ignore_check_constraints=ON;`);
sql.exec(`INSERT INTO altercolumns(checked_col) VALUES ('C')`);
assert.deepEqual(
[...sql.exec(`SELECT * FROM altercolumns`)],
[
{ checked_col: 'A', metadata: null },
{ checked_col: 'C', metadata: null },
]
);
// Or even adding columns that start broken (because second_col is NULL)
sql.exec(`
ALTER TABLE altercolumns
ADD COLUMN second_col TEXT CHECK(second_col IS NOT NULL);
`);
// Turning check constraints back on doesn't actually do any checking, eagerly
sql.exec(`PRAGMA ignore_check_constraints=OFF;`);
// But anything else that CHECKs that table will now fail, like adding another CHECK
assert.throws(
() =>
sql.exec(`
ALTER TABLE altercolumns
ADD COLUMN third_col TEXT DEFAULT 'E' CHECK(third_col IN ('E','F'));
`),
/Error: CHECK constraint failed/
);
// And we can use quick_check to list out that there are now errors
// (although these messages aren't great):
assert.deepEqual(
[...sql.exec(`PRAGMA quick_check;`)],
[
{ quick_check: 'CHECK constraint failed in altercolumns' },
{ quick_check: 'CHECK constraint failed in altercolumns' },
]
);
// Can't create another temp table
assert.throws(
() =>
sql.exec(`
CREATE TEMP TABLE tempy AS
SELECT * FROM sqlite_master;
`),
'Error: not authorized'
);
// Assert foreign keys can be truly turned off, not just deferred
await state.blockConcurrencyWhile(async () => {
sql.exec(`PRAGMA foreign_keys = OFF;`);
});
storage.transactionSync(() => {
sql.exec(`
CREATE TABLE A (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
bId INTEGER NOT NULL REFERENCES B (id) ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO A VALUES(1,1); -- this would throw a parse error with foreign keys on
CREATE TABLE B (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT
);
`);
});
// Until we've inserted the row into B, we can detect our
// foreign key violation (even with foreign_keys=OFF)
assert.deepEqual(Array.from(sql.exec(`pragma foreign_key_check;`)), [
{ table: 'A', rowid: 1, parent: 'B', fkid: 0 },
]);
sql.exec(`INSERT INTO B VALUES (1);`);
assert.deepEqual(Array.from(sql.exec(`pragma foreign_key_check;`)), []);
// Restore foreign keys for the rest of the tests
await state.blockConcurrencyWhile(async () => {
sql.exec(`PRAGMA foreign_keys = ON;`);
});
// Verify caching.
{
let isCached = (q) => {
let cursor = sql.exec(q);
cursor.toArray();
return cursor.reusedCachedQueryForTest;
};
// Query based on literal string is cached.
assert.equal(false, isCached('SELECT 179321'));
assert.equal(true, isCached('SELECT 179321'));
assert.equal(true, isCached('SELECT 179321'));
// Query based on computed string is cached.
assert.equal(false, isCached('SELECT "' + 'x'.repeat(4) + '"'));
assert.equal(true, isCached('SELECT "' + 'x'.repeat(4) + '"'));
assert.equal(true, isCached('SELECT "' + 'x'.repeat(4) + '"'));
}
// Verify that if we alter a table, cached statements continue to work.
{
sql.exec('CREATE TABLE alterTableTest (a INTEGER)');
sql.exec('INSERT INTO alterTableTest VALUES (?)', 1);
assert.deepStrictEqual(sql.exec('SELECT * FROM alterTableTest').toArray(), [
{ a: 1 },
]);
sql.exec('ALTER TABLE alterTableTest ADD COLUMN b INTEGER').toArray();
assert.deepStrictEqual(sql.exec('SELECT * FROM alterTableTest').toArray(), [
{ a: 1, b: null },
]);
sql.exec('INSERT INTO alterTableTest VALUES (?, ?)', 2, 2);
assert.deepStrictEqual(sql.exec('SELECT * FROM alterTableTest').toArray(), [
{ a: 1, b: null },
{ a: 2, b: 2 },
]);
sql.exec('INSERT INTO alterTableTest VALUES (?, ?)', 3, 3);
assert.deepStrictEqual(sql.exec('SELECT * FROM alterTableTest').toArray(), [
{ a: 1, b: null },
{ a: 2, b: 2 },
{ a: 3, b: 3 },
]);
}
}
async function testIoStats(storage) {
const sql = storage.sql;
sql.exec(`CREATE TABLE tbl (id INTEGER PRIMARY KEY, value TEXT)`);
sql.exec(
`INSERT INTO tbl (id, value) VALUES (?, ?)`,
100000,
'arbitrary-initial-value'
);
await scheduler.wait(1);
// When writing, the rowsWritten count goes up.
{
const cursor = sql.exec(
`INSERT INTO tbl (id, value) VALUES (?, ?)`,
1,
'arbitrary-value'
);
Array.from(cursor); // Consume all the results
assert.equal(cursor.rowsWritten, 1);
}
// When reading, the rowsRead count goes up.
{
const cursor = sql.exec(`SELECT * FROM tbl`);
Array.from(cursor); // Consume all the results
assert.equal(cursor.rowsRead, 2);
}
// Each invocation of a prepared statement gets its own counters.
{
const id1 = 101;
const id2 = 202;
const prepared = sql.prepare(`INSERT INTO tbl (id, value) VALUES (?, ?)`);
const cursor123 = prepared(id1, 'value1');
Array.from(cursor123);
assert.equal(cursor123.rowsWritten, 1);
const cursor456 = prepared(id2, 'value2');
Array.from(cursor456);
assert.equal(cursor456.rowsWritten, 1);
assert.equal(cursor123.rowsWritten, 1); // remained unchanged
}
// Row counters are updated as you consume the cursor.
{
sql.exec(`DELETE FROM tbl`);
const prepared = sql.prepare(`INSERT INTO tbl (id, value) VALUES (?, ?)`);
for (let i = 1; i <= 10; i++) {
Array.from(prepared(i, 'value' + i));
}
const cursor = sql.exec(`SELECT * FROM tbl`);
const resultsIterator = cursor[Symbol.iterator]();
let rowsSeen = 0;
while (true) {
const result = resultsIterator.next();
if (result.done) {
assert.equal(10, cursor.rowsRead);
break;
}
// + 1 because the cursor is always one result ahead of what has been returned -- but there
// are only 10 rows total.
assert.equal(Math.min(++rowsSeen + 1, 10), cursor.rowsRead);
}
}
// Row counters can track interleaved cursors
{
const join = [];
const colCounts = [];
// In-JS joining of two tables should be possible:
const rows = sql.exec(`SELECT * FROM abc`);
for (let row of rows) {
const cols = sql.exec(`SELECT * FROM cde`);
for (let col of cols) {
join.push({ row, col });
}
colCounts.push(cols.rowsRead);
}
assert.deepEqual(join, [
{ col: { c: 7, d: 8, e: 9 }, row: { a: 1, b: 2, c: 3 } },
{ col: { c: 1, d: 2, e: 3 }, row: { a: 1, b: 2, c: 3 } },
{ col: { c: 7, d: 8, e: 9 }, row: { a: 4, b: 5, c: 6 } },
{ col: { c: 1, d: 2, e: 3 }, row: { a: 4, b: 5, c: 6 } },
]);
assert.deepEqual(rows.rowsRead, 2);
assert.deepEqual(colCounts, [2, 2]);
}
// Temporary tables (i.e. for IN clauses) don't contribute to rowsWritten
{
const cursor = sql.exec(`SELECT * FROM abc WHERE a IN (1,2,3,4,5,6)`);
const _rows = Array.from(cursor);
assert.deepEqual(cursor.rowsRead, 2);
assert.deepEqual(cursor.rowsWritten, 0);
}
}
async function testForeignKeys(storage) {
const sql = storage.sql;
// Test defer_foreign_keys
{
sql.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);`);
sql.exec(
`CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER, content TEXT, FOREIGN KEY(user_id) REFERENCES users(id));`
);
await scheduler.wait(1);
// By default, primary keys are enforced:
assert.throws(
() =>
sql.exec(
`INSERT INTO posts (user_id, content) VALUES (?, ?)`,
1,
'Post 1'
),
/Error: FOREIGN KEY constraint failed/
);
// Transactions fail immediately too
let passed_first_statement = false;
assert.throws(
() =>
storage.transactionSync(() => {
sql.exec(
`INSERT INTO posts (user_id, content) VALUES (?, ?)`,
1,
'Post 1'
);
passed_first_statement = true;
}),
/Error: FOREIGN KEY constraint failed/
);
assert.equal(passed_first_statement, false);
await scheduler.wait(1);
// With defer_foreign_keys, we can insert things out-of-order within transactions,
// as long as the data is valid by the end.
storage.transactionSync(() => {
sql.exec(`PRAGMA defer_foreign_keys=ON;`);
sql.exec(
`INSERT INTO posts (user_id, content) VALUES (?, ?)`,
1,
'Post 1'
);
sql.exec(`INSERT INTO users VALUES (?, ?)`, 1, 'Alice');
});
await scheduler.wait(1);
// But if we use defer_foreign_keys but try to commit, it resets the DO
storage.transactionSync(() => {
sql.exec(`PRAGMA defer_foreign_keys=ON;`);
sql.exec(
`INSERT INTO posts (user_id, content) VALUES (?, ?)`,
2,
'Post 2'
);
});
}
}
async function testStreamingIngestion(request, storage) {
const { sql } = storage;
sql.exec(`CREATE TABLE streaming(val TEXT);`);
await storage.transaction(async () => {
const stream = request.body.pipeThrough(new TextDecoderStream());
let buffer = '';
for await (const chunk of stream) {
// Append the new chunk to the existing buffer
buffer += chunk;
// Ingest any complete statements and snip those chars off the buffer
buffer = sql.ingest(buffer).remainder;
}
});
// Verify exactly 36 rows were added
assert.deepEqual(Array.from(sql.exec(`SELECT count(*) FROM streaming`)), [
{ 'count(*)': 36 },
]);
assert.deepEqual(
Array.from(sql.exec(`SELECT * FROM streaming WHERE val LIKE 'f%'`)),
[
{ val: 'f: 😳' },
{ val: 'f: 🫠' },
{ val: 'f: 🙃' },
{ val: 'f: 🤡' },
{ val: 'f: 🥺' },
{ val: 'f: 🔥😎🔥' },
]
);
}
export class DurableObjectExample extends DurableObject {
constructor(state, env) {
super(state, env);
this.state = state;
}
async fetch(req) {
if (req.url.endsWith('/sql-test')) {
await test(this.state);
return Response.json({ ok: true });
} else if (req.url.endsWith('/sql-test-foreign-keys')) {
await testForeignKeys(this.state.storage);
return Response.json({ ok: true });
} else if (req.url.endsWith('/increment')) {
let val = (await this.state.storage.get('counter')) || 0;
++val;
this.state.storage.put('counter', val);
return Response.json(val);
} else if (req.url.endsWith('/break')) {
// This `put()` should be discarded due to the actor aborting immediately after.
this.state.storage.put('counter', 888);
// Abort the actor, which also cancels unflushed writes.
this.state.abort('test broken');
// abort() always throws.
throw new Error("can't get here");
} else if (req.url.endsWith('/sql-test-io-stats')) {
await testIoStats(this.state.storage);
return Response.json({ ok: true });
} else if (req.url.endsWith('/streaming-ingestion')) {
await testStreamingIngestion(req, this.state.storage);
return Response.json({ ok: true });
} else if (req.url.endsWith('/deleteAll')) {
this.state.storage.put('counter', 888); // will be deleted
this.state.storage.deleteAll();
assert.strictEqual(await this.state.storage.get('counter'), undefined);
return Response.json({ ok: true });
}
throw new Error('unknown url: ' + req.url);
}
async testRollbackKvInit() {
// Test what happens if initialization of the _cf_KV table gets rolled back.
try {
this.state.storage.transactionSync(() => {
// Cause KV table to be initialized.
this.state.storage.put('foo', 123);
// Roll back the transaction by throwing.
throw new Error('bar');
});
throw new Error('expected error');
} catch (err) {
if (err.message != 'bar') throw err;
}
// Now try to put to KV again. This will create the `_cf_KV` table again.
await this.state.storage.put('foo', 456);
}
async testRollbackAlarmInit() {
// Much like testRollbackKvInit() but for alarms.
try {
this.state.storage.transactionSync(() => {
// Cause KV table to be initialized.
this.state.storage.setAlarm(Date.now() + 86400 * 365);
// Roll back the transaction by throwing.
throw new Error('bar');
});
throw new Error('expected error');
} catch (err) {
if (err.message != 'bar') throw err;
}
assert.strictEqual(await this.state.storage.getAlarm(), null);
await this.state.storage.setAlarm(Date.now() + 86400 * 365);
}
async alarm() {}
async testMultiStatement() {
// Performing this PRAGMA will cause sqlite to invalidate prepared statements and re-compile
// them the next time they are executed. (Probably, many other pragmas would have the same
// effect, but this is the one that we observed causing issues.)
//
// In particular, the prepared statement ActorSqlite::beginTxn, which is simply
// `BEGIN TRANSACTION`, will be invalidated and recompiled on the next invocation.
//
// When we perform our multi-statement exec below, the first line will invoke the
// `ActorSqlite::onWrite` callback, which will invoke `beginTxn`. Because `BEGIN TRANSACTION`
// must be recompiled, the SQLite authorizer callback will be invoked to check if it is
// authorized. But we use the authorizer callback to detect when SQLite has parsed a statement
// as a transaction statement. At one point, we had a bug where we incorrectly thought that
// the authorizer was being called on behalf of the statement we were trying to parse and
// execute, namely, `CREATE TABLE items...`. We therefore incorrectly made note that this
// statement was beginning a transaction. This led the transaction state tracking to become
// all wrong!
//
// This only turned out to be an issue when performing a multi-statement exec(), because in
// this case all statements except the last are executed inside the parse loop, which is why
// we misinterpreted the authorizer callback.
this.state.storage.sql.exec('PRAGMA case_sensitive_like = TRUE');
let cursor = this.state.storage.sql.exec(`
CREATE TABLE items(i INTEGER, s TEXT);
CREATE INDEX itemsIdx ON items(s);
INSERT INTO items VALUES (123, "abc");
INSERT INTO items VALUES (456, "def");
SELECT i FROM items WHERE s = "abc";
`);
assert.deepEqual([...cursor], [{ i: 123 }]);
}
async testSessionsAPIBookmark(previousBookmark) {
if (previousBookmark) {
await this.state.storage.waitForBookmark(previousBookmark);
}
let bookmark = await this.state.storage.getCurrentBookmark();
if (previousBookmark) {
assert.ok(previousBookmark < bookmark, "new bookmark didn't advance!");
}
return bookmark;
}
async createStringTable() {
this.state.storage.sql.exec(
'CREATE TABLE IF NOT EXISTS string_table (id INTEGER PRIMARY KEY, data BLOB)'
);
}
async getStringTableIds() {
return Array.from(
this.state.storage.sql.exec('SELECT id FROM string_table'),
(x) => x.id
);
}
async runActorFunc(name) {
return actorFuncs[name](this.state);
}
}
export default {
async test(ctrl, env, ctx) {
let id = env.ns.idFromName('A');
let obj = env.ns.get(id);
// Now let's test persistence through breakage and atomic write coalescing.
let doReq = async (path, init = {}) => {
let resp = await obj.fetch('http://foo/' + path, init);
return await resp.json();
};
// Test SQL API
assert.deepEqual(await doReq('sql-test'), { ok: true });
// Test SQL IO stats
assert.deepEqual(await doReq('sql-test-io-stats'), { ok: true });
// Test SQL streaming ingestion
assert.deepEqual(
await doReq('streaming-ingestion', {
method: 'POST',
body: new ReadableStream({
async start(controller) {
const data = new TextEncoder().encode(INSERT_36_ROWS);
// Pick a value for chunkSize that splits the first emoji in half
const chunkSize = INSERT_36_ROWS.indexOf('😳') + 1;
assert.equal(chunkSize, 35); // Validate we're getting the value we expect
// Send each chunk with a wait of 1ms in between
for (
let offset = 0;
offset < data.length - 1;
offset += chunkSize
) {
controller.enqueue(data.slice(offset, offset + chunkSize));
await scheduler.wait(1);
}
controller.close();
},
}),
}),
{ ok: true }
);
// Test defer_foreign_keys (explodes the DO)
await assert.rejects(async () => {
await doReq('sql-test-foreign-keys');
}, /constraints were violated: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT/);
// Since the DO was exploded, reusing the stub dosen't work.
await assert.rejects(async () => {
await doReq('increment');
}, /constraints were violated: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT/);
// Get a new stub.
obj = env.ns.get(id);
// Some increments.
assert.equal(await doReq('increment'), 1);
assert.equal(await doReq('increment'), 2);
// Now induce a failure.
await assert.rejects(
async () => {
await doReq('break');
},
(err) => err.message === 'test broken' && err.durableObjectReset
);
// Get a new stub.
obj = env.ns.get(id);
// Everything's still consistent.
assert.equal(await doReq('increment'), 3);
// Delete all: increments start over
await doReq('deleteAll');
assert.equal(await doReq('increment'), 1);
assert.equal(await doReq('increment'), 2);
},
};
export let testRollbackKvInit = {
async test(ctrl, env, ctx) {
let stub = env.ns.get(env.ns.idFromName('rollback-kv-test'));
await stub.testRollbackKvInit();
await stub.testRollbackAlarmInit();
},
};
export let testMultiStatement = {
async test(ctrl, env, ctx) {
let stub = env.ns.get(env.ns.idFromName('multi-statement-test'));
await stub.testMultiStatement();
},
};
const INSERT_36_ROWS = ['a', 'b', 'c', 'd', 'e', 'f']
.map(
(prefix) =>
`INSERT INTO streaming VALUES ${['😳', '🫠', '🙃', '🤡', '🥺', '🔥😎🔥']
.map((suffix) => `('${prefix}: ${suffix}')`)
.join(',')};`
)
.join(' ');
export let testSessionsAPIBookmark = {
async test(ctrl, env, ctx) {
let stub = env.ns.get(env.ns.idFromName('sessions-api-bookmark-test'));
let bookmark = undefined;
for (let i = 0; i < 20; ++i) {
bookmark = await stub.testSessionsAPIBookmark(bookmark);
}
},
};
export let testAutoRollBackOnCriticalError = {
async test(ctrl, env, ctx) {
let id = env.ns.idFromName('auto-rollback-on-critical-error-test');
let stub = env.ns.get(id);
await stub.createStringTable();
// Even though the DO function catches and handles all exceptions, we still expect it to fail
// with the critical exception, due to the output gate being broken with it.
await assert.rejects(async () => {
await stub.runActorFunc('doAutoRollBackOnCriticalError');
}, /^Error: database or disk is full: SQLITE_FULL/);
// Get a new stub since the old stub is broken due to critical error
stub = env.ns.get(id);
// We expect only the first, committed row to be present:
assert.deepStrictEqual(await stub.getStringTableIds(), [1]);
},
};
actorFuncs.doAutoRollBackOnCriticalError = async (state) => {
// Limit size of db so we can trigger a SQLITE_FULL error
state.storage.sql.setMaxPageCountForTest(10);
// Add a row as part of an implicit transaction, and wait for it to commit.
state.storage.sql.exec('INSERT INTO string_table VALUES (?, ?)', 1, 'a');
await state.storage.sync();
// Add another row as part of a new implicit transaction
state.storage.sql.exec('INSERT INTO string_table VALUES (?, ?)', 2, 'a');
// Try to add a row that is too big for the database. We expect this to fail with a critical
// error that rolls back the current implicit transaction and breaks the output gate:
assert.throws(() => {
state.storage.sql.exec(
'INSERT INTO string_table VALUES (?, ?)',
3,
'a'.repeat(1000000)
);
}, /^Error: database or disk is full: SQLITE_FULL/);
// Further storage ops are expected to fail because we've cached the critical error:
assert.throws(() => {
state.storage.sql.exec('INSERT INTO string_table VALUES (?, ?)', 4, 'a');
}, /^Error: database or disk is full: SQLITE_FULL/);
};
export let testCriticalErrorOnTransactionSyncRollback = {
async test(ctrl, env, ctx) {
let id = env.ns.idFromName('critical-error-on-transaction-sync-rollback');
let stub = env.ns.get(id);
await stub.createStringTable();
await assert.rejects(async () => {
await stub.runActorFunc('doCriticalErrorOnTransactionSyncRollback');
}, /^Error: database or disk is full: SQLITE_FULL/);
// Get a new stub since the old stub is broken due to critical error
stub = env.ns.get(id);
// We expect only the first, committed row to be present:
assert.deepStrictEqual(await stub.getStringTableIds(), [1]);
},
};
actorFuncs.doCriticalErrorOnTransactionSyncRollback = async (state) => {
// Limit size of db so we can trigger a SQLITE_FULL error
state.storage.sql.setMaxPageCountForTest(10);
// Add a row as part of an implicit transaction, and wait for it to commit.
state.storage.sql.exec('INSERT INTO string_table VALUES (?, ?)', 1, 'a');
await state.storage.sync();
// Add another row as part of a new implicit transaction. We expect this to also get rolled
// back when the subsequent transactionSync() fails.
state.storage.sql.exec('INSERT INTO string_table VALUES (?, ?)', 2, 'a');
// Try to add a row that is too big for the database, within an explicit synchronous
// transaction. We expect this to fail with a critical error that rolls back the explicit
// transaction and breaks the output gate. Earlier versions of the code failed here with
// an internal error "no such savepoint: _cf_sync_savepoint_0" when trying to roll back.
assert.throws(() => {
state.storage.transactionSync(() => {
assert.throws(() => {
state.storage.sql.exec(
'INSERT INTO string_table VALUES (?, ?)',
3,
'a'.repeat(1000000)
);
}, /^Error: database or disk is full: SQLITE_FULL/);
// Throw an exception to make transactionSync() attempt to roll back the transaction:
throw new Error('an_escaping_exception_to_trigger_rollback');
});
}, /^Error: an_escaping_exception_to_trigger_rollback/);
};
export let testCriticalErrorOnTransactionSyncCommit = {
async test(ctrl, env, ctx) {
let id = env.ns.idFromName('critical-error-on-transaction-sync-commit');
let stub = env.ns.get(id);
await stub.createStringTable();
await assert.rejects(async () => {
await stub.runActorFunc('doCriticalErrorOnTransactionSyncCommit');
}, /^Error: database or disk is full: SQLITE_FULL/);
// Get a new stub since the old stub is broken due to critical error
stub = env.ns.get(id);
// We expect only the first, committed row to be present:
assert.deepStrictEqual(await stub.getStringTableIds(), [1]);
},
};
actorFuncs.doCriticalErrorOnTransactionSyncCommit = async (state) => {
// Limit size of db so we can trigger a SQLITE_FULL error
state.storage.sql.setMaxPageCountForTest(10);
// Add a row as part of an implicit transaction, and wait for it to commit.
state.storage.sql.exec('INSERT INTO string_table VALUES (?, ?)', 1, 'a');
await state.storage.sync();
// Add another row as part of a new implicit transaction. We expect this to also get rolled
// back when the subsequent transactionSync() fails.
state.storage.sql.exec('INSERT INTO string_table VALUES (?, ?)', 2, 'a');
// Try to add a row that is too big for the database, within an explicit synchronous
// transaction. We expect this to fail with a critical error that rolls back the explicit
// transaction and breaks the output gate. Earlier versions of the code failed here with
// internal errors "no such savepoint: _cf_sync_savepoint_0" when trying to commit, then roll
// back.
assert.throws(() => {
state.storage.transactionSync(() => {
assert.throws(() => {
state.storage.sql.exec(
'INSERT INTO string_table VALUES (?, ?)',
3,
'a'.repeat(1000000)
);
}, /^Error: database or disk is full: SQLITE_FULL/);
// Because the lambda completes successfully, transactionSync() will still try to commit the
// transaction.
});
}, /^Error: Cannot commit transaction due to an earlier SQL critical error/);
};
export let testCriticalErrorOnTransactionRollback = {
async test(ctrl, env, ctx) {
let id = env.ns.idFromName('critical-error-on-transaction-rollback');
let stub = env.ns.get(id);
await stub.createStringTable();
await assert.rejects(async () => {
await stub.runActorFunc('doCriticalErrorOnTransactionRollback');
}, /^Error: database or disk is full: SQLITE_FULL/);
// Get a new stub since the old stub is broken due to critical error
stub = env.ns.get(id);
// We expect only the first two committed rows to be present:
assert.deepStrictEqual(await stub.getStringTableIds(), [1, 2]);
},
};
actorFuncs.doCriticalErrorOnTransactionRollback = async (state) => {
// Limit size of db so we can trigger a SQLITE_FULL error
state.storage.sql.setMaxPageCountForTest(10);
// Add a row as part of an implicit transaction, and wait for it to commit.
state.storage.sql.exec('INSERT INTO string_table VALUES (?, ?)', 1, 'a');
await state.storage.sync();
// Add another row as part of a new implicit transaction. We expect this to be committed
// prior to the failing explicit transaction.
state.storage.sql.exec('INSERT INTO string_table VALUES (?, ?)', 2, 'a');
// Try to add a row that is too big for the database, within an explicit asynchronous
// transaction. We expect this to fail with a critical error that rolls back the explicit
// transaction and breaks the output gate.
await assert.rejects(async () => {
await state.storage.transaction(async (txn) => {
assert.throws(() => {
state.storage.sql.exec(
'INSERT INTO string_table VALUES (?, ?)',
3,
'a'.repeat(1000000)
);
}, /^Error: database or disk is full: SQLITE_FULL/);
// Explicitly roll back transaction. In earlier versions of the code, this could throw
// "no such savepoint: _cf_savepoint_0" due to a missing brokenness check.
txn.rollback();
});
}, /^Error: database or disk is full: SQLITE_FULL/);
};