From 4a39ea11b036efbf90548b46f82847382a8d4a98 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Wed, 27 May 2026 20:33:14 +0900 Subject: [PATCH 1/2] feat: more rubust support for durable objects --- .../cli/tests/bindings-test/src/do_test.py | 198 ++++++++++++++++ .../tests/bindings-test/src/durable_object.py | 213 ++++++++++++++++++ .../cli/tests/bindings-test/src/worker.py | 5 + .../cli/tests/bindings-test/wrangler.jsonc | 8 + packages/cli/tests/test_bindings.py | 34 +++ packages/runtime-sdk/src/workers/_workers.py | 11 + 6 files changed, 469 insertions(+) create mode 100644 packages/cli/tests/bindings-test/src/do_test.py create mode 100644 packages/cli/tests/bindings-test/src/durable_object.py diff --git a/packages/cli/tests/bindings-test/src/do_test.py b/packages/cli/tests/bindings-test/src/do_test.py new file mode 100644 index 0000000..56f7a1c --- /dev/null +++ b/packages/cli/tests/bindings-test/src/do_test.py @@ -0,0 +1,198 @@ +async def _get_stub(env, name="test"): + ns = env.TEST_DO + id = ns.idFromName(name) + return ns.get(id) + + +async def test_storage_put_and_get(env): + stub = await _get_stub(env) + await stub.test_storage_put_and_get() + + +async def test_storage_get_nonexistent(env): + stub = await _get_stub(env) + await stub.test_storage_get_nonexistent() + + +async def test_storage_put_multiple(env): + stub = await _get_stub(env) + await stub.test_storage_put_multiple() + + +async def test_storage_get_multiple(env): + stub = await _get_stub(env) + await stub.test_storage_get_multiple() + + +async def test_storage_delete(env): + stub = await _get_stub(env) + await stub.test_storage_delete() + + +async def test_storage_delete_multiple(env): + stub = await _get_stub(env) + await stub.test_storage_delete_multiple() + + +async def test_storage_list(env): + stub = await _get_stub(env) + await stub.test_storage_list() + + +async def test_storage_list_with_options(env): + stub = await _get_stub(env) + await stub.test_storage_list_with_options() + + +async def test_storage_delete_all(env): + stub = await _get_stub(env) + await stub.test_storage_delete_all() + + +async def test_storage_value_types(env): + stub = await _get_stub(env) + await stub.test_storage_value_types() + + +async def test_sql_exec_and_query(env): + stub = await _get_stub(env) + await stub.test_sql_exec_and_query() + + +async def test_sql_cursor_one(env): + stub = await _get_stub(env) + await stub.test_sql_cursor_one() + + +async def test_sql_cursor_column_names(env): + stub = await _get_stub(env) + await stub.test_sql_cursor_column_names() + + +async def test_sql_cursor_rows_read_written(env): + stub = await _get_stub(env) + await stub.test_sql_cursor_rows_read_written() + + +async def test_sql_database_size(env): + stub = await _get_stub(env) + await stub.test_sql_database_size() + + +async def test_alarm_set_get_delete(env): + stub = await _get_stub(env) + await stub.test_alarm_set_get_delete() + + +async def test_transaction(env): + stub = await _get_stub(env) + await stub.test_transaction() + + +async def test_ctx_id(env): + stub = await _get_stub(env) + await stub.test_ctx_id() + + +async def test_namespace_id_from_name(env): + ns = env.TEST_DO + id1 = ns.idFromName("deterministic") + id2 = ns.idFromName("deterministic") + assert id1.toString() == id2.toString(), "idFromName should be deterministic" + assert id1.name == "deterministic" + + +async def test_namespace_new_unique_id(env): + ns = env.TEST_DO + id1 = ns.newUniqueId() + id2 = ns.newUniqueId() + assert id1.toString() != id2.toString(), "newUniqueId should produce unique IDs" + assert len(id1.toString()) == 64, f"expected 64-char hex, got {len(id1.toString())}" + + +async def test_namespace_id_from_string(env): + ns = env.TEST_DO + original = ns.idFromName("roundtrip") + hex_str = original.toString() + restored = ns.idFromString(hex_str) + assert original.toString() == restored.toString(), "idFromString roundtrip failed" + + +async def test_rpc_echo(env): + stub = await _get_stub(env) + assert await stub.test_rpc_echo("hello") == "hello" + assert await stub.test_rpc_echo(42) == 42 + assert await stub.test_rpc_echo(True) is True + + +async def test_rpc_dict(env): + stub = await _get_stub(env) + result = await stub.test_rpc_dict({"key": "value"}) + assert result["received"]["key"] == "value" + assert result["added"] is True + + +async def test_stub_id(env): + ns = env.TEST_DO + id = ns.idFromName("stub_test") + stub = ns.get(id) + assert stub.id.toString() == id.toString() + assert stub.name == "stub_test" + + +async def test_fetch(env): + stub = await _get_stub(env, "fetch_test") + resp = await stub.fetch("http://fake-host/ping") + text = await resp.text() + assert text == "pong from DO", f"expected 'pong from DO', got {text!r}" + + +async def test_block_concurrency_while(env): + stub = await _get_stub(env) + await stub.test_block_concurrency_while() + + +async def test_storage_sync(env): + stub = await _get_stub(env) + await stub.test_storage_sync() + + +async def test_id_equals(env): + ns = env.TEST_DO + id1 = ns.idFromName("equal_test") + id2 = ns.idFromName("equal_test") + id3 = ns.idFromName("different") + assert id1.equals(id2), "same name should produce equal IDs" + assert not id1.equals(id3), "different names should produce different IDs" + + +DO_TESTS = { + "storage_put_and_get": test_storage_put_and_get, + "storage_get_nonexistent": test_storage_get_nonexistent, + "storage_put_multiple": test_storage_put_multiple, + "storage_get_multiple": test_storage_get_multiple, + "storage_delete": test_storage_delete, + "storage_delete_multiple": test_storage_delete_multiple, + "storage_list": test_storage_list, + "storage_list_with_options": test_storage_list_with_options, + "storage_delete_all": test_storage_delete_all, + "storage_value_types": test_storage_value_types, + "sql_exec_and_query": test_sql_exec_and_query, + "sql_cursor_one": test_sql_cursor_one, + "sql_cursor_column_names": test_sql_cursor_column_names, + "sql_cursor_rows_read_written": test_sql_cursor_rows_read_written, + "sql_database_size": test_sql_database_size, + "alarm_set_get_delete": test_alarm_set_get_delete, + "transaction": test_transaction, + "ctx_id": test_ctx_id, + "namespace_id_from_name": test_namespace_id_from_name, + "namespace_new_unique_id": test_namespace_new_unique_id, + "namespace_id_from_string": test_namespace_id_from_string, + "rpc_echo": test_rpc_echo, + "rpc_dict": test_rpc_dict, + "stub_id": test_stub_id, + "fetch": test_fetch, + "block_concurrency_while": test_block_concurrency_while, + "storage_sync": test_storage_sync, + "id_equals": test_id_equals, +} diff --git a/packages/cli/tests/bindings-test/src/durable_object.py b/packages/cli/tests/bindings-test/src/durable_object.py new file mode 100644 index 0000000..d1e81e9 --- /dev/null +++ b/packages/cli/tests/bindings-test/src/durable_object.py @@ -0,0 +1,213 @@ +from workers import DurableObject + + +class TestDurableObject(DurableObject): + def __init__(self, ctx, env): + super().__init__(ctx, env) + + async def fetch(self, request): + from urllib.parse import urlparse + + path = urlparse(request.url).path + if path == "/ping": + from workers import Response + + return Response("pong from DO") + from workers import Response + + return Response("not found", status=404) + + async def test_storage_put_and_get(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put("key1", "value1") + result = await self.ctx.storage.get("key1") + assert result == "value1", f"expected 'value1', got {result!r}" + + async def test_storage_get_nonexistent(self): + await self.ctx.storage.deleteAll() + result = await self.ctx.storage.get("missing") + assert result is None, f"expected None, got {result!r}" + + async def test_storage_put_multiple(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put({"a": 1, "b": 2, "c": 3}) + a = await self.ctx.storage.get("a") + b = await self.ctx.storage.get("b") + c = await self.ctx.storage.get("c") + assert a == 1 and b == 2 and c == 3, f"got a={a!r}, b={b!r}, c={c!r}" + + async def test_storage_get_multiple(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put({"a": 1, "b": 2}) + result = await self.ctx.storage.get(["a", "b", "missing"]) + assert result.get("a") == 1 + assert result.get("b") == 2 + + async def test_storage_delete(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put("to_delete", "gone") + deleted = await self.ctx.storage.delete("to_delete") + assert deleted is True, f"expected True, got {deleted!r}" + result = await self.ctx.storage.get("to_delete") + assert result is None or repr(result) == "undefined", ( + "expected undefined after delete" + ) + + async def test_storage_delete_multiple(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put({"d1": 1, "d2": 2, "d3": 3}) + count = await self.ctx.storage.delete(["d1", "d2"]) + assert count == 2, f"expected 2, got {count!r}" + + async def test_storage_list(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put({"list:a": 1, "list:b": 2, "list:c": 3, "other": 99}) + result = await self.ctx.storage.list({"prefix": "list:"}) + assert len(result) == 3, f"expected 3 entries, got {len(result)}" + assert result["list:a"] == 1 + assert result["list:b"] == 2 + + async def test_storage_list_with_options(self): + await self.ctx.storage.deleteAll() + for i in range(5): + await self.ctx.storage.put(f"item:{i:03d}", i) + result = await self.ctx.storage.list({"prefix": "item:", "limit": 2}) + assert len(result) == 2, f"expected 2 entries, got {len(result)}" + + async def test_storage_delete_all(self): + await self.ctx.storage.put("before_clear", "exists") + await self.ctx.storage.deleteAll() + result = await self.ctx.storage.get("before_clear") + assert result is None or repr(result) == "undefined", ( + "expected undefined after deleteAll" + ) + + async def test_sql_exec_and_query(self): + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_sql") + self.ctx.storage.sql.exec( + "CREATE TABLE test_sql (id INTEGER PRIMARY KEY, val TEXT)" + ) + self.ctx.storage.sql.exec( + "INSERT INTO test_sql (id, val) VALUES (?, ?)", 1, "hello" + ) + self.ctx.storage.sql.exec( + "INSERT INTO test_sql (id, val) VALUES (?, ?)", 2, "world" + ) + rows = self.ctx.storage.sql.exec( + "SELECT id, val FROM test_sql ORDER BY id" + ).toArray() + assert len(rows) == 2, f"expected 2 rows, got {len(rows)}" + assert rows[0]["id"] == 1 and rows[0]["val"] == "hello" + assert rows[1]["id"] == 2 and rows[1]["val"] == "world" + self.ctx.storage.sql.exec("DROP TABLE test_sql") + + async def test_sql_cursor_one(self): + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_one") + self.ctx.storage.sql.exec( + "CREATE TABLE test_one (id INTEGER PRIMARY KEY, val TEXT)" + ) + self.ctx.storage.sql.exec("INSERT INTO test_one VALUES (1, 'only')") + row = self.ctx.storage.sql.exec("SELECT val FROM test_one").one() + assert row["val"] == "only", f"expected 'only', got {row!r}" + self.ctx.storage.sql.exec("DROP TABLE test_one") + + async def test_sql_cursor_column_names(self): + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_cols") + self.ctx.storage.sql.exec("CREATE TABLE test_cols (foo INTEGER, bar TEXT)") + self.ctx.storage.sql.exec("INSERT INTO test_cols VALUES (1, 'a')") + cursor = self.ctx.storage.sql.exec("SELECT foo, bar FROM test_cols") + cols = list(cursor.columnNames) + cursor.toArray() + del cursor # free the cursor otherwise we get Error: database table is locked: SQLITE_LOCKED + assert cols == ["foo", "bar"], f"expected ['foo', 'bar'], got {cols}" + self.ctx.storage.sql.exec("DROP TABLE test_cols") + + async def test_sql_cursor_rows_read_written(self): + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_metrics") + self.ctx.storage.sql.exec("CREATE TABLE test_metrics (id INTEGER PRIMARY KEY)") + write_cursor = self.ctx.storage.sql.exec("INSERT INTO test_metrics VALUES (1)") + write_cursor.toArray() + rows_written = write_cursor.rowsWritten + del write_cursor + assert rows_written >= 1, f"expected rowsWritten >= 1, got {rows_written}" + read_cursor = self.ctx.storage.sql.exec("SELECT * FROM test_metrics") + read_cursor.toArray() + rows_read = read_cursor.rowsRead + del read_cursor # free the cursor otherwise we get Error: database table is locked: SQLITE_LOCKED + assert rows_read >= 1, f"expected rowsRead >= 1, got {rows_read}" + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_metrics") + + async def test_sql_database_size(self): + size = self.ctx.storage.sql.databaseSize + assert isinstance(size, int | float) and size >= 0, ( + f"expected non-negative number, got {size!r}" + ) + + async def test_alarm_set_get_delete(self): + await self.ctx.storage.deleteAlarm() + alarm_before = await self.ctx.storage.getAlarm() + assert alarm_before is None, f"expected no alarm, got {alarm_before!r}" + from datetime import datetime, timedelta + + future_time = datetime.now() + timedelta(minutes=1) + await self.ctx.storage.setAlarm(future_time) + alarm_after = await self.ctx.storage.getAlarm() + assert alarm_after is not None, f"expected alarm time, got {alarm_after!r}" + await self.ctx.storage.deleteAlarm() + alarm_deleted = await self.ctx.storage.getAlarm() + assert alarm_deleted is None, "expected no alarm after delete" + + async def test_transaction(self): + await self.ctx.storage.deleteAll() + + async def txn_body(txn): + await txn.put("txn_key", "txn_value") + val = await txn.get("txn_key") + return val + + result = await self.ctx.storage.transaction(txn_body) + assert result == "txn_value", f"expected 'txn_value', got {result!r}" + persisted = await self.ctx.storage.get("txn_key") + assert persisted == "txn_value", ( + f"expected persisted 'txn_value', got {persisted!r}" + ) + + async def test_ctx_id(self): + id_str = self.ctx.id.toString() + assert isinstance(id_str, str) and len(id_str) == 64, ( + f"expected 64-char hex, got {id_str!r}" + ) + assert self.ctx.id.name is not None, "expected id.name for named DO" + + async def test_block_concurrency_while(self): + async def init(): + await self.ctx.storage.put("bcw_key", "bcw_value") + return 42 + + result = await self.ctx.blockConcurrencyWhile(init) + assert result == 42, f"expected 42, got {result!r}" + val = await self.ctx.storage.get("bcw_key") + assert val == "bcw_value", f"expected 'bcw_value', got {val!r}" + + async def test_storage_sync(self): + await self.ctx.storage.put("sync_key", "sync_value") + await self.ctx.storage.sync() + result = await self.ctx.storage.get("sync_key") + assert result == "sync_value", f"expected 'sync_value', got {result!r}" + + async def test_rpc_echo(self, value): + return value + + async def test_rpc_dict(self, data): + return {"received": data, "added": True} + + async def test_storage_value_types(self): + await self.ctx.storage.deleteAll() + await self.ctx.storage.put("str", "hello") + await self.ctx.storage.put("int", 42) + await self.ctx.storage.put("float", 3.14) + await self.ctx.storage.put("bool", True) + assert await self.ctx.storage.get("str") == "hello" + assert await self.ctx.storage.get("int") == 42 + assert abs(await self.ctx.storage.get("float") - 3.14) < 0.001 + assert await self.ctx.storage.get("bool") is True diff --git a/packages/cli/tests/bindings-test/src/worker.py b/packages/cli/tests/bindings-test/src/worker.py index 3edce73..6733521 100644 --- a/packages/cli/tests/bindings-test/src/worker.py +++ b/packages/cli/tests/bindings-test/src/worker.py @@ -1,6 +1,10 @@ import traceback from d1_test import D1_TESTS +from do_test import DO_TESTS +from durable_object import ( + TestDurableObject, # noqa: F401 - wrangler discovers by class_name +) from kv_test import KV_TESTS from r2_test import R2_TESTS from workers import Response, WorkerEntrypoint @@ -9,6 +13,7 @@ "kv": KV_TESTS, "r2": R2_TESTS, "d1": D1_TESTS, + "do": DO_TESTS, } diff --git a/packages/cli/tests/bindings-test/wrangler.jsonc b/packages/cli/tests/bindings-test/wrangler.jsonc index 8c51d93..9cdc3ca 100644 --- a/packages/cli/tests/bindings-test/wrangler.jsonc +++ b/packages/cli/tests/bindings-test/wrangler.jsonc @@ -15,5 +15,13 @@ "database_id": "00000000-0000-0000-0000-000000000000", "database_name": "test-db" } + ], + "durable_objects": { + "bindings": [ + { "name": "TEST_DO", "class_name": "TestDurableObject" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["TestDurableObject"] } ] } diff --git a/packages/cli/tests/test_bindings.py b/packages/cli/tests/test_bindings.py index 1c07aab..bf49462 100644 --- a/packages/cli/tests/test_bindings.py +++ b/packages/cli/tests/test_bindings.py @@ -199,6 +199,40 @@ def binding_suite(suite: str, tests: list[str]) -> type: ], ) +TestDO = binding_suite( + "do", + [ + "storage_put_and_get", + "storage_get_nonexistent", + "storage_put_multiple", + "storage_get_multiple", + "storage_delete", + "storage_delete_multiple", + "storage_list", + "storage_list_with_options", + "storage_delete_all", + "storage_value_types", + "sql_exec_and_query", + "sql_cursor_one", + "sql_cursor_column_names", + "sql_cursor_rows_read_written", + "sql_database_size", + "alarm_set_get_delete", + "transaction", + "ctx_id", + "namespace_id_from_name", + "namespace_new_unique_id", + "namespace_id_from_string", + "rpc_echo", + "rpc_dict", + "stub_id", + "fetch", + "block_concurrency_while", + "storage_sync", + "id_equals", + ], +) + TestD1 = binding_suite( "d1", [ diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index 5b25418..5da9988 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -1000,6 +1000,9 @@ def _raise_on_disabled_type(value): if isinstance(value, _RPCWrapper): return + if callable(value) and not isinstance(value, type): + return + if _is_js_instance(value, "RegExp"): raise TypeError(f"{value.constructor.name} cannot be sent over RPC.") @@ -1036,6 +1039,11 @@ def _python_to_rpc_default_converter(obj, convert, cache): if isinstance(obj, Exception): return js.Error.new(str(obj)) + if callable(obj) and not isinstance(obj, type): + # Wrap function with create_proxy so that + # it doesn't get garbage collected + return create_proxy(obj) + _raise_on_disabled_type(obj) return obj @@ -1153,6 +1161,9 @@ def __init__(self, ctx: "DurableObjectState"): def __getattr__(self, name: str): result = getattr(self._ctx, name) + if _is_js_instance(result, "DurableObjectStorage"): + # durable_object.ctx.storage + result = _RPCWrapper(result) setattr(self, name, result) return result From c7d38571c6a759a5802cd157947d3d6d3257565a Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Thu, 28 May 2026 14:39:41 +0900 Subject: [PATCH 2/2] chore: update comment --- packages/cli/tests/bindings-test/src/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/tests/bindings-test/src/worker.py b/packages/cli/tests/bindings-test/src/worker.py index 6733521..9c72fc7 100644 --- a/packages/cli/tests/bindings-test/src/worker.py +++ b/packages/cli/tests/bindings-test/src/worker.py @@ -3,7 +3,7 @@ from d1_test import D1_TESTS from do_test import DO_TESTS from durable_object import ( - TestDurableObject, # noqa: F401 - wrangler discovers by class_name + TestDurableObject, # noqa: F401 - side effect of registering the Durable Object ) from kv_test import KV_TESTS from r2_test import R2_TESTS