From 49daa62a6c6ca3a27f1e1c04e2ee01eb7a9a1639 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 07:22:56 +0000 Subject: [PATCH 1/5] Initial plan From 9606b67f308ee7acd2dc48f2621c37e089207167 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 07:25:46 +0000 Subject: [PATCH 2/5] Initial plan for replacing Date with Temporal API Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> --- sdks/typescript/package-lock.json | 87 ++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 20 deletions(-) diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index 620734c..5cdf14b 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -1121,7 +1121,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/better-sqlite3": { "version": "12.6.0", @@ -1143,6 +1144,7 @@ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "license": "MIT", + "peer": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -1152,6 +1154,7 @@ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "license": "MIT", + "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -1187,6 +1190,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -1206,13 +1210,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", + "peer": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -1228,6 +1234,7 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", + "peer": true, "engines": { "node": ">=4.0.0" } @@ -1237,6 +1244,7 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8" } @@ -1246,6 +1254,7 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", + "peer": true, "dependencies": { "once": "^1.4.0" } @@ -1327,6 +1336,7 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", + "peer": true, "engines": { "node": ">=6" } @@ -1363,13 +1373,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -1390,7 +1402,8 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ieee754": { "version": "1.2.1", @@ -1410,19 +1423,22 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/linkify-it": { "version": "5.0.0", @@ -1481,6 +1497,7 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -1509,6 +1526,7 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1517,7 +1535,8 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/nanoid": { "version": "3.3.11", @@ -1542,13 +1561,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/node-abi": { "version": "3.85.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", "license": "MIT", + "peer": true, "dependencies": { "semver": "^7.3.5" }, @@ -1572,6 +1593,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", + "peer": true, "dependencies": { "wrappy": "1" } @@ -1616,19 +1638,22 @@ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/pg-connection-string": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "license": "ISC", + "peer": true, "engines": { "node": ">=4.0.0" } @@ -1638,6 +1663,7 @@ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", "license": "MIT", + "peer": true, "peerDependencies": { "pg": ">=8.0" } @@ -1646,13 +1672,15 @@ "version": "1.10.3", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", + "peer": true, "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -1669,6 +1697,7 @@ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", "license": "MIT", + "peer": true, "dependencies": { "split2": "^4.1.0" } @@ -1686,7 +1715,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1728,6 +1756,7 @@ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -1737,6 +1766,7 @@ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1746,6 +1776,7 @@ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1755,6 +1786,7 @@ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", + "peer": true, "dependencies": { "xtend": "^4.0.0" }, @@ -1767,6 +1799,7 @@ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "license": "MIT", + "peer": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -1793,6 +1826,7 @@ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -1813,6 +1847,7 @@ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "peer": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -1828,6 +1863,7 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -1900,13 +1936,15 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -1939,7 +1977,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -1960,6 +1999,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -1981,6 +2021,7 @@ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "license": "ISC", + "peer": true, "engines": { "node": ">= 10.x" } @@ -2004,6 +2045,7 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -2013,6 +2055,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2022,6 +2065,7 @@ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", + "peer": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -2034,6 +2078,7 @@ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -2094,6 +2139,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -2131,7 +2177,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2158,7 +2203,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite": { "version": "7.3.1", @@ -2166,7 +2212,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2335,13 +2380,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.4" } From f86f9de1403fb23169ff429f214af1af7edfce2b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 07:35:14 +0000 Subject: [PATCH 3/5] Replace Date with Temporal API for datetime types in TypeScript SDK Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> --- sdks/typescript/package-lock.json | 18 ++- sdks/typescript/package.json | 5 +- sdks/typescript/src/index.ts | 15 +++ sdks/typescript/src/sqlite-types.ts | 15 ++- sdks/typescript/src/sqlite.ts | 13 ++- sdks/typescript/src/temporal-types.ts | 106 +++++++++++++++++ sdks/typescript/test/basic.test.ts | 11 +- sdks/typescript/test/events.test.ts | 2 +- sdks/typescript/test/retry.test.ts | 4 +- sdks/typescript/test/setup.ts | 46 +++++--- sdks/typescript/test/sqlite.test.ts | 9 +- sdks/typescript/test/step.test.ts | 2 +- sdks/typescript/test/temporal-types.test.ts | 120 ++++++++++++++++++++ 13 files changed, 330 insertions(+), 36 deletions(-) create mode 100644 sdks/typescript/src/temporal-types.ts create mode 100644 sdks/typescript/test/temporal-types.test.ts diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index 5cdf14b..7741a94 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -9,7 +9,8 @@ "version": "0.2.1-alpha.2", "license": "Apache-2.0", "dependencies": { - "absurd-sdk": "https://github.com/bcho/absurd/releases/download/sdks%2Ftypescript%2Fv0.0.7/typescript-sdk-v0.0.7.tgz" + "absurd-sdk": "https://github.com/bcho/absurd/releases/download/sdks%2Ftypescript%2Fv0.0.7/typescript-sdk-v0.0.7.tgz", + "temporal-polyfill": "^0.3.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -2090,6 +2091,21 @@ "node": ">=6" } }, + "node_modules/temporal-polyfill": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz", + "integrity": "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.0" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.0.tgz", + "integrity": "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==", + "license": "ISC" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index fd021d8..e0bc4cf 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -36,7 +36,8 @@ "better-sqlite3": "^12.5.0" }, "dependencies": { - "absurd-sdk": "https://github.com/bcho/absurd/releases/download/sdks%2Ftypescript%2Fv0.0.7/typescript-sdk-v0.0.7.tgz" + "absurd-sdk": "https://github.com/bcho/absurd/releases/download/sdks%2Ftypescript%2Fv0.0.7/typescript-sdk-v0.0.7.tgz", + "temporal-polyfill": "^0.3.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -48,4 +49,4 @@ "engines": { "node": ">=18.0.0" } -} \ No newline at end of file +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 81d7f21..c5647ec 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -35,6 +35,21 @@ export type { SQLiteVerboseLog, } from "./sqlite-types"; +// Export Temporal types and helpers +export { + Temporal, + type Duration, + type Instant, + durationFromSeconds, + durationToSeconds, + instantFromDate, + instantFromEpochMilliseconds, + instantToDate, + instantToEpochMilliseconds, + isDuration, + isInstant, +} from "./temporal-types"; + export class Absurd extends AbsurdBase implements AbsurdClient { private db: SQLiteDatabase; diff --git a/sdks/typescript/src/sqlite-types.ts b/sdks/typescript/src/sqlite-types.ts index 8882a10..c744b9c 100644 --- a/sdks/typescript/src/sqlite-types.ts +++ b/sdks/typescript/src/sqlite-types.ts @@ -1,4 +1,17 @@ -export type SQLiteBindValue = number | string | Buffer | bigint | Date | null; +import type { Instant } from "./temporal-types"; + +/** + * Values that can be bound to SQLite prepared statements. + * Supports both JavaScript Date and Temporal.Instant for datetime values. + */ +export type SQLiteBindValue = + | number + | string + | Buffer + | bigint + | Date + | Instant + | null; export type SQLiteBindParams = | SQLiteBindValue[] diff --git a/sdks/typescript/src/sqlite.ts b/sdks/typescript/src/sqlite.ts index a83228a..8066e36 100644 --- a/sdks/typescript/src/sqlite.ts +++ b/sdks/typescript/src/sqlite.ts @@ -5,6 +5,11 @@ import type { SQLiteStatement, SQLiteVerboseLog, } from "./sqlite-types"; +import { + isInstant, + instantFromEpochMilliseconds, + instantToDate, +} from "./temporal-types"; export class SqliteConnection implements Queryable { private readonly db: SQLiteDatabase; @@ -164,7 +169,8 @@ function decodeColumnValue( `Expected datetime column ${columnName} to be a number, got ${typeof value}` ); } - return new Date(value) as V; + // Return Temporal.Instant for datetime columns + return instantFromEpochMilliseconds(value) as V; } // For other types, return as is @@ -172,6 +178,11 @@ function decodeColumnValue( } function encodeColumnValue(value: any): any { + // Handle Temporal.Instant - convert to ISO string + if (isInstant(value)) { + return instantToDate(value).toISOString(); + } + // Handle legacy Date objects if (value instanceof Date) { return value.toISOString(); } diff --git a/sdks/typescript/src/temporal-types.ts b/sdks/typescript/src/temporal-types.ts new file mode 100644 index 0000000..84f7bea --- /dev/null +++ b/sdks/typescript/src/temporal-types.ts @@ -0,0 +1,106 @@ +/** + * Temporal API types for representing dates/times and durations. + * + * This module re-exports the Temporal types from temporal-polyfill and provides + * helper functions for working with them in the context of SQLite. + * + * @see https://tc39.es/proposal-temporal/docs/ + * @see https://github.com/fullcalendar/temporal-polyfill + */ + +import { Temporal } from "temporal-polyfill"; + +// Re-export the Temporal namespace for use with this SDK +export { Temporal }; + +/** + * Represents an exact point in time, independent of timezone. + * This is a re-export of Temporal.Instant from temporal-polyfill. + */ +export type Instant = Temporal.Instant; + +/** + * Represents a duration of time. + * This is a re-export of Temporal.Duration from temporal-polyfill. + */ +export type Duration = Temporal.Duration; + +/** + * Creates a Duration from seconds. + * @param seconds Number of seconds + * @returns A Temporal.Duration representing the given seconds + */ +export function durationFromSeconds(seconds: number): Duration { + return Temporal.Duration.from({ seconds }); +} + +/** + * Converts a Duration to total seconds. + * @param duration The duration to convert + * @returns Total seconds (may be fractional for sub-second durations) + */ +export function durationToSeconds(duration: Duration): number { + return duration.total({ unit: "second" }); +} + +/** + * Creates an Instant from a Date object. + * @param date JavaScript Date object + * @returns A Temporal.Instant + */ +export function instantFromDate(date: Date): Instant { + return Temporal.Instant.fromEpochMilliseconds(date.getTime()); +} + +/** + * Creates an Instant from epoch milliseconds. + * @param epochMs Unix timestamp in milliseconds + * @returns A Temporal.Instant + */ +export function instantFromEpochMilliseconds(epochMs: number): Instant { + return Temporal.Instant.fromEpochMilliseconds(epochMs); +} + +/** + * Converts an Instant to a Date object. + * @param instant The instant to convert + * @returns A JavaScript Date object + */ +export function instantToDate(instant: Instant): Date { + return new Date(instant.epochMilliseconds); +} + +/** + * Converts an Instant to epoch milliseconds. + * @param instant The instant to convert + * @returns Unix timestamp in milliseconds + */ +export function instantToEpochMilliseconds(instant: Instant): number { + return instant.epochMilliseconds; +} + +/** + * Type guard to check if a value is a Temporal.Instant. + * @param value The value to check + * @returns True if the value is a Temporal.Instant + */ +export function isInstant(value: unknown): value is Instant { + return ( + value !== null && + typeof value === "object" && + value instanceof Temporal.Instant + ); +} + +/** + * Type guard to check if a value is a Temporal.Duration. + * @param value The value to check + * @returns True if the value is a Temporal.Duration + */ +export function isDuration(value: unknown): value is Duration { + return ( + value !== null && + typeof value === "object" && + value instanceof Temporal.Duration + ); +} diff --git a/sdks/typescript/test/basic.test.ts b/sdks/typescript/test/basic.test.ts index 961d134..bac6a65 100644 --- a/sdks/typescript/test/basic.test.ts +++ b/sdks/typescript/test/basic.test.ts @@ -9,6 +9,7 @@ import { } from "vitest"; import { createTestAbsurd, randomName, type TestContext } from "./setup.js"; import type { Absurd } from "../src/index.js"; +import { instantFromDate } from "../src/temporal-types.js"; import { EventEmitter, once } from "events"; describe("Basic SDK Operations", () => { @@ -170,7 +171,7 @@ describe("Basic SDK Operations", () => { const scheduledRun = await ctx.getRun(runID); expect(scheduledRun).toMatchObject({ state: "sleeping", - available_at: wakeAt, + available_at: instantFromDate(wakeAt), wake_event: null, }); @@ -188,7 +189,7 @@ describe("Basic SDK Operations", () => { const resumedRun = await ctx.getRun(runID); expect(resumedRun).toMatchObject({ state: "running", - started_at: wakeAt, + started_at: instantFromDate(wakeAt), }); }); @@ -215,7 +216,7 @@ describe("Basic SDK Operations", () => { expect(running).toMatchObject({ state: "running", claimed_by: "worker-a", - claim_expires_at: new Date(baseTime.getTime() + 30 * 1000), + claim_expires_at: instantFromDate(new Date(baseTime.getTime() + 30 * 1000)), }); await ctx.setFakeNow(new Date(baseTime.getTime() + 5 * 60 * 1000)); @@ -274,7 +275,7 @@ describe("Basic SDK Operations", () => { const runRow = await ctx.getRun(runID); expect(runRow).toMatchObject({ claimed_by: "worker-clean", - claim_expires_at: new Date(base.getTime() + 60 * 1000), + claim_expires_at: instantFromDate(new Date(base.getTime() + 60 * 1000)), }); const beforeTTL = new Date(finishTime.getTime() + 30 * 60 * 1000); @@ -481,7 +482,7 @@ describe("Basic SDK Operations", () => { const getExpiresAt = async (runID: string) => { const run = await ctx.getRun(runID); - return run?.claim_expires_at ? run.claim_expires_at.getTime() : 0; + return run?.claim_expires_at ? run.claim_expires_at.epochMilliseconds : 0; }; absurd.workBatch("test-worker", claimTimeout); diff --git a/sdks/typescript/test/events.test.ts b/sdks/typescript/test/events.test.ts index 65da5da..3624a9c 100644 --- a/sdks/typescript/test/events.test.ts +++ b/sdks/typescript/test/events.test.ts @@ -109,7 +109,7 @@ describe("Event system", () => { wake_event: eventName, }); const expectedWake = new Date(baseTime.getTime() + timeoutSeconds * 1000); - expect(sleepingRun?.available_at?.getTime()).toBe(expectedWake.getTime()); + expect(sleepingRun?.available_at?.epochMilliseconds).toBe(expectedWake.getTime()); await ctx.setFakeNow(new Date(expectedWake.getTime() + 1000)); await absurd.workBatch("worker1", 120, 1); diff --git a/sdks/typescript/test/retry.test.ts b/sdks/typescript/test/retry.test.ts index 1abfdbf..b2c8e34 100644 --- a/sdks/typescript/test/retry.test.ts +++ b/sdks/typescript/test/retry.test.ts @@ -312,8 +312,8 @@ describe("Retry and cancellation", () => { await absurd.cancelTask(taskID); const second = await ctx.getTask(taskID); - expect(second?.cancelled_at?.getTime()).toBe( - first?.cancelled_at?.getTime(), + expect(second?.cancelled_at?.epochMilliseconds).toBe( + first?.cancelled_at?.epochMilliseconds, ); }); diff --git a/sdks/typescript/test/setup.ts b/sdks/typescript/test/setup.ts index 56fe45f..6ef522e 100644 --- a/sdks/typescript/test/setup.ts +++ b/sdks/typescript/test/setup.ts @@ -11,7 +11,12 @@ import { } from "absurd-sdk"; import { SqliteConnection } from "../src/sqlite"; -import type { Absurd, SQLiteDatabase } from "../src/index"; +import type { Absurd, SQLiteDatabase, Instant } from "../src/index"; +import { + instantFromDate, + instantToEpochMilliseconds, + isInstant, +} from "../src/temporal-types"; // Database row types matching the SQLite schema export interface TaskRow { @@ -22,8 +27,8 @@ export interface TaskRow { retry_strategy: JsonValue | null; max_attempts: number | null; cancellation: JsonValue | null; - enqueue_at: Date; - first_started_at: Date | null; + enqueue_at: Instant; + first_started_at: Instant | null; state: | "pending" | "running" @@ -34,7 +39,7 @@ export interface TaskRow { attempts: number; last_attempt_run: string | null; completed_payload: JsonValue | null; - cancelled_at: Date | null; + cancelled_at: Instant | null; } export interface RunRow { @@ -49,16 +54,16 @@ export interface RunRow { | "failed" | "cancelled"; claimed_by: string | null; - claim_expires_at: Date | null; - available_at: Date; + claim_expires_at: Instant | null; + available_at: Instant; wake_event: string | null; event_payload: JsonValue | null; - started_at: Date | null; - completed_at: Date | null; - failed_at: Date | null; + started_at: Instant | null; + completed_at: Instant | null; + failed_at: Instant | null; result: JsonValue | null; failure_reason: JsonValue | null; - created_at: Date; + created_at: Instant; } interface SqliteFixture { @@ -90,7 +95,7 @@ export interface TestContext { getTask(taskID: string): Promise; getRun(runID: string): Promise; getRuns(taskID: string): Promise; - setFakeNow(ts: Date | null): Promise; + setFakeNow(ts: Date | Instant | null): Promise; sleep(ms: number): Promise; getRemainingTasksCount(): Promise; getRemainingEventsCount(): Promise; @@ -103,7 +108,7 @@ export interface TestContext { state: JsonValue; owner_run_id: string; } | null>; - scheduleRun(runID: string, wakeAt: Date): Promise; + scheduleRun(runID: string, wakeAt: Date | Instant): Promise; completeRun(runID: string, payload: JsonValue): Promise; cleanupTasksByTTL(ttlSeconds: number, limit: number): Promise; cleanupEventsByTTL(ttlSeconds: number, limit: number): Promise; @@ -194,7 +199,7 @@ export async function createTestAbsurd( getTask: (taskID: string) => getTask(fixture.conn, taskID, queueName), getRun: (runID: string) => getRun(fixture.conn, runID, queueName), getRuns: (taskID: string) => getRuns(fixture.conn, taskID, queueName), - setFakeNow: (ts: Date | null) => setFakeNow(fixture.conn, ts), + setFakeNow: (ts: Date | Instant | null) => setFakeNow(fixture.conn, ts), sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), getRemainingTasksCount: () => getRemainingTasksCount(fixture.conn, queueName), @@ -203,7 +208,7 @@ export async function createTestAbsurd( getWaitsCount: () => getWaitsCount(fixture.conn, queueName), getCheckpoint: (taskID: string, checkpointName: string) => getCheckpoint(fixture.conn, taskID, checkpointName, queueName), - scheduleRun: (runID: string, wakeAt: Date) => + scheduleRun: (runID: string, wakeAt: Date | Instant) => scheduleRun(fixture.conn, runID, wakeAt, queueName), completeRun: (runID: string, payload: JsonValue) => completeRun(fixture.conn, runID, payload, queueName), @@ -260,13 +265,14 @@ export async function createTestAbsurd( async function setFakeNow( conn: SqliteConnection, - ts: Date | null + ts: Date | Instant | null ): Promise { if (ts === null) { await conn.exec("select absurd.set_fake_now(null)"); return; } - await conn.exec("select absurd.set_fake_now($1)", [ts.getTime()]); + const epochMs = isInstant(ts) ? instantToEpochMilliseconds(ts) : ts.getTime(); + await conn.exec("select absurd.set_fake_now($1)", [epochMs]); } async function cleanupTasks( @@ -449,13 +455,17 @@ async function getCheckpoint( async function scheduleRun( conn: SqliteConnection, runID: string, - wakeAt: Date, + wakeAt: Date | Instant, queue: string ): Promise { + // Convert Instant to Date for the SQLite extension, which accepts Date + const wakeAtDate = isInstant(wakeAt) + ? new Date(instantToEpochMilliseconds(wakeAt)) + : wakeAt; await conn.exec(`SELECT absurd.schedule_run($1, $2, $3)`, [ queue, runID, - wakeAt, + wakeAtDate, ]); } diff --git a/sdks/typescript/test/sqlite.test.ts b/sdks/typescript/test/sqlite.test.ts index 74aa788..464aa2d 100644 --- a/sdks/typescript/test/sqlite.test.ts +++ b/sdks/typescript/test/sqlite.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { SqliteConnection } from "../src/sqlite"; import type { SQLiteDatabase } from "../src/sqlite-types"; +import { isInstant, type Instant } from "../src/temporal-types"; describe("SqliteConnection", () => { it("rewrites postgres-style params and absurd schema names", async () => { @@ -69,7 +70,7 @@ describe("SqliteConnection", () => { db.close(); }); - it("decodes datetime columns into Date objects", async () => { + it("decodes datetime columns into Temporal.Instant objects", async () => { const db = new sqlite(":memory:") as unknown as SQLiteDatabase; const conn = new SqliteConnection(db); const now = Date.now(); @@ -77,12 +78,12 @@ describe("SqliteConnection", () => { await conn.exec("CREATE TABLE t_date (created_at DATETIME)"); await conn.exec("INSERT INTO t_date (created_at) VALUES ($1)", [now]); - const { rows } = await conn.query<{ created_at: Date }>( + const { rows } = await conn.query<{ created_at: Instant }>( "SELECT created_at FROM t_date" ); - expect(rows[0]?.created_at).toBeInstanceOf(Date); - expect(rows[0]?.created_at.getTime()).toBe(now); + expect(isInstant(rows[0]?.created_at)).toBe(true); + expect(rows[0]?.created_at.epochMilliseconds).toBe(now); db.close(); }); diff --git a/sdks/typescript/test/step.test.ts b/sdks/typescript/test/step.test.ts index 9125be5..c898f85 100644 --- a/sdks/typescript/test/step.test.ts +++ b/sdks/typescript/test/step.test.ts @@ -205,7 +205,7 @@ describe("Step functionality", () => { state: "sleeping", }); const wakeTime = new Date(base.getTime() + durationSeconds * 1000); - expect(sleepingRun?.available_at?.getTime()).toBe(wakeTime.getTime()); + expect(sleepingRun?.available_at?.epochMilliseconds).toBe(wakeTime.getTime()); const resumeTime = new Date(wakeTime.getTime() + 5 * 1000); vi.setSystemTime(resumeTime); diff --git a/sdks/typescript/test/temporal-types.test.ts b/sdks/typescript/test/temporal-types.test.ts new file mode 100644 index 0000000..1a416eb --- /dev/null +++ b/sdks/typescript/test/temporal-types.test.ts @@ -0,0 +1,120 @@ +import { describe, test, expect } from "vitest"; +import { + Temporal, + durationFromSeconds, + durationToSeconds, + instantFromDate, + instantFromEpochMilliseconds, + instantToDate, + instantToEpochMilliseconds, + isInstant, + isDuration, +} from "../src/temporal-types"; + +describe("Temporal types", () => { + describe("Duration helpers", () => { + test("durationFromSeconds creates a Duration from seconds", () => { + const duration = durationFromSeconds(120); + expect(isDuration(duration)).toBe(true); + expect(duration.seconds).toBe(120); + }); + + test("durationToSeconds converts Duration to total seconds", () => { + const duration = Temporal.Duration.from({ minutes: 2, seconds: 30 }); + expect(durationToSeconds(duration)).toBe(150); + }); + + test("durationFromSeconds and durationToSeconds are inverse operations", () => { + const original = 3661; // 1 hour, 1 minute, 1 second + const duration = durationFromSeconds(original); + expect(durationToSeconds(duration)).toBe(original); + }); + }); + + describe("Instant helpers", () => { + test("instantFromDate creates an Instant from a Date", () => { + const date = new Date("2024-05-01T12:00:00.000Z"); + const instant = instantFromDate(date); + expect(isInstant(instant)).toBe(true); + expect(instant.epochMilliseconds).toBe(date.getTime()); + }); + + test("instantToDate converts an Instant back to Date", () => { + const instant = Temporal.Instant.from("2024-05-01T12:00:00Z"); + const date = instantToDate(instant); + expect(date).toBeInstanceOf(Date); + expect(date.toISOString()).toBe("2024-05-01T12:00:00.000Z"); + }); + + test("instantFromDate and instantToDate are inverse operations", () => { + const originalDate = new Date("2024-06-15T08:30:45.123Z"); + const instant = instantFromDate(originalDate); + const roundTripped = instantToDate(instant); + expect(roundTripped.getTime()).toBe(originalDate.getTime()); + }); + + test("instantFromEpochMilliseconds creates Instant from epoch ms", () => { + const epochMs = 1714564800000; // 2024-05-01T12:00:00Z + const instant = instantFromEpochMilliseconds(epochMs); + expect(isInstant(instant)).toBe(true); + expect(instant.epochMilliseconds).toBe(epochMs); + }); + + test("instantToEpochMilliseconds extracts epoch ms from Instant", () => { + const instant = Temporal.Instant.from("2024-05-01T12:00:00Z"); + const epochMs = instantToEpochMilliseconds(instant); + expect(epochMs).toBe(1714564800000); + }); + }); + + describe("Type guards", () => { + test("isInstant returns true for Temporal.Instant", () => { + const instant = Temporal.Instant.from("2024-01-01T00:00:00Z"); + expect(isInstant(instant)).toBe(true); + }); + + test("isInstant returns false for Date", () => { + const date = new Date(); + expect(isInstant(date)).toBe(false); + }); + + test("isInstant returns false for other types", () => { + expect(isInstant(null)).toBe(false); + expect(isInstant(undefined)).toBe(false); + expect(isInstant(12345)).toBe(false); + expect(isInstant("2024-01-01")).toBe(false); + expect(isInstant({})).toBe(false); + }); + + test("isDuration returns true for Temporal.Duration", () => { + const duration = Temporal.Duration.from({ hours: 1 }); + expect(isDuration(duration)).toBe(true); + }); + + test("isDuration returns false for numbers", () => { + expect(isDuration(3600)).toBe(false); + }); + + test("isDuration returns false for other types", () => { + expect(isDuration(null)).toBe(false); + expect(isDuration(undefined)).toBe(false); + expect(isDuration("PT1H")).toBe(false); + expect(isDuration({})).toBe(false); + }); + }); + + describe("Temporal.Duration ISO string parsing", () => { + test("parses ISO 8601 duration strings", () => { + const duration = Temporal.Duration.from("PT1H30M"); + expect(duration.hours).toBe(1); + expect(duration.minutes).toBe(30); + }); + }); + + describe("Temporal.Instant ISO string parsing", () => { + test("parses ISO 8601 instant strings", () => { + const instant = Temporal.Instant.from("2024-05-01T12:30:45.123Z"); + expect(instant.toString()).toBe("2024-05-01T12:30:45.123Z"); + }); + }); +}); From bf763a1e968ab1f886a7df3e2b33de063374eb5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 07:37:48 +0000 Subject: [PATCH 4/5] Address code review feedback: simplify type guards and use direct toString() Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> --- sdks/typescript/src/sqlite.ts | 5 ++--- sdks/typescript/src/temporal-types.ts | 12 ++---------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/sdks/typescript/src/sqlite.ts b/sdks/typescript/src/sqlite.ts index 8066e36..2900318 100644 --- a/sdks/typescript/src/sqlite.ts +++ b/sdks/typescript/src/sqlite.ts @@ -8,7 +8,6 @@ import type { import { isInstant, instantFromEpochMilliseconds, - instantToDate, } from "./temporal-types"; export class SqliteConnection implements Queryable { @@ -178,9 +177,9 @@ function decodeColumnValue( } function encodeColumnValue(value: any): any { - // Handle Temporal.Instant - convert to ISO string + // Handle Temporal.Instant - use built-in toString() for ISO string if (isInstant(value)) { - return instantToDate(value).toISOString(); + return value.toString(); } // Handle legacy Date objects if (value instanceof Date) { diff --git a/sdks/typescript/src/temporal-types.ts b/sdks/typescript/src/temporal-types.ts index 84f7bea..3e2f677 100644 --- a/sdks/typescript/src/temporal-types.ts +++ b/sdks/typescript/src/temporal-types.ts @@ -85,11 +85,7 @@ export function instantToEpochMilliseconds(instant: Instant): number { * @returns True if the value is a Temporal.Instant */ export function isInstant(value: unknown): value is Instant { - return ( - value !== null && - typeof value === "object" && - value instanceof Temporal.Instant - ); + return value instanceof Temporal.Instant; } /** @@ -98,9 +94,5 @@ export function isInstant(value: unknown): value is Instant { * @returns True if the value is a Temporal.Duration */ export function isDuration(value: unknown): value is Duration { - return ( - value !== null && - typeof value === "object" && - value instanceof Temporal.Duration - ); + return value instanceof Temporal.Duration; } From d0c3692704ce453037c27aeb1df392a65fd64d98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 01:42:46 +0000 Subject: [PATCH 5/5] Rebase Temporal API changes on refactored TypeScript SDK Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> --- sdks/typescript/package-lock.json | 177 +-- sdks/typescript/package.json | 6 +- sdks/typescript/src/absurd-types.ts | 149 --- sdks/typescript/src/absurd.ts | 1031 +++++++++++++++++ sdks/typescript/src/index.ts | 192 ++- .../src/{sqlite.ts => sqlite-connection.ts} | 137 ++- sdks/typescript/src/sqlite-types.ts | 2 +- sdks/typescript/test/events.test.ts | 2 +- sdks/typescript/test/index.test.ts | 12 +- sdks/typescript/test/setup.ts | 64 +- sdks/typescript/test/sqlite.test.ts | 60 +- 11 files changed, 1383 insertions(+), 449 deletions(-) delete mode 100644 sdks/typescript/src/absurd-types.ts create mode 100644 sdks/typescript/src/absurd.ts rename sdks/typescript/src/{sqlite.ts => sqlite-connection.ts} (57%) diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index 7741a94..da0b321 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -1,15 +1,14 @@ { "name": "@absurd-sqlite/sdk", - "version": "0.2.1-alpha.2", + "version": "0.3.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@absurd-sqlite/sdk", - "version": "0.2.1-alpha.2", + "version": "0.3.0-alpha.0", "license": "Apache-2.0", "dependencies": { - "absurd-sdk": "https://github.com/bcho/absurd/releases/download/sdks%2Ftypescript%2Fv0.0.7/typescript-sdk-v0.0.7.tgz", "temporal-polyfill": "^0.3.0" }, "devDependencies": { @@ -1068,18 +1067,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/absurd-sdk": { - "version": "0.0.7", - "resolved": "https://github.com/bcho/absurd/releases/download/sdks%2Ftypescript%2Fv0.0.7/typescript-sdk-v0.0.7.tgz", - "integrity": "sha512-VEBMrLufpPtbFN08L6zYn8GFjAHcRA9BlScTvUoOLJUdWrtDkePB5OFVZa5oUotP2D57KJOzyXJe6+J7p69+uw==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "pg": "^8.0.0" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1606,103 +1593,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pg": { - "version": "8.16.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", - "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", - "license": "MIT", - "peer": true, - "dependencies": { - "pg-connection-string": "^2.9.1", - "pg-pool": "^3.10.1", - "pg-protocol": "^1.10.3", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.2.7" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", - "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/pg-connection-string": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", - "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", - "license": "MIT", - "peer": true - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", - "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", - "license": "MIT", - "peer": true, - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", - "license": "MIT", - "peer": true - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "peer": true, - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "peer": true, - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1752,49 +1642,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -2017,16 +1864,6 @@ "node": ">=0.10.0" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">= 10.x" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2399,16 +2236,6 @@ "license": "ISC", "peer": true }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.4" - } - }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index e0bc4cf..27af37a 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@absurd-sqlite/sdk", - "version": "0.2.1-alpha.2", + "version": "0.3.0-alpha.0", "description": "TypeScript SDK for Absurd-SQLite - SQLite-based durable task execution", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -12,7 +12,8 @@ "test": "npm run test:prepare && vitest --silent --run", "test:watch": "npm run test:prepare && vitest", "test:ui": "npm run test:prepare && vitest --ui", - "docs": "typedoc" + "docs": "typedoc", + "docs:serve": "typedoc --watch --out docs/api & python3 -m http.server 8080 -d docs/api" }, "repository": { "type": "git", @@ -36,7 +37,6 @@ "better-sqlite3": "^12.5.0" }, "dependencies": { - "absurd-sdk": "https://github.com/bcho/absurd/releases/download/sdks%2Ftypescript%2Fv0.0.7/typescript-sdk-v0.0.7.tgz", "temporal-polyfill": "^0.3.0" }, "devDependencies": { diff --git a/sdks/typescript/src/absurd-types.ts b/sdks/typescript/src/absurd-types.ts deleted file mode 100644 index c6187d6..0000000 --- a/sdks/typescript/src/absurd-types.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { - ClaimedTask, - JsonValue, - SpawnOptions, - SpawnResult, - TaskHandler, - TaskRegistrationOptions, - WorkerOptions, -} from "absurd-sdk"; - -import type { SQLiteRestBindParams } from "./sqlite-types"; - -/** - * Minimal query interface compatible with Absurd's database operations. - */ -export interface Queryable { - /** - * Execute a parameterized SQL query and return rows. - * @param sql SQL text with parameter placeholders. - * @param params Optional positional parameters. - */ - query>( - sql: string, - params?: SQLiteRestBindParams - ): Promise<{ rows: R[] }>; -} - -/** - * Background worker handle returned by startWorker(). - */ -export interface Worker { - /** - * Stop the worker loop and wait for in-flight tasks to settle. - */ - close(): Promise; -} - -/** - * Absurd client interface. - */ -export interface AbsurdClient { - /** - * Create a new client bound to the provided connection. - * @param con Connection to use for queries. - * @param owned If true, close the connection when close() is called. - */ - // bindToConnection(con: Queryable, owned?: boolean): this; - - /** - * Register a task handler. - * @param options Task registration options. - * @param handler Async task handler. - */ - registerTask

( - options: TaskRegistrationOptions, - handler: TaskHandler - ): void; - - /** - * Create a queue. - * @param queueName Optional queue name (defaults to client queue). - */ - createQueue(queueName?: string): Promise; - /** - * Drop a queue. - * @param queueName Optional queue name (defaults to client queue). - */ - dropQueue(queueName?: string): Promise; - /** - * List available queues. - */ - listQueues(): Promise>; - - /** - * Spawn a task execution. - * @param taskName Task name. - * @param params Task parameters. - * @param options Spawn options including queue and retry behavior. - */ - spawn

( - taskName: string, - params: P, - options?: SpawnOptions - ): Promise; - - /** - * Emit an event on a queue. - * @param eventName Non-empty event name. - * @param payload Optional JSON payload. - * @param queueName Optional queue name (defaults to client queue). - */ - emitEvent( - eventName: string, - payload?: JsonValue, - queueName?: string - ): Promise; - - /** - * Cancel a task by ID. - * @param taskID Task identifier. - * @param queueName Optional queue name (defaults to client queue). - */ - cancelTask(taskID: string, queueName?: string): Promise; - - /** - * Claim tasks for processing. - * @param options Claiming options. - */ - claimTasks(options?: { - batchSize?: number; - claimTimeout?: number; - workerId?: string; - }): Promise; - - /** - * Claim and process a batch of tasks sequentially. - * @param workerId Worker identifier. - * @param claimTimeout Lease duration in seconds. - * @param batchSize Max tasks to process. - */ - workBatch( - workerId?: string, - claimTimeout?: number, - batchSize?: number - ): Promise; - - /** - * Start a background worker that polls and executes tasks. - * @param options Worker behavior options. - */ - startWorker(options?: WorkerOptions): Promise; - - /** - * Close the client and any owned resources. - */ - close(): Promise; - - /** - * Execute a claimed task (used by workers). - * @param task Claimed task record. - * @param claimTimeout Lease duration in seconds. - * @param options Execution options. - */ - executeTask( - task: ClaimedTask, - claimTimeout: number, - options?: { fatalOnLeaseTimeout?: boolean } - ): Promise; -} diff --git a/sdks/typescript/src/absurd.ts b/sdks/typescript/src/absurd.ts new file mode 100644 index 0000000..aafc05b --- /dev/null +++ b/sdks/typescript/src/absurd.ts @@ -0,0 +1,1031 @@ +/* + * This file is derived from the Absurd TypeScript SDK: + * https://github.com/earendil-works/absurd/blob/d68d5dd23a29b45240f156d78ccf98d39ffce744/sdks/typescript/src/index.ts + * + * Original work Copyright (c) earendil-works. + * Licensed under the Apache License, Version 2.0. + * + * Modifications Copyright (c) absurd-sqlite contributors. + */ +import * as os from "node:os"; + +/** + * Minimal query interface compatible with Absurd's database operations. + */ +export interface Queryable { + query>( + sql: string, + params?: unknown[] | Record + ): Promise<{ rows: R[] }>; +} + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonObject = { + [key: string]: JsonValue; +}; + +export interface RetryStrategy { + kind: "fixed" | "exponential" | "none"; + baseSeconds?: number; + factor?: number; + maxSeconds?: number; +} + +export interface CancellationPolicy { + maxDuration?: number; + maxDelay?: number; +} + +export interface SpawnOptions { + maxAttempts?: number; + retryStrategy?: RetryStrategy; + headers?: JsonObject; + queue?: string; + cancellation?: CancellationPolicy; + idempotencyKey?: string; +} + +export interface ClaimedTask { + run_id: string; + task_id: string; + task_name: string; + attempt: number; + params: JsonValue; + retry_strategy: JsonValue; + max_attempts: number | null; + headers: JsonObject | null; + wake_event: string | null; + event_payload: JsonValue | null; +} + +export interface WorkerOptions { + workerId?: string; + claimTimeout?: number; + batchSize?: number; + concurrency?: number; + pollInterval?: number; + onError?: (error: Error) => void; + fatalOnLeaseTimeout?: boolean; +} + +export interface Worker { + close(): Promise; +} + +export interface SpawnResult { + taskID: string; + runID: string; + attempt: number; + created: boolean; +} + +export type TaskHandler

= ( + params: P, + ctx: TaskContext +) => Promise; + +/** + * Internal exception that is thrown to suspend a run. + */ +export class SuspendTask extends Error { + constructor() { + super("Task suspended"); + this.name = "SuspendTask"; + } +} + +/** + * Internal exception that is thrown to cancel a run. + */ +export class CancelledTask extends Error { + constructor() { + super("Task cancelled"); + this.name = "CancelledTask"; + } +} + +/** + * This error is thrown when awaiting an event ran into a timeout. + */ +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = "TimeoutError"; + } +} + +export interface TaskRegistrationOptions { + name: string; + queue?: string; + defaultMaxAttempts?: number; + defaultCancellation?: CancellationPolicy; +} + +interface Log { + log(...args: any[]): void; + info(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; +} + +/** + * Hooks for customizing Absurd behavior. + */ +export interface AbsurdHooks { + /** + * Called before spawning a task. Can modify spawn options (including headers). + * Use this to inject trace IDs or correlation IDs into headers. + */ + beforeSpawn?: ( + taskName: string, + params: JsonValue, + options: SpawnOptions + ) => SpawnOptions | Promise; + /** + * Wraps task execution. Must call and return the result of execute(). + * Use this to restore context before the task handler runs. + */ + wrapTaskExecution?: ( + ctx: TaskContext, + execute: () => Promise + ) => Promise; +} + +/** + * Configuration for the Absurd client. + */ +export interface AbsurdOptions { + db: Queryable; + queueName?: string; + defaultMaxAttempts?: number; + log?: Log; + hooks?: AbsurdHooks; + ownedConnection?: boolean; +} + +class LeaseTimerManager { + private warnTimer: NodeJS.Timeout | null = null; + private fatalTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly log: Log, + private readonly taskLabel: string, + private readonly fatalOnLeaseTimeout: boolean + ) {} + + update(leaseSeconds: number): void { + this.clear(); + if (leaseSeconds <= 0) { + return; + } + this.warnTimer = setTimeout(() => { + this.log.warn( + `task ${this.taskLabel} exceeded claim timeout of ${leaseSeconds}s` + ); + }, leaseSeconds * 1000); + if (this.fatalOnLeaseTimeout) { + this.fatalTimer = setTimeout(() => { + this.log.error( + `task ${this.taskLabel} exceeded claim timeout of ${leaseSeconds}s by more than 100%; terminating process` + ); + process.exit(1); + }, leaseSeconds * 1000 * 2); + } + } + + stop(): void { + this.clear(); + } + + private clear(): void { + if (this.warnTimer) { + clearTimeout(this.warnTimer); + this.warnTimer = null; + } + if (this.fatalTimer) { + clearTimeout(this.fatalTimer); + this.fatalTimer = null; + } + } +} + +/** + * Execution context passed to task handlers. + */ +export class TaskContext { + private readonly log: Log; + readonly taskID: string; + private readonly con: Queryable; + private readonly queueName: string; + private readonly task: ClaimedTask; + private readonly checkpointCache: Map; + private readonly claimTimeout: number; + private readonly leaseTimer: LeaseTimerManager; + private stepNameCounter = new Map(); + + private constructor( + log: Log, + taskID: string, + con: Queryable, + queueName: string, + task: ClaimedTask, + checkpointCache: Map, + claimTimeout: number, + leaseTimer: LeaseTimerManager + ) { + this.log = log; + this.taskID = taskID; + this.con = con; + this.queueName = queueName; + this.task = task; + this.checkpointCache = checkpointCache; + this.claimTimeout = claimTimeout; + this.leaseTimer = leaseTimer; + } + + /** + * Returns all headers attached to this task. + */ + get headers(): Readonly { + return this.task.headers ?? {}; + } + + static async create(args: { + log: Log; + taskID: string; + con: Queryable; + queueName: string; + task: ClaimedTask; + claimTimeout: number; + leaseTimer: LeaseTimerManager; + }): Promise { + const { log, taskID, con, queueName, task, claimTimeout, leaseTimer } = + args; + const result = await con.query<{ + checkpoint_name: string; + state: JsonValue; + status: string; + owner_run_id: string; + updated_at: string; + }>( + `SELECT checkpoint_name, state, status, owner_run_id, updated_at + FROM absurd.get_task_checkpoint_states($1, $2, $3)`, + [queueName, task.task_id, task.run_id] + ); + const cache = new Map(); + for (const row of result.rows) { + cache.set(row.checkpoint_name, row.state); + } + return new TaskContext( + log, + taskID, + con, + queueName, + task, + cache, + claimTimeout, + leaseTimer + ); + } + + /** + * Runs an idempotent step identified by name; caches and reuses its result across retries. + * @param name Unique checkpoint name for this step. + * @param fn Async function computing the step result (must be JSON-serializable). + */ + async step( + name: string, + fn: () => Promise + ): Promise { + const checkpointName = this.getCheckpointName(name); + const state = await this.lookupCheckpoint(checkpointName); + if (state !== undefined) { + return state as T; + } + const rv = await fn(); + await this.persistCheckpoint(checkpointName, rv); + return rv; + } + + /** + * Suspends the task until the given duration (seconds) elapses. + * @param stepName Checkpoint name for this wait. + * @param duration Duration to wait in seconds. + */ + async sleepFor(stepName: string, duration: number): Promise { + return await this.sleepUntil(stepName, new Date(Date.now() + duration * 1000)); + } + + /** + * Suspends the task until the specified time. + * @param stepName Checkpoint name for this wait. + * @param wakeAt Absolute time when the task should resume. + */ + async sleepUntil(stepName: string, wakeAt: Date): Promise { + const checkpointName = this.getCheckpointName(stepName); + const state = await this.lookupCheckpoint(checkpointName); + const actualWakeAt = typeof state === "string" ? new Date(state) : wakeAt; + if (!state) { + await this.persistCheckpoint(checkpointName, wakeAt.toISOString()); + } + if (Date.now() < actualWakeAt.getTime()) { + await this.scheduleRun(actualWakeAt); + throw new SuspendTask(); + } + } + + private getCheckpointName(name: string): string { + const count = (this.stepNameCounter.get(name) ?? 0) + 1; + this.stepNameCounter.set(name, count); + return count === 1 ? name : `${name}#${count}`; + } + + private async lookupCheckpoint( + checkpointName: string + ): Promise { + const cached = this.checkpointCache.get(checkpointName); + if (cached !== undefined) { + return cached; + } + const result = await this.con.query<{ + checkpoint_name: string; + state: JsonValue; + status: string; + owner_run_id: string; + updated_at: string; + }>( + `SELECT checkpoint_name, state, status, owner_run_id, updated_at + FROM absurd.get_task_checkpoint_state($1, $2, $3)`, + [this.queueName, this.task.task_id, checkpointName] + ); + if (result.rows.length > 0) { + const state = result.rows[0].state; + this.checkpointCache.set(checkpointName, state); + return state; + } + return undefined; + } + + private async persistCheckpoint( + checkpointName: string, + value: JsonValue + ): Promise { + await this.con.query(`SELECT absurd.set_task_checkpoint_state($1, $2, $3, $4, $5, $6)`, [ + this.queueName, + this.task.task_id, + checkpointName, + JSON.stringify(value), + this.task.run_id, + this.claimTimeout, + ]); + this.checkpointCache.set(checkpointName, value); + this.recordLeaseExtension(this.claimTimeout); + } + + private async scheduleRun(wakeAt: Date): Promise { + await this.con.query(`SELECT absurd.schedule_run($1, $2, $3)`, [ + this.queueName, + this.task.run_id, + wakeAt, + ]); + } + + /** + * Waits for an event by name and returns its payload; optionally sets a custom step name and timeout (seconds). + * @param eventName Event identifier to wait for. + * @param options.stepName Optional checkpoint name (defaults to $awaitEvent:). + * @param options.timeout Optional timeout in seconds. + * @throws TimeoutError If the event is not received before the timeout. + */ + async awaitEvent( + eventName: string, + options?: { stepName?: string; timeout?: number } + ): Promise { + const stepName = options?.stepName || `$awaitEvent:${eventName}`; + let timeout: number | null = null; + if ( + options?.timeout !== undefined && + Number.isFinite(options?.timeout) && + options?.timeout >= 0 + ) { + timeout = Math.floor(options?.timeout); + } + const checkpointName = this.getCheckpointName(stepName); + const cached = await this.lookupCheckpoint(checkpointName); + if (cached !== undefined) { + return cached; + } + if ( + this.task.wake_event === eventName && + (this.task.event_payload === null || + this.task.event_payload === undefined) + ) { + this.task.wake_event = null; + this.task.event_payload = null; + throw new TimeoutError(`Timed out waiting for event "${eventName}"`); + } + const result = await this.con.query<{ should_suspend: number; payload: JsonValue }>( + `SELECT should_suspend, payload + FROM absurd.await_event($1, $2, $3, $4, $5, $6)`, + [ + this.queueName, + this.task.task_id, + this.task.run_id, + checkpointName, + eventName, + timeout, + ] + ); + if (result.rows.length === 0) { + throw new Error("Failed to await event"); + } + const { should_suspend, payload } = result.rows[0]; + if (!should_suspend) { + this.checkpointCache.set(checkpointName, payload); + this.task.event_payload = null; + return payload; + } + throw new SuspendTask(); + } + + /** + * Extends the current run's lease by the given seconds (defaults to the original claim timeout). + * @param seconds Lease extension in seconds. + */ + async heartbeat(seconds?: number): Promise { + const leaseSeconds = seconds ?? this.claimTimeout; + await this.con.query(`SELECT absurd.extend_claim($1, $2, $3)`, [ + this.queueName, + this.task.run_id, + leaseSeconds, + ]); + this.recordLeaseExtension(leaseSeconds); + } + + private recordLeaseExtension(leaseSeconds: number): void { + this.leaseTimer.update(leaseSeconds); + } + + /** + * Emits an event to this task's queue with an optional payload. + * @param eventName Non-empty event name. + * @param payload Optional JSON-serializable payload. + */ + async emitEvent(eventName: string, payload?: JsonValue): Promise { + if (!eventName) { + throw new Error("eventName must be a non-empty string"); + } + await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [ + this.queueName, + eventName, + JSON.stringify(payload ?? null), + ]); + } +} + +/** + * The Absurd SDK client. + */ +export class AbsurdImpl { + private readonly con: Queryable; + private readonly ownedConnection: boolean; + private readonly queueName: string; + private readonly defaultMaxAttempts: number; + private registry = new Map(); + private readonly log: Log; + private worker: Worker | null = null; + private readonly hooks: AbsurdHooks; + + constructor(options: AbsurdOptions | Queryable) { + if (isQueryable(options)) { + options = { db: options }; + } + if (!options.db) { + throw new Error("Absurd requires a database connection"); + } + this.con = options.db; + this.ownedConnection = options.ownedConnection ?? false; + this.queueName = options.queueName ?? "default"; + this.defaultMaxAttempts = options.defaultMaxAttempts ?? 5; + this.log = options.log ?? console; + this.hooks = options.hooks ?? {}; + } + + /** + * Returns a new client that uses the provided connection for queries; set owned=true to close it with close(). + * @param con Connection to bind to. + * @param owned If true, the bound client will close this connection on close(). + */ + bindToConnection(con: Queryable, owned = false): AbsurdImpl { + const bound = new AbsurdImpl({ + db: con, + queueName: this.queueName, + defaultMaxAttempts: this.defaultMaxAttempts, + log: this.log, + hooks: this.hooks, + ownedConnection: owned, + }); + bound.registry = this.registry; + return bound; + } + + /** + * Registers a task handler by name (optionally specifying queue, defaultMaxAttempts, and defaultCancellation). + * @param options.name Task name. + * @param options.queue Optional queue name (defaults to client queue). + * @param options.defaultMaxAttempts Optional default max attempts. + * @param options.defaultCancellation Optional default cancellation policy. + * @param handler Async task handler. + */ + registerTask

( + options: TaskRegistrationOptions, + handler: TaskHandler + ): void { + if (!options?.name) { + throw new Error("Task registration requires a name"); + } + if ( + options.defaultMaxAttempts !== undefined && + options.defaultMaxAttempts < 1 + ) { + throw new Error("defaultMaxAttempts must be at least 1"); + } + if (options.defaultCancellation) { + normalizeCancellation(options.defaultCancellation); + } + const queue = options.queue ?? this.queueName; + if (!queue) { + throw new Error( + `Task "${options.name}" must specify a queue or use a client with a default queue` + ); + } + this.registry.set(options.name, { + name: options.name, + queue, + defaultMaxAttempts: options.defaultMaxAttempts, + defaultCancellation: options.defaultCancellation, + handler, + }); + } + + /** + * Creates a queue (defaults to this client's queue). + * @param queueName Queue name to create. + */ + async createQueue(queueName?: string): Promise { + const queue = queueName ?? this.queueName; + await this.con.query(`SELECT absurd.create_queue($1)`, [queue]); + } + + /** + * Drops a queue (defaults to this client's queue). + * @param queueName Queue name to drop. + */ + async dropQueue(queueName?: string): Promise { + const queue = queueName ?? this.queueName; + await this.con.query(`SELECT absurd.drop_queue($1)`, [queue]); + } + + /** + * Lists all queue names. + * @returns Array of queue names. + */ + async listQueues(): Promise> { + const result = await this.con.query<{ queue_name: string }>( + `SELECT * FROM absurd.list_queues()` + ); + return result.rows.map((row) => row.queue_name); + } + + /** + * Spawns a task execution by enqueueing it for processing. The task will be picked up by a worker + * and executed with the provided parameters. Returns identifiers that can be used to track or cancel the task. + * + * For registered tasks, the queue and defaults are inferred from registration. For unregistered tasks, + * you must provide options.queue. + * + * @param taskName Name of the task to spawn (must be registered or provide options.queue). + * @param params JSON-serializable parameters passed to the task handler. + * @param options Configure queue, maxAttempts, retryStrategy, headers, and cancellation policies. + * @returns Object containing taskID (unique task identifier), runID (current attempt identifier), and attempt number. + * @throws Error If the task is unregistered without a queue, or if the queue mismatches registration. + */ + async spawn

( + taskName: string, + params: P, + options: SpawnOptions = {} + ): Promise { + const registration = this.registry.get(taskName); + let queue: string; + if (registration) { + queue = registration.queue; + if (options.queue !== undefined && options.queue !== registration.queue) { + throw new Error( + `Task "${taskName}" is registered for queue "${registration.queue}" but spawn requested queue "${options.queue}".` + ); + } + } else if (!options.queue) { + throw new Error( + `Task "${taskName}" is not registered. Provide options.queue when spawning unregistered tasks.` + ); + } else { + queue = options.queue; + } + const effectiveMaxAttempts = + options.maxAttempts !== undefined + ? options.maxAttempts + : registration?.defaultMaxAttempts ?? this.defaultMaxAttempts; + const effectiveCancellation = + options.cancellation !== undefined + ? options.cancellation + : registration?.defaultCancellation; + let effectiveOptions: SpawnOptions = { + ...options, + maxAttempts: effectiveMaxAttempts, + cancellation: effectiveCancellation, + }; + if (this.hooks.beforeSpawn) { + effectiveOptions = await this.hooks.beforeSpawn( + taskName, + params as JsonValue, + effectiveOptions + ); + } + const normalizedOptions = normalizeSpawnOptions(effectiveOptions); + const result = await this.con.query<{ + task_id: string; + run_id: string; + attempt: number; + created: boolean; + }>( + `SELECT task_id, run_id, attempt, created + FROM absurd.spawn_task($1, $2, $3, $4)`, + [ + queue, + taskName, + JSON.stringify(params), + JSON.stringify(normalizedOptions), + ] + ); + if (result.rows.length === 0) { + throw new Error("Failed to spawn task"); + } + const row = result.rows[0]; + return { + taskID: row.task_id, + runID: row.run_id, + attempt: row.attempt, + created: row.created, + }; + } + + /** + * Emits an event with an optional payload on the specified or default queue. + * @param eventName Non-empty event name. + * @param payload Optional JSON-serializable payload. + * @param queueName Queue to emit to (defaults to this client's queue). + */ + async emitEvent( + eventName: string, + payload?: JsonValue, + queueName?: string + ): Promise { + if (!eventName) { + throw new Error("eventName must be a non-empty string"); + } + await this.con.query(`SELECT absurd.emit_event($1, $2, $3)`, [ + queueName || this.queueName, + eventName, + JSON.stringify(payload ?? null), + ]); + } + + /** + * Cancels a task by ID on the specified or default queue; running tasks stop at the next checkpoint/heartbeat. + * @param taskID Task identifier to cancel. + * @param queueName Queue name (defaults to this client's queue). + */ + async cancelTask(taskID: string, queueName?: string): Promise { + await this.con.query(`SELECT absurd.cancel_task($1, $2)`, [ + queueName || this.queueName, + taskID, + ]); + } + + async claimTasks(options?: { + batchSize?: number; + claimTimeout?: number; + workerId?: string; + }): Promise { + const { + batchSize: count = 1, + claimTimeout = 120, + workerId = "worker", + } = options ?? {}; + const result = await this.con.query( + `SELECT run_id, task_id, attempt, task_name, params, retry_strategy, max_attempts, + headers, wake_event, event_payload + FROM absurd.claim_task($1, $2, $3, $4)`, + [this.queueName, workerId, claimTimeout, count] + ); + return result.rows; + } + + /** + * Claims up to batchSize tasks and processes them sequentially using the given workerId and claimTimeout. + * @param workerId Worker identifier. + * @param claimTimeout Lease duration in seconds. + * @param batchSize Maximum number of tasks to process. + * Note: For parallel processing, use startWorker(). + */ + async workBatch( + workerId = "worker", + claimTimeout = 120, + batchSize = 1 + ): Promise { + const tasks = await this.claimTasks({ batchSize, claimTimeout, workerId }); + for (const task of tasks) { + await this.executeTask(task, claimTimeout); + } + } + + /** + * Starts a background worker that continuously polls for and processes tasks from the queue. + * The worker will claim tasks up to the configured concurrency limit and process them in parallel. + * + * @param options Configure worker behavior. + * @returns Worker instance with close() method for graceful shutdown. + */ + async startWorker(options: WorkerOptions = {}): Promise { + const { + workerId = `${os.hostname?.() || "host"}:${process.pid}`, + claimTimeout = 120, + concurrency = 1, + batchSize, + pollInterval = 0.25, + onError = (err) => this.log.error("Worker error:", err), + fatalOnLeaseTimeout = true, + } = options; + const effectiveBatchSize = batchSize ?? concurrency; + let running = true; + let workerLoopPromise: Promise; + const executing = new Set>(); + let availabilityPromise: Promise | null = null; + let availabilityResolve: (() => void) | null = null; + let sleepTimer: NodeJS.Timeout | null = null; + const notifyAvailability = () => { + if (sleepTimer) { + clearTimeout(sleepTimer); + sleepTimer = null; + } + if (availabilityResolve) { + availabilityResolve(); + availabilityResolve = null; + availabilityPromise = null; + } + }; + const waitForAvailability = async () => { + if (!availabilityPromise) { + availabilityPromise = new Promise((resolve) => { + availabilityResolve = resolve; + sleepTimer = setTimeout(() => { + sleepTimer = null; + availabilityResolve = null; + availabilityPromise = null; + resolve(); + }, pollInterval * 1000); + }); + } + await availabilityPromise; + }; + const worker: Worker = { + close: async () => { + running = false; + await workerLoopPromise; + }, + }; + this.worker = worker; + workerLoopPromise = (async () => { + while (running) { + try { + if (executing.size >= concurrency) { + await waitForAvailability(); + continue; + } + const availableCapacity = Math.max(concurrency - executing.size, 0); + const toClaim = Math.min(effectiveBatchSize, availableCapacity); + if (toClaim <= 0) { + await waitForAvailability(); + continue; + } + const messages = await this.claimTasks({ + batchSize: toClaim, + claimTimeout, + workerId, + }); + if (messages.length === 0) { + await waitForAvailability(); + continue; + } + for (const task of messages) { + const promise = this.executeTask(task, claimTimeout, { + fatalOnLeaseTimeout, + }) + .catch((err) => onError(err)) + .finally(() => { + executing.delete(promise); + notifyAvailability(); + }); + executing.add(promise); + } + } catch (err) { + onError(err as Error); + await waitForAvailability(); + } + } + await Promise.allSettled(executing); + })(); + return worker; + } + + /** + * Stops any running worker and closes the underlying connection if owned. + */ + async close(): Promise { + if (this.worker) { + await this.worker.close(); + } + if (this.ownedConnection) { + await closeIfSupported(this.con); + } + } + + async executeTask( + task: ClaimedTask, + claimTimeout: number, + options?: { fatalOnLeaseTimeout?: boolean } + ): Promise { + const registration = this.registry.get(task.task_name); + const taskLabel = `${task.task_name} (${task.task_id})`; + const leaseTimer = new LeaseTimerManager( + this.log, + taskLabel, + options?.fatalOnLeaseTimeout ?? false + ); + const ctx = await TaskContext.create({ + log: this.log, + taskID: task.task_id, + con: this.con, + queueName: registration?.queue ?? "unknown", + task, + claimTimeout, + leaseTimer, + }); + leaseTimer.update(claimTimeout); + try { + if (!registration) { + throw new Error("Unknown task"); + } else if (registration.queue !== this.queueName) { + throw new Error("Misconfigured task (queue mismatch)"); + } + const execute = async () => { + const result = await registration.handler(task.params, ctx); + await completeTaskRun(this.con, this.queueName, task.run_id, result); + }; + if (this.hooks.wrapTaskExecution) { + await this.hooks.wrapTaskExecution(ctx, execute); + } else { + await execute(); + } + } catch (err) { + if (err instanceof SuspendTask || err instanceof CancelledTask) { + return; + } + this.log.error("[absurd] task execution failed:", err); + await failTaskRun(this.con, this.queueName, task.run_id, err as Error); + } finally { + leaseTimer.stop(); + } + } +} + +type RegisteredTask = { + name: string; + queue: string; + defaultMaxAttempts?: number; + defaultCancellation?: CancellationPolicy; + handler: TaskHandler; +}; + +function isQueryable(value: AbsurdOptions | Queryable): value is Queryable { + return ( + typeof value === "object" && + value !== null && + typeof (value as Queryable).query === "function" + ); +} + +async function closeIfSupported(con: Queryable): Promise { + const maybeClose = (con as { close?: () => void | Promise }).close; + if (typeof maybeClose === "function") { + await maybeClose.call(con); + } +} + +function serializeError(err: unknown): JsonObject { + if (err instanceof Error) { + return { + name: err.name, + message: err.message, + stack: err.stack ?? null, + }; + } + return { message: String(err) }; +} + +async function completeTaskRun( + con: Queryable, + queueName: string, + runID: string, + result: JsonValue +): Promise { + await con.query(`SELECT absurd.complete_run($1, $2, $3)`, [ + queueName, + runID, + JSON.stringify(result ?? null), + ]); +} + +async function failTaskRun( + con: Queryable, + queueName: string, + runID: string, + err: Error +): Promise { + await con.query(`SELECT absurd.fail_run($1, $2, $3, $4)`, [ + queueName, + runID, + JSON.stringify(serializeError(err)), + null, + ]); +} + +function normalizeSpawnOptions(options: SpawnOptions): JsonObject { + const normalized: JsonObject = {}; + if (options.headers !== undefined) { + normalized.headers = options.headers; + } + if (options.maxAttempts !== undefined) { + normalized.max_attempts = options.maxAttempts; + } + if (options.retryStrategy) { + normalized.retry_strategy = serializeRetryStrategy(options.retryStrategy); + } + const cancellation = normalizeCancellation(options.cancellation); + if (cancellation) { + normalized.cancellation = cancellation; + } + if (options.idempotencyKey !== undefined) { + normalized.idempotency_key = options.idempotencyKey; + } + return normalized; +} + +function serializeRetryStrategy(strategy: RetryStrategy): JsonObject { + const serialized: JsonObject = { + kind: strategy.kind, + }; + if (strategy.baseSeconds !== undefined) { + serialized.base_seconds = strategy.baseSeconds; + } + if (strategy.factor !== undefined) { + serialized.factor = strategy.factor; + } + if (strategy.maxSeconds !== undefined) { + serialized.max_seconds = strategy.maxSeconds; + } + return serialized; +} + +function normalizeCancellation( + policy?: CancellationPolicy +): JsonObject | undefined { + if (!policy) { + return undefined; + } + const normalized: JsonObject = {}; + if (policy.maxDuration !== undefined) { + normalized.max_duration = policy.maxDuration; + } + if (policy.maxDelay !== undefined) { + normalized.max_delay = policy.maxDelay; + } + return Object.keys(normalized).length > 0 ? normalized : undefined; +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index c5647ec..76ad028 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -1,30 +1,39 @@ -import { Absurd as AbsurdBase } from "absurd-sdk"; +import { + AbsurdImpl, + type AbsurdOptions as AbsurdImplOptions, + type ClaimedTask, + type JsonValue, + type SpawnOptions, + type SpawnResult, + type TaskHandler, + type TaskRegistrationOptions, + type WorkerOptions, +} from "./absurd"; +import { SQLiteConnection } from "./sqlite-connection"; -import type { AbsurdClient } from "./absurd-types"; -import type { SQLiteDatabase } from "./sqlite-types"; -import { SqliteConnection } from "./sqlite"; - -export type { AbsurdClient, Queryable, Worker } from "./absurd-types"; +export type { Queryable } from "./absurd"; +export { + CancelledTask, + SuspendTask, + TimeoutError, + TaskContext, + type AbsurdHooks, + type CancellationPolicy, + type ClaimedTask, + type JsonObject, + type JsonValue, + type RetryStrategy, + type SpawnOptions, + type SpawnResult, + type TaskHandler, + type TaskRegistrationOptions, + type WorkerOptions, +} from "./absurd"; export { downloadExtension, resolveExtensionPath, type DownloadExtensionOptions, } from "./extension-downloader"; -export type { - AbsurdHooks, - AbsurdOptions, - CancellationPolicy, - ClaimedTask, - JsonObject, - JsonValue, - RetryStrategy, - SpawnOptions, - SpawnResult, - TaskContext, - TaskHandler, - TaskRegistrationOptions, - WorkerOptions, -} from "absurd-sdk"; export type { SQLiteBindParams, SQLiteBindValue, @@ -34,6 +43,8 @@ export type { SQLiteStatement, SQLiteVerboseLog, } from "./sqlite-types"; +export { SQLiteConnection } from "./sqlite-connection"; +export type { SQLiteConnectionOptions, SQLiteValueCodec } from "./sqlite-connection"; // Export Temporal types and helpers export { @@ -50,17 +61,136 @@ export { isInstant, } from "./temporal-types"; -export class Absurd extends AbsurdBase implements AbsurdClient { - private db: SQLiteDatabase; +/** + * SQLite-specific Absurd client that loads the extension and owns the database handle. + */ +export type AbsurdOptions = Omit; - constructor(db: SQLiteDatabase, extensionPath: string) { - db.loadExtension(extensionPath); - const queryable = new SqliteConnection(db); - super(queryable); - this.db = db; - } +/** + * Background worker handle returned by startWorker(). + */ +export interface Worker { + /** + * Stop the worker loop and wait for in-flight tasks to settle. + */ + close(): Promise; +} + +/** + * Absurd client interface. + */ +export interface AbsurdClient { + /** + * Register a task handler. + * @param options Task registration options. + * @param handler Async task handler. + */ + registerTask

( + options: TaskRegistrationOptions, + handler: TaskHandler + ): void; + + /** + * Create a queue. + * @param queueName Optional queue name (defaults to client queue). + */ + createQueue(queueName?: string): Promise; + /** + * Drop a queue. + * @param queueName Optional queue name (defaults to client queue). + */ + dropQueue(queueName?: string): Promise; + /** + * List available queues. + */ + listQueues(): Promise>; + + /** + * Spawn a task execution. + * @param taskName Task name. + * @param params Task parameters. + * @param options Spawn options including queue and retry behavior. + */ + spawn

( + taskName: string, + params: P, + options?: SpawnOptions + ): Promise; + + /** + * Emit an event on a queue. + * @param eventName Non-empty event name. + * @param payload Optional JSON payload. + * @param queueName Optional queue name (defaults to client queue). + */ + emitEvent( + eventName: string, + payload?: JsonValue, + queueName?: string + ): Promise; + + /** + * Cancel a task by ID. + * @param taskID Task identifier. + * @param queueName Optional queue name (defaults to client queue). + */ + cancelTask(taskID: string, queueName?: string): Promise; + + /** + * Claim tasks for processing. + * @param options Claiming options. + */ + claimTasks(options?: { + batchSize?: number; + claimTimeout?: number; + workerId?: string; + }): Promise; + + /** + * Claim and process a batch of tasks sequentially. + * @param workerId Worker identifier. + * @param claimTimeout Lease duration in seconds. + * @param batchSize Max tasks to process. + */ + workBatch( + workerId?: string, + claimTimeout?: number, + batchSize?: number + ): Promise; + + /** + * Start a background worker that polls and executes tasks. + * @param options Worker behavior options. + */ + startWorker(options?: WorkerOptions): Promise; + + /** + * Close the client and any owned resources. + */ + close(): Promise; + + /** + * Execute a claimed task (used by workers). + * @param task Claimed task record. + * @param claimTimeout Lease duration in seconds. + * @param options Execution options. + */ + executeTask( + task: ClaimedTask, + claimTimeout: number, + options?: { fatalOnLeaseTimeout?: boolean } + ): Promise; +} - async close(): Promise { - this.db.close(); +export class Absurd extends AbsurdImpl implements AbsurdClient { + constructor(connection: SQLiteConnection, options?: AbsurdOptions) { + super({ + db: connection, + queueName: options?.queueName, + defaultMaxAttempts: options?.defaultMaxAttempts, + log: options?.log, + hooks: options?.hooks, + ownedConnection: false, + }); } } diff --git a/sdks/typescript/src/sqlite.ts b/sdks/typescript/src/sqlite-connection.ts similarity index 57% rename from sdks/typescript/src/sqlite.ts rename to sdks/typescript/src/sqlite-connection.ts index 2900318..a6dcf29 100644 --- a/sdks/typescript/src/sqlite.ts +++ b/sdks/typescript/src/sqlite-connection.ts @@ -1,31 +1,71 @@ -import type { Queryable } from "./absurd-types"; +import type { Queryable } from "./absurd"; import type { - SQLiteRestBindParams, + SQLiteColumnDefinition, SQLiteDatabase, SQLiteStatement, SQLiteVerboseLog, + SQLiteBindValue, } from "./sqlite-types"; import { isInstant, instantFromEpochMilliseconds, } from "./temporal-types"; -export class SqliteConnection implements Queryable { +/** + * Hooks for encoding parameters and decoding query results. + * Useful when SQLite drivers expose different value representations. + */ +export interface SQLiteValueCodec { + encodeParam?: (value: SQLiteBindValue) => SQLiteBindValue; + decodeColumn?: (args: { + value: unknown; + columnName: string; + columnType: string | null; + verbose?: SQLiteVerboseLog; + }) => unknown; + decodeRow?: (args: { + row: Record; + columns: SQLiteColumnDefinition[]; + decodeColumn: NonNullable; + verbose?: SQLiteVerboseLog; + }) => Record; +} + +/** + * Configuration options for SQLiteConnection. + */ +export interface SQLiteConnectionOptions { + valueCodec?: SQLiteValueCodec; + verbose?: SQLiteVerboseLog; +} + +/** + * SQLite adapter that rewrites Absurd's SQL to SQLite syntax and handles retries. + */ +export class SQLiteConnection implements Queryable { private readonly db: SQLiteDatabase; private readonly maxRetries = 5; private readonly baseRetryDelayMs = 50; + private readonly codec: Required> & + Pick; + private readonly verbose?: SQLiteVerboseLog; - constructor(db: SQLiteDatabase) { + constructor(db: SQLiteDatabase, options: SQLiteConnectionOptions = {}) { this.db = db; - // TODO: verbose logging + this.codec = { + encodeParam: options.valueCodec?.encodeParam ?? encodeColumnValue, + decodeColumn: options.valueCodec?.decodeColumn ?? decodeColumnValue, + decodeRow: options.valueCodec?.decodeRow, + }; + this.verbose = options.verbose; } async query>( sql: string, - params?: SQLiteRestBindParams + params?: unknown[] | Record ): Promise<{ rows: R[] }> { const sqliteQuery = rewritePostgresQuery(sql); - const sqliteParams = rewritePostgresParams(params); + const sqliteParams = rewritePostgresParams(params, this.codec.encodeParam); const statement = this.db.prepare(sqliteQuery); if (!statement.readonly) { @@ -34,23 +74,31 @@ export class SqliteConnection implements Queryable { throw new Error("The query() method is only statements that return data"); } - const rowsDecoded = await this.runWithRetry(() => - statement - .all(sqliteParams) - .map((row) => decodeRowValues(statement, row)) - ); + const rowsDecoded = await this.runWithRetry(() => { + const rows = statement.all(sqliteParams); + return rows.map((row) => + decodeRowValues(statement, row, this.codec, this.verbose) + ); + }); return { rows: rowsDecoded }; } - async exec(sql: string, params?: SQLiteRestBindParams): Promise { + async exec( + sql: string, + params?: unknown[] | Record + ): Promise { const sqliteQuery = rewritePostgresQuery(sql); - const sqliteParams = rewritePostgresParams(params); + const sqliteParams = rewritePostgresParams(params, this.codec.encodeParam); const statement = this.db.prepare(sqliteQuery); await this.runWithRetry(() => statement.run(sqliteParams)); } + close(): void { + this.db.close(); + } + private async runWithRetry(operation: () => T): Promise { let attempt = 0; while (true) { @@ -75,53 +123,74 @@ function rewritePostgresQuery(text: string): string { .replace(/absurd\.(\w+)/g, "absurd_$1"); } -function rewritePostgresParams( - params?: SQLiteRestBindParams -): Record { +function rewritePostgresParams( + params: unknown[] | Record | undefined, + encodeParam: (value: SQLiteBindValue) => SQLiteBindValue +): Record { if (!params) { return {}; } - const rewrittenParams: Record = {}; - params.forEach((value, index) => { - const paramKey = `${namedParamPrefix}${index + 1}`; - const encodedParamValue = encodeColumnValue(value); + const rewrittenParams: Record = {}; + if (Array.isArray(params)) { + params.forEach((value, index) => { + const paramKey = `${namedParamPrefix}${index + 1}`; + const encodedParamValue = encodeParam(value as SQLiteBindValue); + + rewrittenParams[paramKey] = encodedParamValue; + }); + return rewrittenParams; + } - rewrittenParams[paramKey] = encodedParamValue; - }); + for (const [key, value] of Object.entries(params)) { + rewrittenParams[key] = encodeParam(value as SQLiteBindValue); + } return rewrittenParams; } function decodeRowValues( statement: SQLiteStatement, row: U, + codec: Required> & + Pick, verbose?: SQLiteVerboseLog ): R { const columns = statement.columns(); + const rowRecord = row as Record; + + if (codec.decodeRow) { + return codec.decodeRow({ + row: rowRecord, + columns, + decodeColumn: codec.decodeColumn, + verbose, + }) as R; + } const decodedRow: any = {}; for (const column of columns) { const columnName = column.name; const columnType = column.type; - const rawValue = (row as Record)[columnName]; - const decodedValue = decodeColumnValue( - rawValue, + const rawValue = rowRecord[columnName]; + const decodedValue = codec.decodeColumn({ + value: rawValue, columnName, columnType, - verbose - ); + verbose, + }); decodedRow[columnName] = decodedValue; } return decodedRow as R; } -function decodeColumnValue( - value: unknown | V, - columnName: string, - columnType: string | null, - verbose?: SQLiteVerboseLog -): V | null { +function decodeColumnValue(args: { + value: unknown | V; + columnName: string; + columnType: string | null; + verbose?: SQLiteVerboseLog; +}): V | null { + const { value, columnName, columnType, verbose } = args; if (value === null || value === undefined) { return null; } diff --git a/sdks/typescript/src/sqlite-types.ts b/sdks/typescript/src/sqlite-types.ts index c744b9c..43bb19f 100644 --- a/sdks/typescript/src/sqlite-types.ts +++ b/sdks/typescript/src/sqlite-types.ts @@ -31,7 +31,7 @@ export interface SQLiteStatement> { readonly: boolean; columns(): SQLiteColumnDefinition[]; all(...args: SQLiteRestBindParams): Result[]; - run(...args: SQLiteRestBindParams): number; + run(...args: SQLiteRestBindParams): unknown; } export interface SQLiteDatabase { diff --git a/sdks/typescript/test/events.test.ts b/sdks/typescript/test/events.test.ts index 3624a9c..571c5fa 100644 --- a/sdks/typescript/test/events.test.ts +++ b/sdks/typescript/test/events.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeAll, afterEach } from "vitest"; import { createTestAbsurd, randomName, type TestContext } from "./setup.js"; import type { Absurd } from "../src/index.js"; -import { TimeoutError } from "absurd-sdk"; +import { TimeoutError } from "../src/index.js"; describe("Event system", () => { let ctx: TestContext; diff --git a/sdks/typescript/test/index.test.ts b/sdks/typescript/test/index.test.ts index 51a7ee8..4df6129 100644 --- a/sdks/typescript/test/index.test.ts +++ b/sdks/typescript/test/index.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { Absurd, SQLiteDatabase } from "../src/index"; +import { Absurd, SQLiteConnection, SQLiteDatabase } from "../src/index"; const testDir = fileURLToPath(new URL(".", import.meta.url)); const repoRoot = join(testDir, "../../.."); @@ -53,8 +53,9 @@ afterEach(() => { describe("Absurd", () => { it("creates and lists queues using the sqlite extension", async () => { const dbPath = createDatabaseWithMigrations(); - const db = new sqlite(dbPath) as unknown as SQLiteDatabase; - const absurd = new Absurd(db, extensionPath); + const db = new sqlite(dbPath) as SQLiteDatabase; + db.loadExtension(extensionPath); + const absurd = new Absurd(new SQLiteConnection(db)); await absurd.createQueue("alpha"); await absurd.createQueue("beta"); @@ -79,8 +80,9 @@ describe("Absurd", () => { it("closes the sqlite database on close()", async () => { const dbPath = createDatabaseWithMigrations(); - const db = new sqlite(dbPath) as unknown as SQLiteDatabase; - const absurd = new Absurd(db, extensionPath); + const db = new sqlite(dbPath) as SQLiteDatabase; + db.loadExtension(extensionPath); + const absurd = new Absurd(new SQLiteConnection(db)); await absurd.close(); diff --git a/sdks/typescript/test/setup.ts b/sdks/typescript/test/setup.ts index 6ef522e..9f8245f 100644 --- a/sdks/typescript/test/setup.ts +++ b/sdks/typescript/test/setup.ts @@ -5,13 +5,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { - Absurd as AbsurdBase, + Absurd, type AbsurdHooks, type JsonValue, -} from "absurd-sdk"; + type SQLiteDatabase, + type Instant, +} from "../src/index"; -import { SqliteConnection } from "../src/sqlite"; -import type { Absurd, SQLiteDatabase, Instant } from "../src/index"; +import { SQLiteConnection } from "../src/sqlite-connection"; import { instantFromDate, instantToEpochMilliseconds, @@ -68,7 +69,7 @@ export interface RunRow { interface SqliteFixture { db: sqlite.Database; - conn: SqliteConnection; + conn: SQLiteConnection; dbPath: string; cleanup: () => void; } @@ -85,7 +86,7 @@ afterAll(() => { export interface TestContext { absurd: Absurd; - pool: SqliteConnection; + pool: SQLiteConnection; queueName: string; dbPath: string; cleanupTasks(): Promise; @@ -165,7 +166,7 @@ function createFixture(): SqliteFixture { const db = new sqlite(dbPath); db.loadExtension(extensionPath); db.prepare("select absurd_apply_migrations()").get(); - const conn = new SqliteConnection(db as unknown as SQLiteDatabase); + const conn = new SQLiteConnection(db as SQLiteDatabase); const cleanup = () => { rmSync(tempDir, { recursive: true, force: true }); @@ -180,11 +181,7 @@ export async function createTestAbsurd( queueName: string = "default" ): Promise { const fixture = createFixture(); - const absurdBase = new AbsurdBase({ - db: fixture.conn, - queueName, - }); - const absurd = absurdBase as unknown as Absurd; + const absurd = new Absurd(fixture.conn, { queueName }); await absurd.createQueue(queueName); @@ -252,19 +249,16 @@ export async function createTestAbsurd( extendClaim(fixture.conn, runID, extendBySeconds, queueName), expectCancelledError: (promise: Promise) => expectCancelledError(promise), - createClient: (options) => { - const client = new AbsurdBase({ - db: fixture.conn, + createClient: (options) => + new Absurd(fixture.conn, { queueName: options?.queueName ?? queueName, hooks: options?.hooks, - }); - return client as unknown as Absurd; - }, + }), }; } async function setFakeNow( - conn: SqliteConnection, + conn: SQLiteConnection, ts: Date | Instant | null ): Promise { if (ts === null) { @@ -276,7 +270,7 @@ async function setFakeNow( } async function cleanupTasks( - conn: SqliteConnection, + conn: SQLiteConnection, queue: string ): Promise { const tables = [ @@ -292,7 +286,7 @@ async function cleanupTasks( } async function getQueueStorageState( - conn: SqliteConnection, + conn: SQLiteConnection, queue: string ): Promise<{ exists: boolean; tables: string[] }> { const { rows } = await conn.query<{ count: number }>( @@ -309,7 +303,7 @@ async function getQueueStorageState( } async function getTask( - conn: SqliteConnection, + conn: SQLiteConnection, taskID: string, queue: string ): Promise { @@ -336,7 +330,7 @@ async function getTask( } async function getRun( - conn: SqliteConnection, + conn: SQLiteConnection, runID: string, queue: string ): Promise { @@ -364,7 +358,7 @@ async function getRun( } async function getRuns( - conn: SqliteConnection, + conn: SQLiteConnection, taskID: string, queue: string ): Promise { @@ -393,7 +387,7 @@ async function getRuns( } async function getRemainingTasksCount( - conn: SqliteConnection, + conn: SQLiteConnection, queue: string ): Promise { const { rows } = await conn.query<{ count: number }>( @@ -404,7 +398,7 @@ async function getRemainingTasksCount( } async function getRemainingEventsCount( - conn: SqliteConnection, + conn: SQLiteConnection, queue: string ): Promise { const { rows } = await conn.query<{ count: number }>( @@ -415,7 +409,7 @@ async function getRemainingEventsCount( } async function getWaitsCount( - conn: SqliteConnection, + conn: SQLiteConnection, queue: string ): Promise { const { rows } = await conn.query<{ count: number }>( @@ -426,7 +420,7 @@ async function getWaitsCount( } async function getCheckpoint( - conn: SqliteConnection, + conn: SQLiteConnection, taskID: string, checkpointName: string, queue: string @@ -453,7 +447,7 @@ async function getCheckpoint( } async function scheduleRun( - conn: SqliteConnection, + conn: SQLiteConnection, runID: string, wakeAt: Date | Instant, queue: string @@ -470,7 +464,7 @@ async function scheduleRun( } async function completeRun( - conn: SqliteConnection, + conn: SQLiteConnection, runID: string, payload: JsonValue, queue: string @@ -483,7 +477,7 @@ async function completeRun( } async function cleanupTasksByTTL( - conn: SqliteConnection, + conn: SQLiteConnection, ttlSeconds: number, limit: number, queue: string @@ -496,7 +490,7 @@ async function cleanupTasksByTTL( } async function cleanupEventsByTTL( - conn: SqliteConnection, + conn: SQLiteConnection, ttlSeconds: number, limit: number, queue: string @@ -509,7 +503,7 @@ async function cleanupEventsByTTL( } async function setTaskCheckpointState( - conn: SqliteConnection, + conn: SQLiteConnection, taskID: string, stepName: string, state: JsonValue, @@ -531,7 +525,7 @@ async function setTaskCheckpointState( } async function awaitEventInternal( - conn: SqliteConnection, + conn: SQLiteConnection, taskID: string, runID: string, stepName: string, @@ -547,7 +541,7 @@ async function awaitEventInternal( } async function extendClaim( - conn: SqliteConnection, + conn: SQLiteConnection, runID: string, extendBySeconds: number, queue: string diff --git a/sdks/typescript/test/sqlite.test.ts b/sdks/typescript/test/sqlite.test.ts index 464aa2d..665a794 100644 --- a/sdks/typescript/test/sqlite.test.ts +++ b/sdks/typescript/test/sqlite.test.ts @@ -4,14 +4,14 @@ import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { SqliteConnection } from "../src/sqlite"; +import { SQLiteConnection } from "../src/sqlite-connection"; import type { SQLiteDatabase } from "../src/sqlite-types"; import { isInstant, type Instant } from "../src/temporal-types"; -describe("SqliteConnection", () => { +describe("SQLiteConnection", () => { it("rewrites postgres-style params and absurd schema names", async () => { - const db = new sqlite(":memory:") as unknown as SQLiteDatabase; - const conn = new SqliteConnection(db); + const db = new sqlite(":memory:") as SQLiteDatabase; + const conn = new SQLiteConnection(db); await conn.exec("CREATE TABLE absurd_tasks (id, name)"); await conn.exec("INSERT INTO absurd.tasks (id, name) VALUES ($1, $2)", [ @@ -29,8 +29,8 @@ describe("SqliteConnection", () => { }); it("throws when query is used for non-reader statements", async () => { - const db = new sqlite(":memory:") as unknown as SQLiteDatabase; - const conn = new SqliteConnection(db); + const db = new sqlite(":memory:") as SQLiteDatabase; + const conn = new SQLiteConnection(db); await expect(conn.query("CREATE TABLE t (id)")).rejects.toThrow( "only statements that return data" @@ -39,8 +39,8 @@ describe("SqliteConnection", () => { }); it("decodes JSON from typeless columns", async () => { - const db = new sqlite(":memory:") as unknown as SQLiteDatabase; - const conn = new SqliteConnection(db); + const db = new sqlite(":memory:") as SQLiteDatabase; + const conn = new SQLiteConnection(db); await conn.exec("CREATE TABLE t (payload)"); await conn.exec("INSERT INTO t (payload) VALUES ($1)", ['{"a":1}']); @@ -54,8 +54,8 @@ describe("SqliteConnection", () => { }); it("decodes JSON from blob columns", async () => { - const db = new sqlite(":memory:") as unknown as SQLiteDatabase; - const conn = new SqliteConnection(db); + const db = new sqlite(":memory:") as SQLiteDatabase; + const conn = new SQLiteConnection(db); await conn.exec("CREATE TABLE t_blob (payload BLOB)"); await conn.exec("INSERT INTO t_blob (payload) VALUES ($1)", [ @@ -71,8 +71,8 @@ describe("SqliteConnection", () => { }); it("decodes datetime columns into Temporal.Instant objects", async () => { - const db = new sqlite(":memory:") as unknown as SQLiteDatabase; - const conn = new SqliteConnection(db); + const db = new sqlite(":memory:") as SQLiteDatabase; + const conn = new SQLiteConnection(db); const now = Date.now(); await conn.exec("CREATE TABLE t_date (created_at DATETIME)"); @@ -87,12 +87,42 @@ describe("SqliteConnection", () => { db.close(); }); + it("allows custom value codec overrides", async () => { + const db = new sqlite(":memory:") as SQLiteDatabase; + const encodedValues: unknown[] = []; + const conn = new SQLiteConnection(db, { + valueCodec: { + encodeParam: (value) => { + encodedValues.push(value); + return value; + }, + decodeColumn: ({ value }) => { + if (typeof value === "string") { + return value.toUpperCase(); + } + return value; + }, + }, + }); + + await conn.exec("CREATE TABLE t (name TEXT)"); + await conn.exec("INSERT INTO t (name) VALUES ($1)", ["alpha"]); + + const { rows } = await conn.query<{ name: string }>( + "SELECT name FROM t" + ); + + expect(encodedValues).toEqual(["alpha"]); + expect(rows[0]?.name).toBe("ALPHA"); + db.close(); + }); + it("retries when SQLite reports the database is busy", async () => { const tempDir = mkdtempSync(join(tmpdir(), "absurd-sqlite-busy-")); const dbPath = join(tempDir, "busy.db"); - const primary = new sqlite(dbPath) as unknown as SQLiteDatabase; + const primary = new sqlite(dbPath) as SQLiteDatabase; (primary as any).pragma("busy_timeout = 1"); - const conn = new SqliteConnection(primary); + const conn = new SQLiteConnection(primary); await conn.exec("CREATE TABLE t_busy (id INTEGER PRIMARY KEY, value TEXT)"); const blocker = new sqlite(dbPath); @@ -151,7 +181,7 @@ describe("SqliteConnection", () => { close: vi.fn(), loadExtension: vi.fn(), }; - const conn = new SqliteConnection(db); + const conn = new SQLiteConnection(db); await expect( conn.exec("UPDATE locked_table SET value = $1 WHERE id = $2", [1, 1])