diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 90762c82..1dcae740 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,6 +8,7 @@ jobs: "wasm-test": name: "Build and test Wasm on Node.js" runs-on: ubuntu-latest + timeout-minutes: 2 defaults: run: working-directory: ./packages/libsql-client-wasm @@ -35,6 +36,7 @@ jobs: "node-test": name: "Build and test on Node.js" runs-on: ubuntu-latest + timeout-minutes: 2 defaults: run: working-directory: ./packages/libsql-client @@ -94,7 +96,9 @@ jobs: "workers-test": name: "Build and test with Cloudflare Workers" + if: false runs-on: ubuntu-latest + timeout-minutes: 2 defaults: run: working-directory: ./packages/libsql-client diff --git a/.gitignore b/.gitignore index 60688f18..5e61a70e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules /docs *.tsbuildinfo Session.vim +packages/libsql-client/hrana-test-server diff --git a/package-lock.json b/package-lock.json index 8c9098fb..378ac964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -597,6 +597,114 @@ "dev": true, "license": "MIT" }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.0.tgz", + "integrity": "sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==", + "dev": true, + "dependencies": { + "cookie": "^0.5.0" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@inquirer/confirm": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.1.8.tgz", + "integrity": "sha512-f3INZ+ca4dQdn+MQiq1yP/mOIR/Oc8BLRYuDh6ciToWd6z4W8yArfzjBCMQ0BPY8PcJKwZxGIt8Z6yNT32eSTw==", + "dev": true, + "dependencies": { + "@inquirer/core": "^8.2.1", + "@inquirer/type": "^1.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-8.2.1.tgz", + "integrity": "sha512-TIcuQMn2qrtyYe0j136UpHeYpk7AcR/trKeT/7YY0vRgcS9YSfJuQ2+PudPhSofLLsHNnRYAHScQCcVZrJkMqA==", + "dev": true, + "dependencies": { + "@inquirer/figures": "^1.0.2", + "@inquirer/type": "^1.3.2", + "@types/mute-stream": "^0.0.4", + "@types/node": "^20.12.12", + "@types/wrap-ansi": "^3.0.0", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "cli-spinners": "^2.9.2", + "cli-width": "^4.1.0", + "mute-stream": "^1.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core/node_modules/@types/node": { + "version": "20.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.2.tgz", + "integrity": "sha512-4F1MBwVr3c/m4bAUef6LgkvBfSjzwH+OfldgHqcuacWwSUetFebM2wi58WfG9uk1rR98U6GwLed4asLJbwdV5w==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.3.2.tgz", + "integrity": "sha512-5Frickan9c89QbPkSu6I6y8p+9eR6hZkdPahGmNDsTFX8FHLPAozyzCZMKUeW8FyYwnlCKUjqIEqxY+UctARiw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "dev": true, @@ -1055,11 +1163,59 @@ "win32" ] }, + "node_modules/@mswjs/cookies": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mswjs/cookies/-/cookies-1.1.0.tgz", + "integrity": "sha512-0ZcCVQxifZmhwNBoQIrystCb+2sWBY2Zw8lpfJBPCHGCA/HWqehITeCRVIv4VMy8MPlaHo2w2pTHFV2pFfqKPw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz", + "integrity": "sha512-3rDakgJZ77+RiQUuSK69t1F0m8BQKA8Vh5DCS5V0DWvNY67zob2JhhQrhCO0AKLGINTRSFd1tBaHcJTkhefoSw==", + "dev": true, + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.2.1", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@neon-rs/load": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz", "integrity": "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==" }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "dev": true, @@ -1118,6 +1274,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "dev": true, @@ -1156,6 +1318,15 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/mute-stream": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", + "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "18.19.8", "license": "MIT", @@ -1168,6 +1339,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", + "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==", + "dev": true + }, + "node_modules/@types/wrap-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", + "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.5.10", "license": "MIT", @@ -1520,6 +1703,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-truncate": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", @@ -1581,6 +1776,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "dev": true, @@ -1647,6 +1851,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "dev": true, @@ -2009,6 +2222,15 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", + "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -2028,6 +2250,12 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true + }, "node_modules/html-escaper": { "version": "2.0.2", "dev": true, @@ -2127,6 +2355,12 @@ "node": ">=6" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "node_modules/is-number": { "version": "7.0.0", "license": "MIT", @@ -3422,6 +3656,70 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/msw": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.3.0.tgz", + "integrity": "sha512-cDr1q/QTMzaWhY8n9lpGhceY209k29UZtdTgJ3P8Bzne3TSMchX2EM/ldvn4ATLOktpCefCU2gcEgzHc31GTPw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.0", + "@bundled-es-modules/statuses": "^1.0.1", + "@inquirer/confirm": "^3.0.0", + "@mswjs/cookies": "^1.1.0", + "@mswjs/interceptors": "^0.29.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "chalk": "^4.1.2", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.2", + "path-to-regexp": "^6.2.0", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.9.0", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.7.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "4.18.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.18.3.tgz", + "integrity": "sha512-Q08/0IrpvM+NMY9PA2rti9Jb+JejTddwmwmVQGskAlhtcrw1wsRzoR6ode6mR+OAabNa75w/dxedSUY2mlphaQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -3510,6 +3808,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/outvariant": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.2.tgz", + "integrity": "sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==", + "dev": true + }, "node_modules/p-limit": { "version": "3.1.0", "dev": true, @@ -3602,6 +3906,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "dev": true + }, "node_modules/picocolors": { "version": "1.0.0", "dev": true, @@ -3912,6 +4222,21 @@ "node": ">=10" } }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -4395,6 +4720,7 @@ "husky": "^9.0.11", "jest": "^29.3.1", "lint-staged": "^15.2.2", + "msw": "^2.3.0", "prettier": "3.2.5", "ts-jest": "^29.0.5", "typedoc": "^0.23.28", diff --git a/packages/libsql-client/package.json b/packages/libsql-client/package.json index 259a9a3f..45bf9895 100644 --- a/packages/libsql-client/package.json +++ b/packages/libsql-client/package.json @@ -113,6 +113,7 @@ "husky": "^9.0.11", "jest": "^29.3.1", "lint-staged": "^15.2.2", + "msw": "^2.3.0", "prettier": "3.2.5", "ts-jest": "^29.0.5", "typedoc": "^0.23.28", diff --git a/packages/libsql-client/src/__tests__/client.test.ts b/packages/libsql-client/src/__tests__/client.test.ts index 7d230e96..4e59c08b 100644 --- a/packages/libsql-client/src/__tests__/client.test.ts +++ b/packages/libsql-client/src/__tests__/client.test.ts @@ -9,6 +9,11 @@ import "./helpers.js"; import type * as libsql from "../node.js"; import { createClient } from "../node.js"; +import * as migrations from "../migrations"; + +jest.spyOn(migrations, "getIsSchemaDatabase").mockImplementation( + (_params) => new Promise((resolve) => resolve(false)), +); const config = { url: process.env.URL ?? "ws://localhost:8080", diff --git a/packages/libsql-client/src/__tests__/migrations.test.ts b/packages/libsql-client/src/__tests__/migrations.test.ts new file mode 100644 index 00000000..52c16370 --- /dev/null +++ b/packages/libsql-client/src/__tests__/migrations.test.ts @@ -0,0 +1,15 @@ +import { waitForLastMigrationJobToFinish } from "../migrations"; +import { server } from "./mocks/node"; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("waitForLastMigrationJobToFinish()", () => { + test("waits until the last job is completed", async () => { + await waitForLastMigrationJobToFinish({ + authToken: "fake-auth-token", + baseUrl: "http://fake-base-url.example.com", + }); + }); +}); diff --git a/packages/libsql-client/src/__tests__/mocks/handlers.ts b/packages/libsql-client/src/__tests__/mocks/handlers.ts new file mode 100644 index 00000000..0012e6de --- /dev/null +++ b/packages/libsql-client/src/__tests__/mocks/handlers.ts @@ -0,0 +1,34 @@ +import { http, HttpResponse } from "msw"; + +export const handlers = [ + http.get("http://fake-base-url.example.com/v1/jobs", () => { + return HttpResponse.json({ + schema_version: 4, + migrations: [ + { job_id: 4, status: "WaitingDryRun" }, + { job_id: 3, status: "RunSuccess" }, + { job_id: 2, status: "RunSuccess" }, + { job_id: 1, status: "RunSuccess" }, + ], + }); + }), + + http.get( + "http://fake-base-url.example.com/v1/jobs/:job_id", + ({ params }) => { + const { job_id } = params; + + return HttpResponse.json({ + job_id, + status: "RunSuccess", + progress: [ + { + namespace: "b2ab4a64-402c-4bdf-a1e8-27ef33518cbd", + status: "RunSuccess", + error: null, + }, + ], + }); + }, + ), +]; diff --git a/packages/libsql-client/src/__tests__/mocks/node.ts b/packages/libsql-client/src/__tests__/mocks/node.ts new file mode 100644 index 00000000..7b37f2ac --- /dev/null +++ b/packages/libsql-client/src/__tests__/mocks/node.ts @@ -0,0 +1,4 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +export const server = setupServer(...handlers); diff --git a/packages/libsql-client/src/http.ts b/packages/libsql-client/src/http.ts index 1cf64a57..d6177e89 100644 --- a/packages/libsql-client/src/http.ts +++ b/packages/libsql-client/src/http.ts @@ -20,6 +20,10 @@ import { import { SqlCache } from "./sql_cache.js"; import { encodeBaseUrl } from "@libsql/core/uri"; import { supportedUrlLink } from "@libsql/core/util"; +import { + getIsSchemaDatabase, + waitForLastMigrationJobToFinish, +} from "./migrations.js"; export * from "@libsql/core/api"; @@ -65,6 +69,9 @@ const sqlCacheCapacity = 30; export class HttpClient implements Client { #client: hrana.HttpClient; protocol: "http"; + #url: URL; + #authToken: string | undefined; + #isSchemaDatabase: boolean | undefined; /** @private */ constructor( @@ -76,10 +83,24 @@ export class HttpClient implements Client { this.#client = hrana.openHttp(url, authToken, customFetch); this.#client.intMode = intMode; this.protocol = "http"; + this.#url = url; + this.#authToken = authToken; + } + + async getIsSchemaDatabase(): Promise { + if (this.#isSchemaDatabase === undefined) { + this.#isSchemaDatabase = await getIsSchemaDatabase({ + authToken: this.#authToken, + baseUrl: this.#url.origin, + }); + } + + return this.#isSchemaDatabase; } async execute(stmt: InStatement): Promise { try { + const isSchemaDatabasePromise = this.getIsSchemaDatabase(); const hranaStmt = stmtToHrana(stmt); // Pipeline all operations, so `hrana.HttpClient` can open the stream, execute the statement and @@ -92,7 +113,16 @@ export class HttpClient implements Client { stream.closeGracefully(); } - return resultSetFromHrana(await rowsPromise); + const rowsResult = await rowsPromise; + const isSchemaDatabase = await isSchemaDatabasePromise; + if (isSchemaDatabase) { + await waitForLastMigrationJobToFinish({ + authToken: this.#authToken, + baseUrl: this.#url.origin, + }); + } + + return resultSetFromHrana(rowsResult); } catch (e) { throw mapHranaError(e); } @@ -103,6 +133,7 @@ export class HttpClient implements Client { mode: TransactionMode = "deferred", ): Promise> { try { + const isSchemaDatabasePromise = this.getIsSchemaDatabase(); const hranaStmts = stmts.map(stmtToHrana); const version = await this.#client.getVersion(); @@ -131,7 +162,16 @@ export class HttpClient implements Client { stream.closeGracefully(); } - return await resultsPromise; + const results = await resultsPromise; + const isSchemaDatabase = await isSchemaDatabasePromise; + if (isSchemaDatabase) { + await waitForLastMigrationJobToFinish({ + authToken: this.#authToken, + baseUrl: this.#url.origin, + }); + } + + return results; } catch (e) { throw mapHranaError(e); } diff --git a/packages/libsql-client/src/migrations.ts b/packages/libsql-client/src/migrations.ts new file mode 100644 index 00000000..0276dc50 --- /dev/null +++ b/packages/libsql-client/src/migrations.ts @@ -0,0 +1,168 @@ +type MigrationJobType = { + job_id: number; + status: string; +}; + +type ExtendedMigrationJobType = MigrationJobType & { + progress: Array<{ + namespace: string; + status: string; + error: string | null; + }>; +}; + +type MigrationResult = { + schema_version: number; + migrations: Array; +}; + +const SCHEMA_MIGRATION_SLEEP_TIME_IN_MS = 1000; +const SCHEMA_MIGRATION_MAX_RETRIES = 30; + +async function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +type isMigrationJobFinishedProps = { + authToken: string | undefined; + baseUrl: string; + jobId: number; +}; + +async function isMigrationJobFinished({ + authToken, + baseUrl, + jobId, +}: isMigrationJobFinishedProps): Promise { + const url = normalizeURLScheme(baseUrl + `/v1/jobs/${jobId}`); + const result = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + const json = (await result.json()) as ExtendedMigrationJobType; + const job = json as { status: string }; + if (result.status !== 200) { + throw new Error( + `Unexpected status code while fetching job status for migration with id ${jobId}: ${result.status}`, + ); + } + + if (job.status == "RunFailure") { + throw new Error("Migration job failed"); + } + + return job.status == "RunSuccess"; +} + +type getLastMigrationJobProps = { + authToken: string | undefined; + baseUrl: string; +}; + +function normalizeURLScheme(url: string) { + if (url.startsWith("ws://")) { + return url.replace("ws://", "http://"); + } + if (url.startsWith("wss://")) { + return url.replace("wss://", "https://"); + } + + return url; +} + +export async function getIsSchemaDatabase({ + authToken, + baseUrl, +}: { + authToken: string | undefined; + baseUrl: string; +}) { + const url = normalizeURLScheme(baseUrl + "/v1/jobs"); + console.log("url: ", url); + const result = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + console.log("result: ", result); + const json = (await result.json()) as { error: string }; + const isChildDatabase = + result.status === 400 && json.error === "Invalid namespace"; + return !isChildDatabase; +} + +async function getLastMigrationJob({ + authToken, + baseUrl, +}: getLastMigrationJobProps): Promise { + const url = normalizeURLScheme(baseUrl + "/v1/jobs"); + const result = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + if (result.status !== 200) { + throw new Error( + "Unexpected status code while fetching migration jobs: " + + result.status, + ); + } + + const json = (await result.json()) as MigrationResult; + if (!json.migrations || json.migrations.length === 0) { + throw new Error("No migrations found"); + } + + const migrations = json.migrations || []; + let lastJob: MigrationJobType | undefined; + for (const migration of migrations) { + if (migration.job_id > (lastJob?.job_id || 0)) { + lastJob = migration; + } + } + if (!lastJob) { + throw new Error("No migration job found"); + } + if (lastJob?.status === "RunFailure") { + throw new Error("Last migration job failed"); + } + + return lastJob; +} + +type waitForLastMigrationJobToFinishProps = { + authToken: string | undefined; + baseUrl: string; +}; + +export async function waitForLastMigrationJobToFinish({ + authToken, + baseUrl, +}: getLastMigrationJobProps) { + const lastMigrationJob = await getLastMigrationJob({ + authToken: authToken, + baseUrl, + }); + if (lastMigrationJob.status !== "RunSuccess") { + let i = 0; + while (i < SCHEMA_MIGRATION_MAX_RETRIES) { + i++; + const isLastMigrationJobFinished = await isMigrationJobFinished({ + authToken: authToken, + baseUrl, + jobId: lastMigrationJob.job_id, + }); + if (isLastMigrationJobFinished) { + break; + } + + await sleep(SCHEMA_MIGRATION_SLEEP_TIME_IN_MS); + } + } +} diff --git a/packages/libsql-client/src/ws.ts b/packages/libsql-client/src/ws.ts index 8b5f0c00..d628a598 100644 --- a/packages/libsql-client/src/ws.ts +++ b/packages/libsql-client/src/ws.ts @@ -21,6 +21,10 @@ import { import { SqlCache } from "./sql_cache.js"; import { encodeBaseUrl } from "@libsql/core/uri"; import { supportedUrlLink } from "@libsql/core/util"; +import { + getIsSchemaDatabase, + waitForLastMigrationJobToFinish, +} from "./migrations.js"; export * from "@libsql/core/api"; @@ -120,6 +124,7 @@ export class WsClient implements Client { #futureConnState: ConnState | undefined; closed: boolean; protocol: "ws"; + #isSchemaDatabase: boolean | undefined; /** @private */ constructor( @@ -137,9 +142,21 @@ export class WsClient implements Client { this.protocol = "ws"; } + async getIsSchemaDatabase(): Promise { + if (this.#isSchemaDatabase === undefined) { + this.#isSchemaDatabase = await getIsSchemaDatabase({ + authToken: this.#authToken, + baseUrl: this.#url.origin, + }); + } + + return this.#isSchemaDatabase; + } + async execute(stmt: InStatement): Promise { const streamState = await this.#openStream(); try { + const isSchemaDatabasePromise = this.getIsSchemaDatabase(); const hranaStmt = stmtToHrana(stmt); // Schedule all operations synchronously, so they will be pipelined and executed in a single @@ -148,7 +165,16 @@ export class WsClient implements Client { const hranaRowsPromise = streamState.stream.query(hranaStmt); streamState.stream.closeGracefully(); - return resultSetFromHrana(await hranaRowsPromise); + const hranaRowsResult = await hranaRowsPromise; + const isSchemaDatabase = await isSchemaDatabasePromise; + if (isSchemaDatabase) { + await waitForLastMigrationJobToFinish({ + authToken: this.#authToken, + baseUrl: this.#url.origin, + }); + } + + return resultSetFromHrana(hranaRowsResult); } catch (e) { throw mapHranaError(e); } finally { @@ -162,6 +188,7 @@ export class WsClient implements Client { ): Promise> { const streamState = await this.#openStream(); try { + const isSchemaDatabasePromise = this.getIsSchemaDatabase(); const hranaStmts = stmts.map(stmtToHrana); const version = await streamState.conn.client.getVersion(); @@ -176,7 +203,16 @@ export class WsClient implements Client { hranaStmts, ); - return await resultsPromise; + const results = await resultsPromise; + const isSchemaDatabase = await isSchemaDatabasePromise; + if (isSchemaDatabase) { + await waitForLastMigrationJobToFinish({ + authToken: this.#authToken, + baseUrl: this.#url.origin, + }); + } + + return results; } catch (e) { throw mapHranaError(e); } finally { diff --git a/test.ts b/test.ts new file mode 100644 index 00000000..a316f5c4 --- /dev/null +++ b/test.ts @@ -0,0 +1,52 @@ +const libsqlClient = require("./packages/libsql-client"); +const createClient = libsqlClient.createClient; + +//const client = createClient({ +//url: "libsql://schema-db-test-giovannibenussiparedes.turso.io", +//authToken: +//"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTQ0ODYzMjAsImlkIjoiMWVjNGY3ZTktNzY3NC00NzEzLWEzYjAtNWM4NzFjNjZlZGQ1In0.lzim2HBCf3W-tnDUwRzeHQz0nbOA_L2D8v_ahfuw_PVq_teuqyFGE0tUgiM_HIvVo6xDdGFQGKj6dFHhLAwrDw", +//}); +const schemaUrl = "libsql://schema-test-giovannibenussi.turso.io"; +const schemaAuthToken = + "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTU2ODIwMTAsImlkIjoiM2IyYTIwMDEtOTcxZC00MzIzLWE2YWYtMjk1YTRmOWNkYzVkIn0.l-LzYur2KffpkrZog5vT3eThwB3m2Nl0RIgc5rLn1DpBsYyWujPTkpS62WoYBwWbM0AMaAoRqfyCzi-T-LnJBQ"; + +const normalClient = createClient({ + url: "libsql://libsql-client-test-giovannibenussi.turso.io", + authToken: + "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTY5ODc2MDgsImlkIjoiNjhlNzI2M2ItNGQxMi00NWJhLWIxYWYtZGRjZmFhN2I5MTY0In0.PCMMgoOVxnh-Aj10urzsMpXH1anzcmng_Q0ByXIz_E9zHMZide3NAgeVzDg52q3DgHbMndoid_qv1ULjsChMAg", +}); + +const childClient = createClient({ + url: "libsql://schema-child-1-giovannibenussi.turso.io", + authToken: + "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTU2ODE0MDIsImlkIjoiYjJhYjRhNjQtNDAyYy00YmRmLWExZTgtMjdlZjMzNTE4Y2JkIn0.Og9E7nl_Y8P93FO1XJlvAhkKEOsGynDdFEziJwLeGrMNaAOhQLqdxk7shao13VQo4JVFkMuSTXMibKXuPnavBA", +}); + +const schemaClient = createClient({ + url: schemaUrl, + authToken: schemaAuthToken, +}); + +const canarySchemaClient = createClient({ + url: "libsql://canary-schema-db-giovannibenussi.turso.io", + authToken: + "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTY5OTI0MTksImlkIjoiOWM5NjYwZDYtYjFlZS00MDZhLTg1NDYtMzJlMGI2YzE5OTcwIn0.qtQKo32Jtxa8ghhDD1WsN0gbY4kCQMUVgaoJVGNoRehm3XUpsG2z8xzQ4Qgo9r2-GKYE6vmG9ufFGwkoK7shBg", +}); + +async function main() { + //await schemaClient.execute( + //"ALTER TABLE users ADD COLUMN test_column_18 number;", + //); + //await schemaClient.batch([ + //"ALTER TABLE users ADD COLUMN test_column_20 number;", + //]); + //await canarySchemaClient.execute( + //"ALTER TABLE users ADD COLUMN test_column_21 number;", + //); + await canarySchemaClient.batch([ + "ALTER TABLE users ADD COLUMN test_column_101 number;", + "ALTER TABLE users ADD COLUMN test_column_102 number;", + ]); +} + +main();