diff --git a/cspell.json b/cspell.json index ee5a34f10db..7a624f8723c 100644 --- a/cspell.json +++ b/cspell.json @@ -101,6 +101,7 @@ "hashchange", "havings", "hidedevtools", + "HOLDLOCK", "hrana", "hypot", "importmap", @@ -155,6 +156,7 @@ "nkzw", "nocheck", "Noll", + "nvarchar", "nwse", "oddr", "ogawa", @@ -169,6 +171,7 @@ "partyserver", "partysocket", "parvovirus", + "passwordless", "Patryk", "Pearce", "Persistable", @@ -182,6 +185,7 @@ "prettierrc", "primodiumxyz", "reconnections", + "recordset", "relationshipinhtmltable", "requiredness", "resultsortablegrid", @@ -191,6 +195,7 @@ "Riedl", "Romniyi", "Rosso", + "rowversion", "sandboxed", "savepoints", "schematizer", diff --git a/gulpfile.mjs b/gulpfile.mjs index bf23c899813..cbdd4999893 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -34,6 +34,7 @@ const ALL_MODULES = [ 'persisters/persister-file', 'persisters/persister-indexed-db', 'persisters/persister-libsql', + 'persisters/persister-mssql', 'persisters/persister-partykit-client', 'persisters/persister-partykit-server', 'persisters/persister-pg', @@ -241,10 +242,12 @@ const copyPackageFiles = async (forProd = false) => { await promises.copyFile('agents.md', join(DIST_DIR, 'agents.md')); }; -let labelBlocks; -const getLabelBlocks = async () => { - if (labelBlocks == null) { - labelBlocks = new Map(); +// The promise is memoized rather than the Map, since callers run concurrently +// and would otherwise be handed the Map before it has been filled. +let labelBlocksPromise; +const getLabelBlocks = () => + (labelBlocksPromise ??= (async () => { + const labelBlocks = new Map(); await allModules(async (module) => { [ ...( @@ -257,9 +260,8 @@ const getLabelBlocks = async () => { labelBlocks.set(label, block); }); }); - } - return labelBlocks; -}; + return labelBlocks; + })()); const copyDefinition = async (dir, module) => { const labelBlocks = await getLabelBlocks(); diff --git a/package-lock.json b/package-lock.json index bb9311e256c..3ed04522e6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tinybase", - "version": "9.7.0-beta.1", + "version": "9.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tinybase", - "version": "9.7.0-beta.1", + "version": "9.7.0", "license": "MIT", "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", @@ -40,6 +40,7 @@ "@types/better-sqlite3": "^9.6.0", "@types/bun": "^1.4.0", "@types/http-server": "^0.12.4", + "@types/mssql": "^12.3.0", "@types/node": "^26.4.1", "@types/pg": "^8.23.1", "@types/react": "^19.2.18", @@ -78,6 +79,7 @@ "idb": "^8.0.3", "less": "^4.9.1", "memfs": "^4.69.1", + "mssql": "^12.7.0", "partykit": "^0.0.115", "partysocket": "^1.3.0", "pg": "^8.23.0", @@ -133,6 +135,7 @@ "electric-sql": "^0.12.1", "expo": "^57.0.19", "expo-sqlite": "^57.0.2", + "mssql": "^12.7.0", "partykit": "^0.0.115", "partysocket": "^1.3.0", "pg": "^8.23.0", @@ -201,6 +204,9 @@ "expo-sqlite": { "optional": true }, + "mssql": { + "optional": true + }, "partykit": { "optional": true }, @@ -507,6 +513,304 @@ "@automerge/automerge-repo": "2.5.6" } }, + "node_modules/@azure-rest/core-client": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.8.0.tgz", + "integrity": "sha512-F1ybHeN+++QhyFCF/ehLUEvrOB6fehPdFBFtGdj0C3B2lpQ9zkPiO5JDgsqc6IfjuUe6b3dAbXK0a7+VgSGfhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.24.0", + "@azure/core-tracing": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.0.tgz", + "integrity": "sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.7.0.tgz", + "integrity": "sha512-7GEAoIsaoBr6KELNRb8nypowCqvk8dnCHFCYg4XD4lOQGY2GqjQg5IhkRjyBFRO18CGSMq05PaNqSOE9GQro3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-process": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", + "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.2.tgz", + "integrity": "sha512-NXL2/pCJctLxgw8bvrwwgge743kEq8LBT+O1pmV0vyUwetzFPH9auP6jhkU/cgZCPPtWoewAe3ncaGCgPo07fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-process": "^1.0.0", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.5", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity/node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@azure/keyvault-common": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@azure/keyvault-common/-/keyvault-common-2.1.0.tgz", + "integrity": "sha512-aCDidWuKY06LWQ4x7/8TIXK6iRqTaRWRL3t7T+LC+j1b07HtoIsOxP/tU90G4jCSBn5TAyUTCtA4MS/y5Hudaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-rest-pipeline": "^1.8.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.10.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/keyvault-keys": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@azure/keyvault-keys/-/keyvault-keys-4.10.2.tgz", + "integrity": "sha512-VmUSLbXRAbSzDD8grXHGPaknYs0SKr3yuf6U+d4XMpX4XuVYskNqbTTwXce0zR1LyxfTZm9rWEBcvs3vdYwCmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure-rest/core-client": "^2.3.3", + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-lro": "^2.7.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/keyvault-common": "^2.1.0", + "@azure/logger": "^1.1.4", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.21.0.tgz", + "integrity": "sha512-80OcuXDErmcEDAIH9pBtSqBsed2sPT/IWmbG3xHLoPMl5zc8TINd6SlJAbVSmN5huGa3xGAg5qR7VnpaIEK0Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.0.tgz", + "integrity": "sha512-A4rb55hI86Q9tBl/+jBj7TMz7iX2RFgQs/nExFzcAtoI/BFRVdaH5SL/MivrYD7qvweMpN8AgVvVMHV8UBYxew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.6.0.tgz", + "integrity": "sha512-uFY9NxrWHw8PwZx7gAX6PDn+9vdfS05+levc/kwkx77IkjfaldnQbbcQzzDIZ5Hq5Zdr6/z92oAIoRWKp6MnOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.13.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "16.13.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.13.0.tgz", + "integrity": "sha512-rOAy0KUcyBbdwVJ+f3uPpthXatFLLZN+/KWAsTLzk1aB23Xl9DRmmXYwSvBFOZyXj4jUQQ5FKxxRkhAFW1fOow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -4750,6 +5054,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-joda/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-6.1.0.tgz", + "integrity": "sha512-H8NTMRDJqad/leyv/D/A3kSOsf5/58Ydj4DJGDyaCWk9OU/zuZOLhndVffJgQjsgrn5GC0znHMHie7TfvPPG4w==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", @@ -7319,6 +7630,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/@tediousjs/connection-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-1.1.0.tgz", + "integrity": "sha512-z9ZBWEG+8pIB5V1zYzlRPXx0oRJ5H7coPnMQK8EZOw03UTPI9Umn6viL36f5w+CuqkKsnCM50RVStpjZmR0Bng==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -7682,6 +8000,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mssql": { + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@types/mssql/-/mssql-12.3.0.tgz", + "integrity": "sha512-+jy+AJtfuTDI5+nhh0hDNcir1p8P+pf+qsHXpUpYvg7EikxUUePBe+a+Kr6j/Xs89o4EbHlVzrh0HOxbqWM31Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "tarn": "^3.0.1", + "tedious": "*" + } + }, "node_modules/@types/node": { "version": "26.4.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", @@ -7731,6 +8061,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/tmp": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", @@ -8063,6 +8403,21 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.8.tgz", + "integrity": "sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", @@ -8326,7 +8681,6 @@ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -8391,7 +8745,6 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 14" } @@ -9519,6 +9872,13 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/buffer-fill": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", @@ -9600,6 +9960,22 @@ "@types/node": "*" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -10984,6 +11360,36 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -11027,6 +11433,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -11219,6 +11638,16 @@ "node": ">= 10.13.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -12357,7 +12786,6 @@ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -14293,6 +14721,20 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-proxy/node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -14334,7 +14776,6 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -15586,6 +16027,13 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -15684,6 +16132,29 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -15753,6 +16224,29 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kebab-case": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/kebab-case/-/kebab-case-1.0.2.tgz", @@ -16394,6 +16888,20 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -16402,6 +16910,34 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.mapvalues": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz", @@ -16423,6 +16959,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.partition": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.partition/-/lodash.partition-4.6.0.tgz", @@ -18496,6 +19039,36 @@ "devOptional": true, "license": "MIT" }, + "node_modules/mssql": { + "version": "12.7.0", + "resolved": "https://registry.npmjs.org/mssql/-/mssql-12.7.0.tgz", + "integrity": "sha512-J6SJKXi1jYbhHjjooLNtPnX7+s3cq5IJ701Wgy/UW1SXRpgFlJJsYi3IPve9RVgCUkq0Cqv2aaaSJ4IXtIF3mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tediousjs/connection-string": "^1.0.0", + "commander": "^11.0.0", + "debug": "^4.3.3", + "tarn": "^3.0.2", + "tedious": "^19.2.2 || ^20.0.0" + }, + "bin": { + "mssql": "bin/mssql" + }, + "engines": { + "node": ">=18.19.0" + } + }, + "node_modules/mssql/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/multitars": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.2.tgz", @@ -18562,6 +19135,13 @@ "dev": true, "license": "MIT" }, + "node_modules/native-duplexpair": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz", + "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -20875,6 +21455,16 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -22152,6 +22742,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -22950,6 +23553,13 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/sql.js": { "version": "1.14.2", "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", @@ -23728,6 +24338,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/tarn": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.1.2.tgz", + "integrity": "sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/tcp-port-used": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.3.tgz", @@ -23764,6 +24384,100 @@ "dev": true, "license": "MIT" }, + "node_modules/tedious": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/tedious/-/tedious-20.0.0.tgz", + "integrity": "sha512-bTR0aou0Ghucf0ytvZUJjnKHGKDV8tT57jPYtEkSpfTWFe++4uR1wxJLQ4mh5wlSvAYXzmgBxbI0vaE56qigXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.10.1", + "@azure/identity": "^4.13.1", + "@azure/keyvault-keys": "^4.10.2", + "@js-joda/core": "^6.0.1", + "@types/node": ">=22", + "bl": "^6.1.4", + "iconv-lite": "^0.7.0", + "js-md4": "^0.3.2", + "native-duplexpair": "^1.0.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/tedious/node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/tedious/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/tedious/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/tedious/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -25683,6 +26397,38 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xcode": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", diff --git a/package.json b/package.json index 2e0f6ad81d6..9957304f5fa 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "electric-sql": "^0.12.1", "expo": "^57.0.19", "expo-sqlite": "^57.0.2", + "mssql": "^12.7.0", "partykit": "^0.0.115", "partysocket": "^1.3.0", "pg": "^8.23.0", @@ -118,6 +119,9 @@ "expo-sqlite": { "optional": true }, + "mssql": { + "optional": true + }, "partykit": { "optional": true }, @@ -242,6 +246,7 @@ "@types/better-sqlite3": "^9.6.0", "@types/bun": "^1.4.0", "@types/http-server": "^0.12.4", + "@types/mssql": "^12.3.0", "@types/node": "^26.4.1", "@types/pg": "^8.23.1", "@types/react": "^19.2.18", @@ -280,6 +285,7 @@ "idb": "^8.0.3", "less": "^4.9.1", "memfs": "^4.69.1", + "mssql": "^12.7.0", "partykit": "^0.0.115", "partysocket": "^1.3.0", "pg": "^8.23.0", diff --git a/site/build.ts b/site/build.ts index e23d790cf5d..f61e20b90d8 100644 --- a/site/build.ts +++ b/site/build.ts @@ -390,6 +390,7 @@ const addApi = (docs: Docs): Docs => .addApiFile('dist/@types/persisters/persister-file/index.d.ts') .addApiFile('dist/@types/persisters/persister-indexed-db/index.d.ts') .addApiFile('dist/@types/persisters/persister-libsql/index.d.ts') + .addApiFile('dist/@types/persisters/persister-mssql/index.d.ts') .addApiFile('dist/@types/persisters/persister-partykit-client/index.d.ts') .addApiFile('dist/@types/persisters/persister-partykit-server/index.d.ts') .addApiFile('dist/@types/persisters/persister-pg/index.d.ts') diff --git a/site/guides/07_persistence/1_an_intro_to_persistence.md b/site/guides/07_persistence/1_an_intro_to_persistence.md index e120f1907e3..c6eb96149a8 100644 --- a/site/guides/07_persistence/1_an_intro_to_persistence.md +++ b/site/guides/07_persistence/1_an_intro_to_persistence.md @@ -55,6 +55,7 @@ is available. | ElectricSqlPersister | Electric SQL, via [electric](https://github.com/electric-sql/electric) | | LibSqlPersister | LibSQL for Turso, via [libsql-client](https://github.com/tursodatabase/libsql-client-ts) | | PowerSyncPersister | PowerSync, via [powersync-sdk](https://github.com/powersync-ja/powersync-js) | +| MsSqlPersister | SQL Server and Azure SQL, via [mssql](https://github.com/tediousjs/node-mssql) | | PgPersister | PostgreSQL, via [pg](https://github.com/brianc/node-postgres) | | PostgresPersister | PostgreSQL, via [postgres](https://github.com/porsager/postgres) | | PglitePersister | PostgreSQL, via [PGlite](https://github.com/electric-sql/pglite) | diff --git a/site/guides/07_persistence/2_database_persistence.md b/site/guides/07_persistence/2_database_persistence.md index 42c0f9e8b29..b99976aaa63 100644 --- a/site/guides/07_persistence/2_database_persistence.md +++ b/site/guides/07_persistence/2_database_persistence.md @@ -3,8 +3,8 @@ Since v4.0, there are various options for persisting Store data to and from SQLite databases, via a range of third-party modules. -There are currently twelve SQLite-based persistence options, and four for -PostgreSQL: +There are currently twelve SQLite-based persistence options, four for +PostgreSQL, and one for SQL Server: | Persister | Storage | | -------------------------- | ---------------------------------------------------------------------------------------------------------------- | @@ -20,6 +20,7 @@ PostgreSQL: | ElectricSqlPersister | Electric SQL, via [electric](https://github.com/electric-sql/electric) | | LibSqlPersister | LibSQL for Turso, via [libsql-client](https://github.com/tursodatabase/libsql-client-ts) | | PowerSyncPersister | PowerSync, via [powersync-sdk](https://github.com/powersync-ja/powersync-js) | +| MsSqlPersister | SQL Server and Azure SQL, via [mssql](https://github.com/tediousjs/node-mssql) | | PgPersister | PostgreSQL, via [pg](https://github.com/brianc/node-postgres) | | PostgresPersister | PostgreSQL, via [postgres](https://github.com/porsager/postgres) | | PglitePersister | PostgreSQL, via [PGlite](https://github.com/electric-sql/pglite) | @@ -39,6 +40,21 @@ and `Client` objects can be passed straight to the createPgPersister function, which means you can persist a Store to PostgreSQL from an edge runtime that cannot open a regular TCP connection. +The MsSqlPersister covers the SQL Server family, and since Azure SQL Database +and Azure SQL Managed Instance both speak the same protocol, the same +createMsSqlPersister function works against all three. It takes a `mssql` +connection pool that you have configured yourself, so the passwordless +authentication that Microsoft recommends for applications hosted in Azure is a +matter of how you build that pool rather than anything TinyBase needs to know +about. Only the JSON mode described below is available to it so far. + +It also differs in how it notices changes made by other writers. The PostgreSQL +Persisters use LISTEN and NOTIFY, which has no equivalent that works across +every flavor of SQL Server, so the MsSqlPersister adds a `rowversion` column to +its table and polls that instead. SQL Server maintains that column itself on +every insert and update, which keeps external changes detectable without +requiring Service Broker, Change Tracking, or triggers. + The SupabasePersister is the odd one out, since it talks to Supabase's REST API rather than to the database directly. That means it runs in a browser or edge runtime, that row-level security policies apply to what it reads and writes, diff --git a/site/guides/08_synchronization/1_using_a_mergeablestore.md b/site/guides/08_synchronization/1_using_a_mergeablestore.md index 7837cd991f1..cf69b292261 100644 --- a/site/guides/08_synchronization/1_using_a_mergeablestore.md +++ b/site/guides/08_synchronization/1_using_a_mergeablestore.md @@ -126,6 +126,7 @@ MergeableStore, but _only_ in the 'JSON-serialization' mode: | ExpoSqlitePersister | SQLite in React Native, via [expo-sqlite](https://github.com/expo/expo/tree/main/packages/expo-sqlite) | | ReactNativeSqlitePersister | SQLite in React Native, via [react-native-sqlite-storage](https://github.com/andpor/react-native-sqlite-storage) | | CapacitorSqlitePersister | SQLite in Capacitor, via [capacitor-sqlite](https://github.com/capacitor-community/sqlite) | +| MsSqlPersister | SQL Server and Azure SQL, via [mssql](https://github.com/tediousjs/node-mssql) | | PgPersister | PostgreSQL, via [pg](https://github.com/brianc/node-postgres) | | PostgresPersister | PostgreSQL, via [postgres](https://github.com/porsager/postgres) | | PglitePersister | PostgreSQL, via [PGlite](https://github.com/electric-sql/pglite) | diff --git a/site/guides/20_releases.md b/site/guides/20_releases.md index c8d776826b4..31e304ee26e 100644 --- a/site/guides/20_releases.md +++ b/site/guides/20_releases.md @@ -5,6 +5,58 @@ highlighted features. --- +# v9.8 + +## SQL Server and Azure SQL, via `mssql` + +The new persister-mssql module provides the MsSqlPersister, which binds to a +SQL Server database with the +[`mssql`](https://github.com/tediousjs/node-mssql) module. Since Azure SQL +Database and Azure SQL Managed Instance speak the same protocol, the same +Persister works against all three: + +```js +import {connect} from 'mssql'; +import {createStore} from 'tinybase'; +import {createMsSqlPersister} from 'tinybase/persisters/persister-mssql'; + +const msSqlPool = await connect(process.env.TINYBASE_MSSQL); +const msSqlStore = createStore().setTables({pets: {fido: {species: 'dog'}}}); +const msSqlPersister = await createMsSqlPersister( + msSqlStore, + msSqlPool, + 'my_tinybase', +); + +await msSqlPersister.save(); +console.log( + (await msSqlPool.request().query('SELECT * FROM my_tinybase;')).recordset, +); +// -> [{_id: '_', store: '[{"pets":{"fido":{"species":"dog"}}},{}]'}] + +await msSqlPersister.destroy(); +await msSqlPool.request().query('DROP TABLE IF EXISTS my_tinybase;'); +await msSqlPool.close(); +``` + +The Persister takes a connection pool that you have already configured, so it +stays out of the way of how you authenticate. That matters most on Azure, where +Microsoft recommends passwordless access for hosted applications: build the +pool with an `azure-active-directory-default` authentication type and the +Persister needs to know nothing about it. + +This release supports the JSON serialization mode, for both a Store and a +MergeableStore. Tabular mapping may follow. + +Automatic loading works differently here than it does for PostgreSQL. There is +no equivalent of LISTEN and NOTIFY that is available on every flavor of SQL +Server, so the Persister adds a `rowversion` column to its table and polls it. +SQL Server maintains that column itself on every insert and update, so changes +made by other writers are still picked up, without needing Service Broker, +Change Tracking, or triggers. + +--- + # v9.7 ## SQLite, via `node:sqlite` diff --git a/src/@types/omni/index.d.ts b/src/@types/omni/index.d.ts index 3dfdf5d3bbe..b2bc4b52a5b 100644 --- a/src/@types/omni/index.d.ts +++ b/src/@types/omni/index.d.ts @@ -22,6 +22,7 @@ export * from '../persisters/persister-expo-sqlite/index.d.ts'; export * from '../persisters/persister-file/index.d.ts'; export * from '../persisters/persister-indexed-db/index.d.ts'; export * from '../persisters/persister-libsql/index.d.ts'; +export * from '../persisters/persister-mssql/index.d.ts'; export * from '../persisters/persister-partykit-client/index.d.ts'; export * from '../persisters/persister-partykit-server/index.d.ts'; export * from '../persisters/persister-pg/index.d.ts'; diff --git a/src/@types/omni/with-schemas/index.d.ts b/src/@types/omni/with-schemas/index.d.ts index 7a1c21bc79d..e2d5511416d 100644 --- a/src/@types/omni/with-schemas/index.d.ts +++ b/src/@types/omni/with-schemas/index.d.ts @@ -21,6 +21,7 @@ export * from '../../persisters/persister-expo-sqlite/with-schemas/index.d.ts'; export * from '../../persisters/persister-file/with-schemas/index.d.ts'; export * from '../../persisters/persister-indexed-db/with-schemas/index.d.ts'; export * from '../../persisters/persister-libsql/with-schemas/index.d.ts'; +export * from '../../persisters/persister-mssql/with-schemas/index.d.ts'; export * from '../../persisters/persister-partykit-client/with-schemas/index.d.ts'; export * from '../../persisters/persister-partykit-server/with-schemas/index.d.ts'; export * from '../../persisters/persister-pg/with-schemas/index.d.ts'; diff --git a/src/@types/persisters/docs.js b/src/@types/persisters/docs.js index 675744cb96a..a34f975cff0 100644 --- a/src/@types/persisters/docs.js +++ b/src/@types/persisters/docs.js @@ -1890,6 +1890,46 @@ * @since v5.2.0 */ /// createCustomSqlitePersister +/** + * The createCustomMsSqlPersister function creates a Persister object that you + * can configure to persist the Store to a SQL Server database. + * + * This is only used when developing custom database-oriented Persisters, and + * most TinyBase users will not need to be particularly aware of it. + * + * Unlike the PostgreSQL equivalent, this takes no change listener functions. + * SQL Server has no notification mechanism that is available on every one of + * its hosted flavors, so automatic loading polls a `rowversion` column that the + * Persister maintains instead. + * + * The createMsSqlPersister function uses this function under the covers, and so + * you may wish to look at that implementation for ideas on how to build your + * own Persister type, and as a functional example. + * @param store The Store to persist. + * @param configOrStoreTableName A DpcJson object, or a string that will be used + * as the name of the Store's table in the database. + * @param executeCommand A function that will execute a command against the + * database. + * @param onSqlCommand A function that will be called for each SQL command + * executed against the database. + * @param onIgnoredError An optional handler for the errors that the Persister + * would otherwise ignore when trying to save or load data. This is suitable for + * debugging persistence issues in a development environment. + * @param destroy A function that will be called to perform any extra clean up + * on the Persister. + * @param persist An integer from the Persists enum to indicate which types of + * Store are supported by this Persister: `1` indicates only a regular Store is + * supported, `2` indicates only a MergeableStore is supported, and `3` + * indicates that both Store and MergeableStore are supported. + * @param thing A reference to the database or connection that can be returned + * with a method, by default called `getDb`. + * @param getThing An optional string that will be used to get the reference to + * the database or connection from the Persister, defaulting to `getDb`. + * @returns A reference to the new Persister object. + * @category Creation + * @since 9.8.0 + */ +/// createCustomMsSqlPersister /** * The createCustomPostgreSqlPersister function creates a Persister object that * you can configure to persist the Store to a PostgreSQL database. diff --git a/src/@types/persisters/index.d.ts b/src/@types/persisters/index.d.ts index 862c54529c2..15051576632 100644 --- a/src/@types/persisters/index.d.ts +++ b/src/@types/persisters/index.d.ts @@ -265,6 +265,21 @@ export function createCustomSqlitePersister< getThing?: string, ): Persister; +/// createCustomMsSqlPersister +export function createCustomMsSqlPersister< + Persist extends Persists = Persists.StoreOnly, +>( + store: PersistedStore, + configOrStoreTableName: DatabasePersisterConfig | string | undefined, + executeCommand: DatabaseExecuteCommand, + onSqlCommand: ((sql: string, params?: any[]) => void) | undefined, + onIgnoredError: ((error: any) => void) | undefined, + destroy: () => void, + persist: Persist, + thing: any, + getThing?: string, +): Persister; + /// createCustomPostgreSqlPersister export function createCustomPostgreSqlPersister< ListenerHandle, diff --git a/src/@types/persisters/persister-mssql/docs.js b/src/@types/persisters/persister-mssql/docs.js new file mode 100644 index 00000000000..030861abb4c --- /dev/null +++ b/src/@types/persisters/persister-mssql/docs.js @@ -0,0 +1,166 @@ +/** + * The persister-mssql module of the TinyBase project lets you save and load + * Store data to and from a SQL Server database, via the `mssql` module (in an + * appropriate environment). + * + * Since the `mssql` module speaks the SQL Server protocol, this module works + * with SQL Server itself, Azure SQL Database, and Azure SQL Managed Instance + * alike. + * + * The Persister takes a `mssql` connection pool that you have already + * configured, so it stays out of the way of how you choose to authenticate. + * That includes the passwordless options that Microsoft recommends for + * applications hosted in Azure: + * + * ```js ignore + * import {ConnectionPool} from 'mssql'; + * import {createStore} from 'tinybase'; + * import {createMsSqlPersister} from 'tinybase/persisters/persister-mssql'; + * + * const pool = new ConnectionPool({ + * server: 'myserver.database.windows.net', + * database: 'my_database', + * authentication: {type: 'azure-active-directory-default'}, + * options: {encrypt: true}, + * }); + * await pool.connect(); + * + * const store = createStore(); + * const persister = await createMsSqlPersister(store, pool, 'my_tinybase'); + * await persister.startAutoPersisting(); + * ``` + * + * This module currently supports the JSON serialization mode only. + * + * The examples in this documentation connect with the `TINYBASE_MSSQL` + * environment variable, which should be set to a connection string for a + * scratch database. + * @see Database Persistence guide + * @packageDocumentation + * @module persister-mssql + * @since 9.8.0 + */ +/// persister-mssql +/** + * The MsSqlPersister interface represents a Persister that lets you save and + * load Store data to and from a SQL Server database, via the `mssql` module. + * + * You should use the createMsSqlPersister function to create an MsSqlPersister + * object. + * + * It is a minor extension to the Persister interface and simply provides an + * extra getMsSql method for accessing a reference to the database connection + * the Store is being persisted to. + * @category Persister + * @since 9.8.0 + */ +/// MsSqlPersister +{ + /** + * The getMsSql method returns a reference to the database connection the + * Store is being persisted to. + * @returns A reference to the `mssql` ConnectionPool. + * @example + * This example creates a Persister object against a newly-created Store and + * then gets the database connection back out again. + * + * ```js + * import {connect} from 'mssql'; + * import {createStore} from 'tinybase'; + * import {createMsSqlPersister} from 'tinybase/persisters/persister-mssql'; + * + * const pool = await connect(process.env.TINYBASE_MSSQL); + * const store = createStore().setTables({pets: {fido: {species: 'dog'}}}); + * const persister = await createMsSqlPersister(store, pool, 'my_tinybase'); + * + * console.log(persister.getMsSql() == pool); + * // -> true + * + * await persister.destroy(); + * await pool.close(); + * ``` + * @category Getter + * @since 9.8.0 + */ + /// MsSqlPersister.getMsSql +} +/** + * The createMsSqlPersister function creates an MsSqlPersister object that can + * persist the Store to a SQL Server database via the `mssql` module. + * + * An MsSqlPersister supports regular Store objects, and can also be used to + * persist the metadata of a MergeableStore, since it uses the JSON + * serialization mode. + * + * As well as providing a reference to the Store to persist, you must provide an + * `mssql` parameter which is a ConnectionPool. The Persister issues its + * transactions on connections taken from that pool, so make sure it is large + * enough to accommodate the rest of your application too. + * + * The third argument is a DpcJson object that configures the table and column + * names used for the serialization. If it is simply a string, it is used as the + * `storeTableName` property instead. Unlike the PostgreSQL and SQLite + * Persisters, the tabular mode is not yet supported here, and a DpcTabular + * configuration will be rejected. + * + * Automatic loading polls a `rowversion` column that the Persister adds to its + * table. SQL Server maintains that column itself on every insert and update, + * including ones made by other clients, so changes made outside of TinyBase are + * picked up too. Use the `autoLoadIntervalSeconds` property of the + * configuration to control how often it is checked. + * + * This method is asynchronous. You will need to `await` a call to this function + * or handle the return type natively as a Promise. + * @param store The Store or MergeableStore to persist. + * @param mssql The `mssql` ConnectionPool that identifies the database + * connection. + * @param configOrStoreTableName A DpcJson object to configure the persistence + * (or a string to set its `storeTableName` property). + * @param onSqlCommand An optional handler called every time the Persister + * executes a SQL command or query. This is suitable for logging persistence + * behavior in a development environment. + * @param onIgnoredError An optional handler for the errors that the Persister + * would otherwise ignore when trying to save or load data. This is suitable for + * debugging persistence issues in a development environment. + * @returns A reference to the new MsSqlPersister object. + * @example + * This example creates an MsSqlPersister object and persists the Store to a + * local SQL Server database as a JSON serialization into the `my_tinybase` + * table. It makes a change to the database directly and then reloads it back + * into the Store. + * + * ```js + * import {connect} from 'mssql'; + * import {createStore} from 'tinybase'; + * import {createMsSqlPersister} from 'tinybase/persisters/persister-mssql'; + * + * const pool = await connect(process.env.TINYBASE_MSSQL); + * const store = createStore().setTables({pets: {fido: {species: 'dog'}}}); + * const persister = await createMsSqlPersister(store, pool, 'my_tinybase'); + * + * await persister.save(); + * // Store will be saved to the database. + * + * console.log( + * (await pool.request().query('SELECT * FROM my_tinybase;')).recordset, + * ); + * // -> [{_id: '_', store: '[{"pets":{"fido":{"species":"dog"}}},{}]'}] + * + * await pool + * .request() + * .input('store', '[{"pets":{"felix":{"species":"cat"}}},{}]') + * .input('id', '_') + * .query('UPDATE my_tinybase SET store = @store WHERE _id = @id;'); + * + * await persister.load(); + * console.log(store.getTables()); + * // -> {pets: {felix: {species: 'cat'}}} + * + * await persister.destroy(); + * await pool.request().query('DROP TABLE IF EXISTS my_tinybase;'); + * await pool.close(); + * ``` + * @category Creation + * @since 9.8.0 + */ +/// createMsSqlPersister diff --git a/src/@types/persisters/persister-mssql/index.d.ts b/src/@types/persisters/persister-mssql/index.d.ts new file mode 100644 index 00000000000..aa98ee7bf6c --- /dev/null +++ b/src/@types/persisters/persister-mssql/index.d.ts @@ -0,0 +1,20 @@ +/// persister-mssql +import type {ConnectionPool} from 'mssql'; +import type {MergeableStore} from '../../mergeable-store/index.d.ts'; +import type {Store} from '../../store/index.d.ts'; +import type {DpcJson, Persister, Persists} from '../index.d.ts'; + +/// MsSqlPersister +export interface MsSqlPersister extends Persister { + /// MsSqlPersister.getMsSql + getMsSql(): ConnectionPool; +} + +/// createMsSqlPersister +export function createMsSqlPersister( + store: Store | MergeableStore, + mssql: ConnectionPool, + configOrStoreTableName?: DpcJson | string, + onSqlCommand?: (sql: string, params?: any[]) => void, + onIgnoredError?: (error: any) => void, +): Promise; diff --git a/src/@types/persisters/persister-mssql/with-schemas/index.d.ts b/src/@types/persisters/persister-mssql/with-schemas/index.d.ts new file mode 100644 index 00000000000..739c6faf85f --- /dev/null +++ b/src/@types/persisters/persister-mssql/with-schemas/index.d.ts @@ -0,0 +1,25 @@ +/// persister-mssql +import type {ConnectionPool} from 'mssql'; +import type {MergeableStore} from '../../../mergeable-store/with-schemas/index.d.ts'; +import type { + OptionalSchemas, + Store, +} from '../../../store/with-schemas/index.d.ts'; +import type {DpcJson, Persister, Persists} from '../../with-schemas/index.d.ts'; + +/// MsSqlPersister +export interface MsSqlPersister< + Schemas extends OptionalSchemas, +> extends Persister { + /// MsSqlPersister.getMsSql + getMsSql(): ConnectionPool; +} + +/// createMsSqlPersister +export function createMsSqlPersister( + store: MergeableStore | Store, + mssql: ConnectionPool, + configOrStoreTableName?: DpcJson | string, + onSqlCommand?: (sql: string, params?: any[]) => void, + onIgnoredError?: (error: any) => void, +): Promise>; diff --git a/src/@types/persisters/with-schemas/index.d.ts b/src/@types/persisters/with-schemas/index.d.ts index b571d385e65..51032d9fdec 100644 --- a/src/@types/persisters/with-schemas/index.d.ts +++ b/src/@types/persisters/with-schemas/index.d.ts @@ -306,6 +306,22 @@ export function createCustomSqlitePersister< getThing?: string, ): Persister; +/// createCustomMsSqlPersister +export function createCustomMsSqlPersister< + Schemas extends OptionalSchemas, + Persist extends Persists = Persists.StoreOnly, +>( + store: PersistedStore, + configOrStoreTableName: DatabasePersisterConfig | string | undefined, + executeCommand: DatabaseExecuteCommand, + onSqlCommand: ((sql: string, params?: any[]) => void) | undefined, + onIgnoredError: ((error: any) => void) | undefined, + destroy: () => void, + persist: Persist, + thing: any, + getThing?: string, +): Persister; + /// createCustomPostgreSqlPersister export function createCustomPostgreSqlPersister< Schemas extends OptionalSchemas, diff --git a/src/omni/index.ts b/src/omni/index.ts index 2cb67450343..b56da565b6b 100644 --- a/src/omni/index.ts +++ b/src/omni/index.ts @@ -17,6 +17,7 @@ export * from '../persisters/persister-expo-sqlite/index.ts'; export * from '../persisters/persister-file/index.ts'; export * from '../persisters/persister-indexed-db/index.ts'; export * from '../persisters/persister-libsql/index.ts'; +export * from '../persisters/persister-mssql/index.ts'; export * from '../persisters/persister-partykit-client/index.ts'; export * from '../persisters/persister-partykit-server/index.ts'; export * from '../persisters/persister-pg/index.ts'; diff --git a/src/persisters/common/database/commands.ts b/src/persisters/common/database/commands.ts index f5c588a2fc1..d8b79080275 100644 --- a/src/persisters/common/database/commands.ts +++ b/src/persisters/common/database/commands.ts @@ -38,6 +38,7 @@ import { ALTER_TABLE, CREATE_TABLE, DELETE_FROM, + type Dialect, escapeColumnNames, escapeId, GetPlaceholder, @@ -66,6 +67,7 @@ export const getCommandFunctions = ( encode?: (cellOrValue: any) => string | number, decode?: (field: string | number) => any, executeTransaction?: DatabaseTransaction, + dialect: Dialect = [], ): [ refreshSchema: () => Promise, loadTable: ( @@ -92,6 +94,11 @@ export const getCommandFunctions = ( ) => Promise, transaction: (actions: () => Promise) => Promise, ] => { + const [ + trueCondition = TRUE, + rowIdColumnType = columnType, + dropColumn = 'DROP', + ] = dialect; const schemaMap: Schema = mapNew(); const uniqueSchemaMap: Schema = mapNew(); let executeCommand = databaseExecuteCommand; @@ -131,7 +138,7 @@ export const getCommandFunctions = ( await executeCommand( SELECT_STAR_FROM + escapeId(tableName) + - getWhereCondition(tableName, condition), + getWhereCondition(tableName, condition, trueCondition), ), (row): [Id | undefined, Row] => { const rowId = row[rowIdColumnName]; @@ -221,7 +228,8 @@ export const getCommandFunctions = ( await executeCommand( CREATE_TABLE + escapeId(tableName) + - `(${escapeId(rowIdColumnName)}${columnType} PRIMARY KEY${arrayJoin( + `(${escapeId(rowIdColumnName)}${rowIdColumnType} PRIMARY KEY` + + `${arrayJoin( arrayMap( settingColumnNames, (settingColumnName) => @@ -286,7 +294,7 @@ export const getCommandFunctions = ( await executeCommand( ALTER_TABLE + escapeId(tableName) + - 'DROP' + + dropColumn + escapeId(unaccountedColumnName), ); collDel(currentColumnNames, unaccountedColumnName); @@ -303,7 +311,7 @@ export const getCommandFunctions = ( await executeCommand( DELETE_FROM + escapeId(tableName) + - getWhereCondition(tableName, condition), + getWhereCondition(tableName, condition, trueCondition), ); } } else { @@ -314,7 +322,7 @@ export const getCommandFunctions = ( await executeCommand( DELETE_FROM + escapeId(tableName) + - getWhereCondition(tableName, condition) + + getWhereCondition(tableName, condition, trueCondition) + `AND(${escapeId(rowIdColumnName)}=${getPlaceholder([1])})`, [rowId], ); @@ -377,7 +385,7 @@ export const getCommandFunctions = ( await executeCommand( DELETE_FROM + escapeId(tableName) + - getWhereCondition(tableName, condition) + + getWhereCondition(tableName, condition, trueCondition) + // eslint-disable-next-line max-len `AND${escapeId(rowIdColumnName)}NOT IN(${getPlaceholders(deleteRowIds, getPlaceholder)})`, deleteRowIds, @@ -390,7 +398,7 @@ export const getCommandFunctions = ( await executeCommand( DELETE_FROM + escapeId(tableName) + - getWhereCondition(tableName, condition), + getWhereCondition(tableName, condition, trueCondition), ); } } diff --git a/src/persisters/common/database/common.ts b/src/persisters/common/database/common.ts index 0eace79f4cc..677b3b7ac1a 100644 --- a/src/persisters/common/database/common.ts +++ b/src/persisters/common/database/common.ts @@ -76,6 +76,18 @@ export const escapeColumnNames = (...columnNames: string[]) => export type GetPlaceholder = (offset: number[]) => string; +// Where dialects disagree on syntax that is otherwise shared. Each part is +// optional and falls back to the SQLite and PostgreSQL spelling. +export type Dialect = [ + // SQL Server has no boolean literal, and needs '1=1' instead of 'true'. + trueCondition?: string, + // SQL Server cannot index its unbounded text type, so the row Id column + // needs a narrower type than the other columns. + rowIdColumnType?: string, + // SQL Server spells this 'DROP COLUMN' rather than 'DROP'. + dropColumn?: string, +]; + // PostgreSQL needs numbered placeholders; SQLite drivers only agree on // anonymous ones. export const numberedPlaceholder: GetPlaceholder = (offset) => @@ -95,7 +107,14 @@ export const getPlaceholders = ( export const getWhereCondition = ( tableName: string, condition: DpcTabularCondition = TRUE, -) => WHERE + `(${replaceTableName(condition, escapeId(tableName))})`; + trueCondition: string = TRUE, +) => + WHERE + + `(${ + condition == TRUE + ? trueCondition + : replaceTableName(condition, escapeId(tableName)) + })`; export const replaceTableName = ( condition: DpcTabularCondition, diff --git a/src/persisters/common/database/json.ts b/src/persisters/common/database/json.ts index a94512538a3..1565927802c 100644 --- a/src/persisters/common/database/json.ts +++ b/src/persisters/common/database/json.ts @@ -12,7 +12,13 @@ import { } from '../../../common/json.ts'; import {createCustomPersister} from '../create.ts'; import {DatabaseTransaction, getCommandFunctions} from './commands.ts'; -import {GetPlaceholder, QuerySchema, SINGLE_ROW_ID, Upsert} from './common.ts'; +import { + type Dialect, + GetPlaceholder, + QuerySchema, + SINGLE_ROW_ID, + Upsert, +} from './common.ts'; import type {DefaultedJsonConfig} from './config.ts'; export const createJsonPersister = < @@ -41,6 +47,7 @@ export const createJsonPersister = < _encode?: (cellOrValue: any) => string | number, _decode?: (field: string | number) => any, executeTransaction?: DatabaseTransaction, + dialect?: Dialect, ): Persister => { const [refreshSchema, loadTable, saveTable, transaction] = getCommandFunctions( @@ -53,6 +60,7 @@ export const createJsonPersister = < undefined, undefined, executeTransaction, + dialect, ); const getPersisted = (): Promise> => diff --git a/src/persisters/common/database/mssql.ts b/src/persisters/common/database/mssql.ts new file mode 100644 index 00000000000..5e494292fd3 --- /dev/null +++ b/src/persisters/common/database/mssql.ts @@ -0,0 +1,326 @@ +import type { + DatabaseExecuteCommand, + DatabasePersisterConfig, + PersistedStore, + Persister, + PersisterListener, + Persists, +} from '../../../@types/persisters/index.d.ts'; +import {arrayJoin, arrayMap} from '../../../common/array.ts'; +import {collValues} from '../../../common/coll.ts'; +import { + ERROR_STORE_TYPE, + errorThrow, + tryCatch, + tryCatchIgnore, + tryFinallyAsync, +} from '../../../common/error.ts'; +import {jsonParse, jsonString} from '../../../common/json.ts'; +import {objToArray} from '../../../common/obj.ts'; +import { + isEmpty, + isUndefined, + startInterval, + stopInterval, +} from '../../../common/other.ts'; +import {COMMA, EMPTY_STRING} from '../../../common/strings.ts'; +import {DatabaseTransaction} from './commands.ts'; +import { + type Dialect, + escapeColumnNames, + escapeId, + GetPlaceholder, + getPlaceholders, + getWrappedCommand, + INSERT, + SELECT, + SINGLE_ROW_ID, + UPDATE, + Upsert, + WHERE, +} from './common.ts'; +import {DefaultedJsonConfig, getConfigStructures} from './config.ts'; +import {createJsonPersister} from './json.ts'; + +// SQL Server generates and maintains this itself on every insert and update, +// including ones made by other clients, which is what makes it a sound and +// very cheap signal to poll for auto-loading. +const VERSION_COLUMN_NAME = '_version'; +const ROWVERSION = 'rowversion'; +// The type rowversion reports itself as in INFORMATION_SCHEMA. +const TIMESTAMP = 'timestamp'; +// nvarchar(max) cannot be indexed, so the row Id column gets the widest type +// that still fits SQL Server's 900-byte index key limit. +const ROW_ID_COLUMN_TYPE = 'nvarchar(450)'; +const COLUMN_TYPE = 'nvarchar(max)'; +// T-SQL has no boolean literal. +const TRUE_CONDITION = '1=1'; +const DROP_COLUMN = 'DROP COLUMN'; +const MSSQL_DIALECT: Dialect = [ + TRUE_CONDITION, + ROW_ID_COLUMN_TYPE, + DROP_COLUMN, +]; + +const namedPlaceholder: GetPlaceholder = (offset) => '@p' + offset[0]++; + +// SQL Server has no ON CONFLICT. HOLDLOCK is required to stop the match and +// the insert from racing each other under concurrency. +const mssqlUpsert: Upsert = async ( + executeCommand: DatabaseExecuteCommand, + tableName: string, + rowIdColumnName: string, + changingColumnNames: string[], + rows: {[id: string]: any[]}, + getPlaceholder: GetPlaceholder, +) => { + const offset = [1]; + const escapedTableName = escapeId(tableName); + const escapedRowIdColumnName = escapeId(rowIdColumnName); + const escapedColumnNames = escapeColumnNames( + rowIdColumnName, + ...changingColumnNames, + ); + await executeCommand( + 'MERGE INTO' + + escapedTableName + + ` WITH(HOLDLOCK)AS t USING(VALUES` + + arrayJoin( + objToArray( + rows, + (row: any[]) => + '(' + + getPlaceholder(offset) + + COMMA + + getPlaceholders(row, getPlaceholder, offset) + + ')', + ), + COMMA, + ) + + `)AS s(${escapedColumnNames})ON t.${escapedRowIdColumnName}` + + `=s.${escapedRowIdColumnName}` + + (isEmpty(changingColumnNames) + ? EMPTY_STRING + : ` WHEN MATCHED THEN ${UPDATE} SET ` + + arrayJoin( + arrayMap( + changingColumnNames, + (columnName) => + 't.' + escapeId(columnName) + '=s.' + escapeId(columnName), + ), + COMMA, + )) + + ` WHEN NOT MATCHED THEN ${INSERT}(${escapedColumnNames})VALUES(` + + arrayJoin( + arrayMap( + [rowIdColumnName, ...changingColumnNames], + (columnName) => 's.' + escapeId(columnName), + ), + COMMA, + ) + + ');', + objToArray(rows, (row: any[], id: string) => [ + id, + ...arrayMap(row, (value) => value ?? null), + ]).flat(), + ); +}; + +export const createCustomMsSqlPersister = < + Persist extends Persists = Persists.StoreOnly, +>( + store: PersistedStore, + configOrStoreTableName: DatabasePersisterConfig | string | undefined, + rawExecuteCommand: DatabaseExecuteCommand, + onSqlCommand: ((sql: string, params?: any[]) => void) | undefined, + onIgnoredError: ((error: any) => void) | undefined, + destroy: () => void, + persist: Persist, + thing: any, + getThing = 'getDb', + executeTransaction?: DatabaseTransaction, +): Persister => { + const executeCommand = getWrappedCommand(rawExecuteCommand, onSqlCommand); + + // The shared fallback issues BEGIN and END, which are block delimiters + // rather than transaction statements in T-SQL. + const executeMsSqlTransaction: DatabaseTransaction = async (actions) => { + await executeCommand('BEGIN TRANSACTION'); + try { + const result = await actions(executeCommand); + await executeCommand('COMMIT'); + return result; + } catch (error) { + await tryCatch(() => executeCommand('ROLLBACK')); + throw error; + } + }; + + const [ + isJson, + autoLoadIntervalSeconds, + defaultedConfig, + managedTableNamesSet, + ] = getConfigStructures(configOrStoreTableName); + // This Persister only supports JSON serialization for now. + if (!isJson) { + errorThrow(ERROR_STORE_TYPE); + } + const [storeTableName, storeIdColumnName] = + defaultedConfig as DefaultedJsonConfig; + + const escapedStoreTableName = escapeId(storeTableName); + const escapedVersionColumnName = escapeId(VERSION_COLUMN_NAME); + + // Adding the column is only possible once the table itself exists, which the + // shared save path creates. It is dropped along with the table whenever the + // Store empties, so this runs again whenever the probe stops working. + const addVersionColumn = async (): Promise => { + await executeCommand( + `IF OBJECT_ID(@p1,'U')IS NOT NULL AND ` + + `COL_LENGTH(@p1,'${VERSION_COLUMN_NAME}')IS NULL ALTER TABLE` + + escapedStoreTableName + + 'ADD' + + escapedVersionColumnName + + ' ' + + ROWVERSION, + [storeTableName], + ); + }; + + const getVersion = async (): Promise => { + const rows = (await tryCatch(() => + executeCommand( + SELECT + + ` CONVERT(bigint,${escapedVersionColumnName}) v FROM` + + escapedStoreTableName + + WHERE + + escapeId(storeIdColumnName) + + '=@p1', + [SINGLE_ROW_ID], + ), + )) as {[field: string]: any}[] | undefined; + if (isUndefined(rows)) { + // The table or the version column is missing; try to put it back. + await tryCatchIgnore(addVersionColumn, onIgnoredError); + return null; + } + const version = rows[0]?.v; + return isUndefined(version) || version === null + ? null + : EMPTY_STRING + version; + }; + + const addPersisterListener = ( + listener: PersisterListener, + ): Promise<() => Promise> => { + let active = 1; + let baselineReady = 0; + let currentVersion: string | null = null; + let interval: ReturnType | undefined; + let task: Promise | undefined; + + const checkForChanges = async (notify = true) => { + const version = await getVersion(); + if (active && version != currentVersion) { + const shouldNotify = notify && baselineReady; + currentVersion = version; + if (shouldNotify && active) { + await listener(); + } + } + }; + + const stopPolling = () => { + if (!isUndefined(interval)) { + stopInterval(interval); + interval = undefined; + } + }; + + const startPolling = () => { + if (active && isUndefined(interval)) { + interval = startInterval( + () => void run(true), + autoLoadIntervalSeconds as number, + ); + } + }; + + const run = (notify = false): Promise => { + if (task) { + return task; + } + const newTask = tryFinallyAsync( + () => + tryCatchIgnore(async () => { + if (!baselineReady) { + await addVersionColumn(); + await checkForChanges(false); + baselineReady = 1; + } else if (notify) { + await checkForChanges(); + } + }, onIgnoredError), + () => { + if (task == newTask) { + task = undefined; + } + if (active) { + startPolling(); + } + }, + ); + task = newTask; + return newTask; + }; + + return run().then(() => async () => { + active = 0; + stopPolling(); + await tryFinallyAsync( + async () => await task, + () => { + currentVersion = null; + }, + ); + }); + }; + + const delPersisterListener = ( + stopPolling: () => void | Promise, + ): void | Promise => stopPolling(); + + return createJsonPersister( + store, + executeCommand, + addPersisterListener, + delPersisterListener, + onIgnoredError, + destroy, + persist, + defaultedConfig as DefaultedJsonConfig, + collValues(managedTableNamesSet), + async ( + executeCommand: DatabaseExecuteCommand, + managedTableNames: string[], + ): Promise => + await executeCommand( + SELECT + + // Rowversion columns are excluded so that the shared schema handling + // neither writes to them nor drops them as unaccounted for. + // eslint-disable-next-line max-len + ` c.TABLE_NAME tn,c.COLUMN_NAME cn,CASE WHEN tc.CONSTRAINT_TYPE IN('PRIMARY KEY','UNIQUE')AND(${SELECT} count(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu2 ${WHERE} kcu2.CONSTRAINT_SCHEMA=kcu.CONSTRAINT_SCHEMA AND kcu2.CONSTRAINT_NAME=kcu.CONSTRAINT_NAME)=1 THEN 1 ELSE 0 END uq FROM INFORMATION_SCHEMA.COLUMNS c LEFT JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu ON kcu.TABLE_SCHEMA=c.TABLE_SCHEMA AND kcu.TABLE_NAME=c.TABLE_NAME AND kcu.COLUMN_NAME=c.COLUMN_NAME LEFT JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_SCHEMA=kcu.CONSTRAINT_SCHEMA AND tc.CONSTRAINT_NAME=kcu.CONSTRAINT_NAME ${WHERE} c.TABLE_SCHEMA=SCHEMA_NAME()AND c.DATA_TYPE<>'${TIMESTAMP}'AND c.TABLE_NAME IN(${getPlaceholders(managedTableNames, namedPlaceholder)})`, + managedTableNames, + ), + thing, + getThing, + COLUMN_TYPE, + namedPlaceholder, + mssqlUpsert, + (cellOrValue: any) => jsonString(cellOrValue), + (field: string | number) => jsonParse(field as string), + executeTransaction ?? executeMsSqlTransaction, + MSSQL_DIALECT, + ); +}; diff --git a/src/persisters/common/database/tabular.ts b/src/persisters/common/database/tabular.ts index 8aee6e10f58..7b40ed435da 100644 --- a/src/persisters/common/database/tabular.ts +++ b/src/persisters/common/database/tabular.ts @@ -22,6 +22,7 @@ import {createCustomPersister} from '../create.ts'; import {DatabaseTransaction, getCommandFunctions} from './commands.ts'; import { DEFAULT_ROW_ID_COLUMN_NAME, + type Dialect, GetPlaceholder, QuerySchema, SINGLE_ROW_ID, @@ -65,6 +66,7 @@ export const createTabularPersister = < encode?: (cellOrValue: any) => string | number, decode?: (field: string | number) => any, executeTransaction?: DatabaseTransaction, + dialect?: Dialect, ): Persister => { const [refreshSchema, loadTable, saveTable, transaction] = getCommandFunctions( @@ -77,6 +79,7 @@ export const createTabularPersister = < encode, decode, executeTransaction, + dialect, ); const saveTables = ( diff --git a/src/persisters/index.ts b/src/persisters/index.ts index ee3a873cec2..de69e2f8edb 100644 --- a/src/persisters/index.ts +++ b/src/persisters/index.ts @@ -1,3 +1,4 @@ export * from './common/create.ts'; +export * from './common/database/mssql.ts'; export * from './common/database/postgresql.ts'; export * from './common/database/sqlite.ts'; diff --git a/src/persisters/persister-mssql/index.ts b/src/persisters/persister-mssql/index.ts new file mode 100644 index 00000000000..039b1215e5e --- /dev/null +++ b/src/persisters/persister-mssql/index.ts @@ -0,0 +1,62 @@ +import type {ConnectionPool, Request} from 'mssql'; +import type {MergeableStore} from '../../@types/mergeable-store/index.d.ts'; +import type { + DatabaseExecuteCommand, + DatabasePersisterConfig, +} from '../../@types/persisters/index.d.ts'; +import type { + MsSqlPersister, + createMsSqlPersister as createMsSqlPersisterDecl, +} from '../../@types/persisters/persister-mssql/index.d.ts'; +import type {Store} from '../../@types/store/index.d.ts'; +import {arrayForEach} from '../../common/array.ts'; +import {IdObj} from '../../common/obj.ts'; +import {noop} from '../../common/other.ts'; +import {createCustomMsSqlPersister} from '../common/database/mssql.ts'; + +// The shared code emits positional parameters as @p1, @p2, and so on, which +// the driver binds by name. +const getExecuteCommand = + (getRequest: () => Request): DatabaseExecuteCommand => + async (sql: string, params: any[] = []): Promise[]> => { + const request = getRequest(); + arrayForEach(params, (param, index) => + request.input('p' + (index + 1), param), + ); + return (await request.query(sql)).recordset ?? []; + }; + +export const createMsSqlPersister = (async ( + store: Store | MergeableStore, + mssql: ConnectionPool, + configOrStoreTableName?: DatabasePersisterConfig | string, + onSqlCommand?: (sql: string, params?: any[]) => void, + onIgnoredError?: (error: any) => void, +): Promise => + createCustomMsSqlPersister( + store, + configOrStoreTableName, + getExecuteCommand(() => mssql.request()), + onSqlCommand, + onIgnoredError, + noop, + 3, // StoreOrMergeableStore, + mssql, + 'getMsSql', + // A Transaction takes its own connection from the pool, so unlike other + // drivers there is no need to reserve one up front. + async (actions) => { + const transaction = mssql.transaction(); + await transaction.begin(); + try { + const result = await actions( + getExecuteCommand(() => transaction.request()), + ); + await transaction.commit(); + return result; + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + ) as MsSqlPersister) as typeof createMsSqlPersisterDecl; diff --git a/test/unit/documentation.test.ts b/test/unit/documentation.test.ts index b66b878a970..a4c2bc2a46e 100644 --- a/test/unit/documentation.test.ts +++ b/test/unit/documentation.test.ts @@ -11,6 +11,7 @@ import 'fake-indexeddb/auto'; import * as fs from 'fs'; import {readFileSync, readdirSync} from 'fs'; import {createRequire} from 'module'; +import * as mssql from 'mssql'; import {dirname, extname, join, resolve} from 'path'; import * as pg from 'pg'; import postgres from 'postgres'; @@ -30,6 +31,7 @@ import * as TinyBasePersisterBrowser from 'tinybase/persisters/persister-browser import * as TinyBasePersisterCrSqliteWasm from 'tinybase/persisters/persister-cr-sqlite-wasm'; import * as TinyBasePersisterFile from 'tinybase/persisters/persister-file'; import * as TinyBasePersisterIndexedDb from 'tinybase/persisters/persister-indexed-db'; +import * as TinyBasePersisterMsSql from 'tinybase/persisters/persister-mssql'; import * as TinyBasePersisterPartyKitClient from 'tinybase/persisters/persister-partykit-client'; import * as TinyBasePersisterPartyKitServer from 'tinybase/persisters/persister-partykit-server'; import * as TinyBasePersisterPg from 'tinybase/persisters/persister-pg'; @@ -132,6 +134,7 @@ const TinyBaseForTest = { '@vlcn.io/crsqlite-wasm': initWasm, arktype, fs, + mssql, pg, postgres, react: React, @@ -159,6 +162,7 @@ const TinyBaseForTest = { 'tinybase/persisters/persister-cr-sqlite-wasm': TinyBasePersisterCrSqliteWasm, 'tinybase/persisters/persister-file': TinyBasePersisterFile, 'tinybase/persisters/persister-indexed-db': TinyBasePersisterIndexedDb, + 'tinybase/persisters/persister-mssql': TinyBasePersisterMsSql, 'tinybase/persisters/persister-partykit-client': TinyBasePersisterPartyKitClient, 'tinybase/persisters/persister-partykit-server': diff --git a/test/unit/persisters/__snapshots__/mergeable.test.ts.snap b/test/unit/persisters/__snapshots__/mergeable.test.ts.snap index d0445764ecf..05605828a0e 100644 --- a/test/unit/persisters/__snapshots__/mergeable.test.ts.snap +++ b/test/unit/persisters/__snapshots__/mergeable.test.ts.snap @@ -6786,6 +6786,716 @@ exports[`Persists to/from mockMergeableNoContentListener > saves 2`] = ` ] `; +exports[`Persists to/from mssql > autoLoads 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF0----7JQY8", + 4065945599, + ], + }, + "", + 1279994494, + ], + }, + "", + 1293085726, + ], + }, + "", + 4033596827, + ], + [ + {}, + "", + 0, + ], +] +`; + +exports[`Persists to/from mssql > autoLoads 2`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 2, + "Nn1JUF1----7JQY8", + 2669080357, + ], + }, + "", + 274319047, + ], + }, + "", + 4089057354, + ], + }, + "", + 3386696034, + ], + [ + {}, + "", + 0, + ], +] +`; + +exports[`Persists to/from mssql > autoLoads 3`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 3, + "Nn1JUF2----7JQY8", + 3252714811, + ], + }, + "", + 1416411412, + ], + }, + "", + 3704904231, + ], + }, + "", + 4008152259, + ], + [ + {}, + "", + 0, + ], +] +`; + +exports[`Persists to/from mssql > autoLoads 4`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 3, + "Nn1JUF2----7JQY8", + 3252714811, + ], + }, + "", + 1416411412, + ], + }, + "", + 3704904231, + ], + }, + "", + 4008152259, + ], + [ + {}, + "", + 0, + ], +] +`; + +exports[`Persists to/from mssql > autoSave & autoLoad: roundtrip 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + "c2": [ + undefined, + "Nn1JUF----77JQY8", + 1267840296, + ], + }, + "", + 2533574593, + ], + "r2": [ + { + "c2": [ + undefined, + "Nn1JUF----67JQY8", + 2770824445, + ], + }, + "", + 811264265, + ], + }, + "", + 2755269984, + ], + "t2": [ + { + "r2": [ + { + "c2": [ + undefined, + "Nn1JUF----57JQY8", + 755827458, + ], + }, + "", + 3087140291, + ], + }, + "", + 1225184002, + ], + }, + "", + 3340350845, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----37JQY8", + 3542219356, + ], + "v2": [ + undefined, + "Nn1JUF----87JQY8", + 2233415979, + ], + }, + "", + 2110402117, + ], +] +`; + +exports[`Persists to/from mssql > autoSave & autoLoad: roundtrip 2`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + "c2": [ + "", + "Nn1JUF----77JQY8", + 1267840296, + ], + }, + "", + 2533574593, + ], + "r2": [ + { + "c2": [ + "", + "Nn1JUF----67JQY8", + 2770824445, + ], + }, + "", + 811264265, + ], + }, + "", + 2755269984, + ], + "t2": [ + { + "r2": [ + { + "c2": [ + "", + "Nn1JUF----57JQY8", + 755827458, + ], + }, + "", + 3087140291, + ], + }, + "", + 1225184002, + ], + }, + "", + 3340350845, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----37JQY8", + 3542219356, + ], + "v2": [ + "", + "Nn1JUF----87JQY8", + 2233415979, + ], + }, + "", + 2110402117, + ], +] +`; + +exports[`Persists to/from mssql > autoSave & autoLoad: roundtrip 3`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + "c2": [ + "", + "Nn1JUF----77JQY8", + 1267840296, + ], + }, + "", + 2533574593, + ], + "r2": [ + { + "c2": [ + "", + "Nn1JUF----67JQY8", + 2770824445, + ], + }, + "", + 811264265, + ], + }, + "", + 2755269984, + ], + "t2": [ + { + "r2": [ + { + "c2": [ + "", + "Nn1JUF----57JQY8", + 755827458, + ], + }, + "", + 3087140291, + ], + }, + "", + 1225184002, + ], + }, + "", + 3340350845, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----37JQY8", + 3542219356, + ], + "v2": [ + "", + "Nn1JUF----87JQY8", + 2233415979, + ], + }, + "", + 2110402117, + ], +] +`; + +exports[`Persists to/from mssql > autoSaves > delCell 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + "c2": [ + "", + "Nn1JUFc----7JQY8", + 4036784140, + ], + }, + "", + 1580055069, + ], + }, + "", + 3198578267, + ], + }, + "", + 3962176564, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----07JQY8", + 1130939691, + ], + "v2": [ + 2, + "Nn1JUFJ----7JQY8", + 3659329950, + ], + }, + "", + 143526703, + ], +] +`; + +exports[`Persists to/from mssql > autoSaves > delValue 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + "c2": [ + "", + "Nn1JUFc----7JQY8", + 4036784140, + ], + }, + "", + 1580055069, + ], + }, + "", + 3198578267, + ], + }, + "", + 3962176564, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----07JQY8", + 1130939691, + ], + "v2": [ + "", + "Nn1JUFw----7JQY8", + 1709187352, + ], + }, + "", + 2494864705, + ], +] +`; + +exports[`Persists to/from mssql > autoSaves > initial 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + }, + "", + 550994372, + ], + }, + "", + 1072852846, + ], + }, + "", + 1771939739, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----07JQY8", + 1130939691, + ], + }, + "", + 3877632732, + ], +] +`; + +exports[`Persists to/from mssql > autoSaves > setTables 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + "c2": [ + 2, + "Nn1JUF----17JQY8", + 1804136345, + ], + }, + "", + 3439185877, + ], + }, + "", + 855391547, + ], + }, + "", + 262414121, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----07JQY8", + 1130939691, + ], + }, + "", + 3877632732, + ], +] +`; + +exports[`Persists to/from mssql > autoSaves > setValues 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + "c2": [ + 2, + "Nn1JUF----17JQY8", + 1804136345, + ], + }, + "", + 3439185877, + ], + }, + "", + 855391547, + ], + }, + "", + 262414121, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----07JQY8", + 1130939691, + ], + "v2": [ + 2, + "Nn1JUFJ----7JQY8", + 3659329950, + ], + }, + "", + 143526703, + ], +] +`; + +exports[`Persists to/from mssql > loads 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 4065945599, + ], + }, + "", + 1279994494, + ], + }, + "", + 1293085726, + ], + }, + "", + 4033596827, + ], + [ + { + "v1": [ + 1, + "Nn1JUF-----7JQY8", + 4065945599, + ], + }, + "", + 2304392760, + ], +] +`; + +exports[`Persists to/from mssql > saves 1`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + }, + "", + 550994372, + ], + }, + "", + 1072852846, + ], + }, + "", + 1771939739, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----07JQY8", + 1130939691, + ], + }, + "", + 3877632732, + ], +] +`; + +exports[`Persists to/from mssql > saves 2`] = ` +[ + [ + { + "t1": [ + { + "r1": [ + { + "c1": [ + 1, + "Nn1JUF-----7JQY8", + 1003668370, + ], + }, + "", + 550994372, + ], + }, + "", + 1072852846, + ], + }, + "", + 1771939739, + ], + [ + { + "v1": [ + 1, + "Nn1JUF----07JQY8", + 1130939691, + ], + }, + "", + 3877632732, + ], +] +`; + exports[`Persists to/from opfs > autoSaves > delCell 1`] = ` [ [ diff --git a/test/unit/persisters/common/databases.ts b/test/unit/persisters/common/databases.ts index 0216d6c5200..d798c425604 100644 --- a/test/unit/persisters/common/databases.ts +++ b/test/unit/persisters/common/databases.ts @@ -17,6 +17,7 @@ import type {ElectricClient} from 'electric-sql/client/model'; import {DbSchema} from 'electric-sql/client/model'; import {ElectricDatabase, electrify} from 'electric-sql/wa-sqlite'; import 'fake-indexeddb/auto'; +import {ConnectionPool} from 'mssql'; import {DatabaseSync} from 'node:sqlite'; import type {PoolClient} from 'pg'; import {Pool} from 'pg'; @@ -29,6 +30,7 @@ import {createBetterSqlite3Persister} from 'tinybase/persisters/persister-better import {createCrSqliteWasmPersister} from 'tinybase/persisters/persister-cr-sqlite-wasm'; import {createElectricSqlPersister} from 'tinybase/persisters/persister-electric-sql'; import {createLibSqlPersister} from 'tinybase/persisters/persister-libsql'; +import {createMsSqlPersister} from 'tinybase/persisters/persister-mssql'; import {createPgPersister} from 'tinybase/persisters/persister-pg'; import {createPglitePersister} from 'tinybase/persisters/persister-pglite'; import {createPostgresPersister} from 'tinybase/persisters/persister-postgres'; @@ -55,6 +57,7 @@ export type Variants = {[name: string]: DatabaseVariant}; export type SqliteWasmDb = [sqlite3: any, db: any]; export type SqlClientsAndName = [Sql, ReservedSql, string]; export type PgClientsAndName = [Pool, PoolClient, Mutex, string]; +export type MsSqlPoolsAndName = [ConnectionPool, ConnectionPool, Mutex, string]; const PG_ADMIN_URL = 'postgres://localhost:5432/postgres'; const PG_OPTIONS = '-c client_min_messages=warning'; @@ -68,6 +71,27 @@ const pgAdmin = async (sql: string) => { await adminPool.end(); }; +// SQL Server needs credentials, so unlike the trust-authenticated PostgreSQL +// above, these come from the environment rather than being hard-coded. Point +// them at a scratch instance holding nothing but test data. +const getMsSqlConfig = (database: string) => ({ + server: process.env.TINYBASE_MSSQL_SERVER ?? 'localhost', + port: Number(process.env.TINYBASE_MSSQL_PORT ?? 1433), + user: process.env.TINYBASE_MSSQL_USER ?? 'sa', + password: process.env.TINYBASE_MSSQL_PASSWORD ?? '', + database, + pool: {max: 20}, + options: {encrypt: false, trustServerCertificate: true}, +}); + +const msSqlAdmin = async (sql: string) => { + const adminPool = await new ConnectionPool( + getMsSqlConfig('master'), + ).connect(); + await adminPool.request().query(sql); + await adminPool.close(); +}; + const electricSchema = new DbSchema({}, [], []); type Electric = ElectricClient; @@ -99,11 +123,37 @@ type DatabaseVariant = [ close: (db: Database) => Promise, autoLoadPause?: number, autoLoadIntervalSeconds?: number, - isPostgres?: boolean, + dialect?: DatabaseDialect, supportsMultipleConnections?: boolean, skipSqlChecks?: boolean, ]; +// Undefined means SQLite, which is the shape most of the matrix has. +export type DatabaseDialect = 'postgresql' | 'mssql'; + +// What INFORMATION_SCHEMA and friends report a column as. +export const getColumnType = (dialect?: DatabaseDialect) => + dialect == 'postgresql' ? 'text' : dialect == 'mssql' ? 'nvarchar' : ''; + +// What to write in a CREATE TABLE. SQL Server needs an explicit length, +// since a bare nvarchar means nvarchar(1), and its widest indexable one +// is used so that the same type works for the row Id primary key too. +export const getDdlColumnType = (dialect?: DatabaseDialect) => + dialect == 'mssql' ? 'nvarchar(450)' : getColumnType(dialect); + +export const getPlaceholder = + (dialect?: DatabaseDialect) => + (number: number): string => + dialect == 'postgresql' + ? '$' + number + : dialect == 'mssql' + ? '@p' + number + : '?'; + +// Both of the server dialects store Cells and Values JSON-encoded. +export const usesJsonValues = (dialect?: DatabaseDialect) => + dialect != undefined; + export const getStoreContentWaiter = (pauseMilliseconds: number) => (store: Store, content: Content): Promise => @@ -113,6 +163,7 @@ export const getStoreContentWaiter = ); const escapeId = (str: string) => `"${str.replace(/"/g, '""')}"`; +const escapeString = (str: string) => `'${str.replace(/'/g, `''`)}'`; const getPowerSyncDatabase = async ( dbFilename: string, @@ -513,7 +564,7 @@ export const NODE_POSTGRESQL_VARIANTS: Variants = { }, 20, undefined, - true, + 'postgresql', true, ], pg: [ @@ -565,7 +616,7 @@ export const NODE_POSTGRESQL_VARIANTS: Variants = { }, 20, undefined, - true, + 'postgresql', true, ], pglite: [ @@ -601,7 +652,7 @@ export const NODE_POSTGRESQL_VARIANTS: Variants = { async () => {}, undefined, undefined, - true, + 'postgresql', ], }; @@ -632,6 +683,64 @@ export const BUN_MERGEABLE_VARIANTS: Variants = { ], }; +export const NODE_MSSQL_VARIANTS: Variants = { + mssql: [ + async ( + msSqlPoolsAndName?: MsSqlPoolsAndName, + ): Promise => { + const existingName = msSqlPoolsAndName?.[3]; + const name = existingName ?? 'tinybase_' + getUniqueId(); + if (!existingName) { + await msSqlAdmin('CREATE DATABASE ' + escapeId(name)); + } + const pool = await new ConnectionPool(getMsSqlConfig(name)).connect(); + // Commands are issued as transactions, so they need one stable + // connection rather than an arbitrary one from the pool each time. + const cmdPool = await new ConnectionPool({ + ...getMsSqlConfig(name), + pool: {min: 1, max: 1}, + }).connect(); + return [pool, cmdPool, new Mutex(), name]; + }, + ['getMsSql', ([pool]: MsSqlPoolsAndName) => pool], + (store, [pool], storeTableOrConfig, onSqlCommand, onIgnoredError) => + (createMsSqlPersister as any)( + store, + pool, + storeTableOrConfig, + onSqlCommand, + onIgnoredError, + ), + ( + [, cmdPool, cmdMutex]: MsSqlPoolsAndName, + sqlStr: string, + args: any[] = [], + ) => + cmdMutex.runExclusive(async () => { + const request = cmdPool.request(); + args.forEach((arg, index) => request.input('p' + (index + 1), arg)); + return (await request.query(sqlStr)).recordset ?? []; + }), + async ([pool, cmdPool, , name]: MsSqlPoolsAndName) => { + await Promise.race([pool.close().catch(noop), pause(100)]); + await Promise.race([cmdPool.close().catch(noop), pause(100)]); + // Both handles of a two-connection test name the same database, so + // the second close finds it already gone. Remaining connections also + // have to be booted before the drop can proceed. + await msSqlAdmin( + `IF DB_ID(${escapeString(name)}) IS NOT NULL BEGIN ` + + `ALTER DATABASE ${escapeId(name)} ` + + 'SET SINGLE_USER WITH ROLLBACK IMMEDIATE;' + + `DROP DATABASE ${escapeId(name)};END`, + ); + }, + 20, + undefined, + 'mssql', + true, + ], +}; + export const NODE_SQLITE_VARIANTS: Variants = { ...NODE_SQLITE_MERGEABLE_VARIANTS, ...NODE_SQLITE_NON_MERGEABLE_VARIANTS, @@ -640,6 +749,7 @@ export const NODE_SQLITE_VARIANTS: Variants = { export const NODE_MERGEABLE_VARIANTS: Variants = { ...NODE_SQLITE_MERGEABLE_VARIANTS, ...NODE_POSTGRESQL_VARIANTS, + ...NODE_MSSQL_VARIANTS, }; export const ALL_NODE_VARIANTS: Variants = { @@ -647,6 +757,13 @@ export const ALL_NODE_VARIANTS: Variants = { ...NODE_POSTGRESQL_VARIANTS, }; +// The SQL Server Persister only supports JSON serialization so far, so it +// joins the JSON suites but not the tabular one. +export const ALL_NODE_JSON_VARIANTS: Variants = { + ...ALL_NODE_VARIANTS, + ...NODE_MSSQL_VARIANTS, +}; + export const ALL_BUN_VARIANTS: Variants = { ...BUN_MERGEABLE_VARIANTS, }; @@ -657,6 +774,10 @@ export const MERGEABLE_VARIANTS = isBun export const ALL_VARIANTS = isBun ? ALL_BUN_VARIANTS : ALL_NODE_VARIANTS; +export const ALL_JSON_VARIANTS = isBun + ? ALL_BUN_VARIANTS + : ALL_NODE_JSON_VARIANTS; + export const ADHOC_VARIANTS: Variants = { adhoc: NODE_SQLITE_NON_MERGEABLE_VARIANTS.crSqliteWasm, }; @@ -667,27 +788,36 @@ export const getDatabaseFunctions = ( sql: string, args?: any[], ) => Promise<{[id: string]: any}[]>, - isPostgres = false, + dialect?: DatabaseDialect, jsonValues = false, ): [ (db: Database) => Promise, (db: Database, dump: DumpIn) => Promise, (db: Database, dump: DumpOut) => Promise, ] => { - const placeholder = (number: number) => (isPostgres ? '$' + number : '?'); + const placeholder = getPlaceholder(dialect); const getDatabase = async (db: Database): Promise => { const dump: DumpOut = {}; ( await cmd( db, - isPostgres + dialect == 'postgresql' ? 'SELECT table_name tn, column_name cn, data_type ty ' + 'FROM information_schema.columns ' + `WHERE table_schema='public' ` + `AND table_name NOT LIKE ${placeholder(1)} ` + `AND table_name NOT LIKE ${placeholder(2)}` - : 'SELECT t.name tn, c.name cn, LOWER(c.type) ty ' + + : dialect == 'mssql' + ? // The rowversion column that the Persister maintains for + // auto-loading is excluded, since it is not part of the schema + // that TinyBase itself manages. + 'SELECT TABLE_NAME tn, COLUMN_NAME cn, DATA_TYPE ty ' + + 'FROM INFORMATION_SCHEMA.COLUMNS ' + + `WHERE TABLE_SCHEMA=SCHEMA_NAME() AND DATA_TYPE<>'timestamp' ` + + `AND TABLE_NAME NOT LIKE ${placeholder(1)} ` + + `AND TABLE_NAME NOT LIKE ${placeholder(2)}` + : 'SELECT t.name tn, c.name cn, LOWER(c.type) ty ' + 'FROM pragma_table_list() t, ' + 'pragma_table_info(t.name) c ' + `WHERE t.schema='main' AND t.type = 'table' ` + @@ -723,7 +853,13 @@ export const getDatabaseFunctions = ( }; const setDatabase = async (db: Database, dump: DumpIn) => { - await cmd(db, 'BEGIN'); + // The mssql module drives transactions through its own Transaction + // object, so raw statements issued on pooled connections leave the + // transaction count unbalanced. Seeding does not need to be atomic. + const transactional = dialect != 'mssql'; + if (transactional) { + await cmd(db, 'BEGIN'); + } await Promise.all( Object.entries(dump).map(async ([name, [sql, rows]]) => { await cmd(db, sql); @@ -755,7 +891,9 @@ export const getDatabaseFunctions = ( ); }), ); - await cmd(db, 'END'); + if (transactional) { + await cmd(db, 'END'); + } }; const expectDatabaseContent = (db: Database, dump: DumpOut): Promise => diff --git a/test/unit/persisters/common/mocks.ts b/test/unit/persisters/common/mocks.ts index f13ef93996d..b256571cb60 100644 --- a/test/unit/persisters/common/mocks.ts +++ b/test/unit/persisters/common/mocks.ts @@ -39,7 +39,7 @@ import type {LocalSynchronizer} from 'tinybase/synchronizers/synchronizer-local' import {createLocalSynchronizer} from 'tinybase/synchronizers/synchronizer-local'; import tmp from 'tmp'; import {Doc as YDoc, Map as YMap} from 'yjs'; -import {Variants} from './databases.ts'; +import {DatabaseDialect, getPlaceholder, Variants} from './databases.ts'; import {GetLocationMethod, Persistable} from './other.ts'; tmp.setGracefulCleanup(); @@ -237,11 +237,12 @@ const getMockedDatabase = ( close: (location: Location) => Promise, autoLoadPause = 2, autoLoadIntervalSeconds = 0.001, - isPostgres = false, + dialect: DatabaseDialect | undefined = undefined, _supportsMultipleConnections = false, _skipSqlChecks = false, ): Persistable => { - const placeholder = (number: number) => (isPostgres ? '$' + number : '?'); + const placeholder = getPlaceholder(dialect); + const isMsSql = dialect == 'mssql'; const mockDatabase = { getLocation, getLocationMethod, @@ -263,16 +264,27 @@ const getMockedDatabase = ( set: async (location: Location, rawContent: any): Promise => await mockDatabase.write(location, JSON.stringify(rawContent)), write: async (location: Location, rawContent: any): Promise => { + // SQL Server has neither IF NOT EXISTS nor ON CONFLICT. await cmd( location, - 'CREATE TABLE IF NOT EXISTS tinybase ' + - '(_id text PRIMARY KEY, store text);', + isMsSql + ? `IF OBJECT_ID('tinybase','U') IS NULL CREATE TABLE tinybase ` + + '(_id nvarchar(450) PRIMARY KEY, store nvarchar(max));' + : 'CREATE TABLE IF NOT EXISTS tinybase ' + + '(_id text PRIMARY KEY, store text);', ); await cmd( location, - `INSERT INTO tinybase (_id, store) VALUES (${placeholder(1)}, ` + - `${placeholder(2)}) ` + - 'ON CONFLICT (_id) DO UPDATE SET store=excluded.store', + isMsSql + ? 'MERGE INTO tinybase WITH(HOLDLOCK) AS t USING(VALUES(' + + `${placeholder(1)},${placeholder(2)})) AS s(_id, store) ` + + 'ON t._id=s._id ' + + 'WHEN MATCHED THEN UPDATE SET t.store=s.store ' + + 'WHEN NOT MATCHED THEN INSERT(_id, store) ' + + 'VALUES(s._id, s.store);' + : `INSERT INTO tinybase (_id, store) VALUES (${placeholder(1)}, ` + + `${placeholder(2)}) ` + + 'ON CONFLICT (_id) DO UPDATE SET store=excluded.store', ['_', rawContent], ); }, diff --git a/test/unit/persisters/database/json.test.ts b/test/unit/persisters/database/json.test.ts index 702678ff142..8dc4000dd8a 100644 --- a/test/unit/persisters/database/json.test.ts +++ b/test/unit/persisters/database/json.test.ts @@ -5,12 +5,15 @@ import type {Persister} from 'tinybase/persisters'; import {afterEach, beforeEach, describe, expect, test} from 'vitest'; import {pause, waitFor} from '../../common/other.ts'; import { - ALL_VARIANTS, + ALL_JSON_VARIANTS, + getColumnType, getDatabaseFunctions, + getDdlColumnType, + getPlaceholder, getStoreContentWaiter, } from '../common/databases.ts'; -describe.each(Object.entries(ALL_VARIANTS))( +describe.each(Object.entries(ALL_JSON_VARIANTS))( '%s', ( name, @@ -22,16 +25,17 @@ describe.each(Object.entries(ALL_VARIANTS))( close, autoLoadPause = 3, autoLoadIntervalSeconds = 0.001, - isPostgres, + dialect, supportsMultipleConnections, ], ) => { - const [getDatabase, setDatabase] = getDatabaseFunctions(cmd, isPostgres); + const [getDatabase, setDatabase] = getDatabaseFunctions(cmd, dialect); const expectStoreContent = getStoreContentWaiter(autoLoadPause); - const columnType = isPostgres ? 'text' : ''; + const columnType = getColumnType(dialect); + const ddlColumnType = getDdlColumnType(dialect); const placeholders = (...numbers: number[]) => - numbers.map((number) => (isPostgres ? '$' + number : '?')).join(','); + numbers.map(getPlaceholder(dialect)).join(','); let db: any; let store: Store; @@ -254,9 +258,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase2: [ 'CREATE TABLE "tinybase2"("a" ' + - columnType + + ddlColumnType + ' PRIMARY KEY,"b" ' + - columnType + + ddlColumnType + ')', [{a: 'a', b: 'b'}], ], @@ -275,9 +279,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [], ], @@ -295,9 +299,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: 'a', store: 'b'}], ], @@ -314,7 +318,7 @@ describe.each(Object.entries(ALL_VARIANTS))( test('table, empty, missing key', async () => { await setDatabase(db, { tinybase: [ - 'CREATE TABLE "tinybase" ("store" ' + columnType + ')', + 'CREATE TABLE "tinybase" ("store" ' + ddlColumnType + ')', [], ], }); @@ -330,7 +334,7 @@ describe.each(Object.entries(ALL_VARIANTS))( test('table, empty, missing column', async () => { await setDatabase(db, { tinybase: [ - 'CREATE TABLE "tinybase" ("_id" ' + columnType + ' PRIMARY KEY)', + 'CREATE TABLE "tinybase" ("_id" ' + ddlColumnType + ' PRIMARY KEY)', [], ], }); @@ -347,9 +351,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY,"b" ' + - columnType + + ddlColumnType + ')', [], ], @@ -367,11 +371,11 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ', "b" ' + - columnType + + ddlColumnType + ')', [], ], @@ -401,9 +405,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":1}]'}], ], @@ -416,9 +420,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":}]'}], ], @@ -431,9 +435,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":{"r1":{"c1":1}}},{}]'}], ], @@ -446,9 +450,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{}, {"v1":1}]'}], ], @@ -461,9 +465,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [ { @@ -487,9 +491,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":{"r1":{"c1":1}}},{"v1":1}]'}], ], @@ -518,9 +522,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":{"r1":{"c1":1}}},{"v1":1}]'}], ], @@ -542,9 +546,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":{"r1":{"c1":1}}},{"v1":1}]'}], ], @@ -564,9 +568,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":{"r1":{"c1":1}}},{"v1":1}]'}], ], @@ -577,9 +581,9 @@ describe.each(Object.entries(ALL_VARIANTS))( await cmd( db, 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', ); await cmd( diff --git a/test/unit/persisters/database/mergeable-json.test.ts b/test/unit/persisters/database/mergeable-json.test.ts index 382c11b72fc..e579bad4715 100644 --- a/test/unit/persisters/database/mergeable-json.test.ts +++ b/test/unit/persisters/database/mergeable-json.test.ts @@ -8,7 +8,10 @@ import {getTimeFunctions} from '../../common/mergeable.ts'; import {waitFor} from '../../common/other.ts'; import { MERGEABLE_VARIANTS, + getColumnType, getDatabaseFunctions, + getDdlColumnType, + getPlaceholder, getStoreContentWaiter, } from '../common/databases.ts'; @@ -30,16 +33,17 @@ describe.each(Object.entries(MERGEABLE_VARIANTS))( close, autoLoadPause = 3, autoLoadIntervalSeconds = 0.001, - isPostgres, + dialect, supportsMultipleConnections, ], ) => { - const [getDatabase, setDatabase] = getDatabaseFunctions(cmd, isPostgres); + const [getDatabase, setDatabase] = getDatabaseFunctions(cmd, dialect); const expectStoreContent = getStoreContentWaiter(autoLoadPause); - const columnType = isPostgres ? 'text' : ''; + const columnType = getColumnType(dialect); + const ddlColumnType = getDdlColumnType(dialect); const placeholders = (...numbers: number[]) => - numbers.map((number) => (isPostgres ? '$' + number : '?')).join(','); + numbers.map(getPlaceholder(dialect)).join(','); let db: any; let store: MergeableStore; @@ -231,9 +235,9 @@ describe.each(Object.entries(MERGEABLE_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":1}]'}], ], @@ -246,9 +250,9 @@ describe.each(Object.entries(MERGEABLE_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":}]'}], ], @@ -261,9 +265,9 @@ describe.each(Object.entries(MERGEABLE_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":{"r1":{"c1":1}}},{}]'}], ], @@ -276,9 +280,9 @@ describe.each(Object.entries(MERGEABLE_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{}, {"v1":1}]'}], ], @@ -292,9 +296,9 @@ describe.each(Object.entries(MERGEABLE_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":{"r1":{"c1":1}}},{"v1":1}]'}], ], @@ -322,9 +326,9 @@ describe.each(Object.entries(MERGEABLE_VARIANTS))( await setDatabase(db, { tinybase: [ 'CREATE TABLE "tinybase" ("_id" ' + - columnType + + ddlColumnType + ' PRIMARY KEY, "store" ' + - columnType + + ddlColumnType + ')', [{_id: '_', store: '[{"t1":{"r1":{"c1":1}}},{"v1":1}]'}], ], diff --git a/test/unit/persisters/database/mssql.test.ts b/test/unit/persisters/database/mssql.test.ts new file mode 100644 index 00000000000..4a72a1e7eab --- /dev/null +++ b/test/unit/persisters/database/mssql.test.ts @@ -0,0 +1,321 @@ +import {createMergeableStore, createStore} from 'tinybase'; +import {createCustomMsSqlPersister, Persists} from 'tinybase/persisters'; +import {expect, test, vi} from 'vitest'; +import {pause} from '../../common/other.ts'; + +const STORE_TABLE_NAME = 'tinybase'; + +type FakeMsSql = { + commands: [sql: string, params: any[]][]; + executeCommand: (sql: string, params?: any[]) => Promise; + failWith: Error | null; + hasTable: boolean; + hasVersionColumn: boolean; + store: string | null; + version: number; + sqlMatching: (pattern: RegExp) => string[]; +}; + +// A deliberately literal-minded stand-in for SQL Server: it only answers a +// query if the SQL would really have worked, so invalid T-SQL shows up as a +// failing expectation rather than passing silently. +const getFakeMsSql = (): FakeMsSql => { + const fake: FakeMsSql = { + commands: [], + failWith: null, + hasTable: false, + hasVersionColumn: false, + store: null, + version: 1, + executeCommand: async (sql: string, params: any[] = []) => { + fake.commands.push([sql, params]); + if (fake.failWith) { + throw fake.failWith; + } + if (sql.startsWith('SELECT c.TABLE_NAME')) { + return fake.hasTable + ? [ + {tn: STORE_TABLE_NAME, cn: '_id', uq: 1}, + {tn: STORE_TABLE_NAME, cn: 'store', uq: 0}, + // A rowversion column is only ever reported when the query has + // not excluded it. + ...(fake.hasVersionColumn && + !sql.includes(`c.DATA_TYPE<>'timestamp'`) + ? [{tn: STORE_TABLE_NAME, cn: '_version', uq: 0}] + : []), + ] + : []; + } + if (sql.startsWith('IF OBJECT_ID')) { + if (fake.hasTable) { + fake.hasVersionColumn = true; + } + return []; + } + if (sql.startsWith('SELECT CONVERT(bigint')) { + if (!fake.hasTable) { + throw new Error(`Invalid object name '${STORE_TABLE_NAME}'.`); + } + if (!fake.hasVersionColumn) { + throw new Error(`Invalid column name '_version'.`); + } + return [{v: '' + fake.version}]; + } + if (sql.startsWith('CREATE TABLE')) { + fake.hasTable = true; + return []; + } + if (sql.startsWith('MERGE INTO')) { + fake.store = params[1]; + fake.version++; + return []; + } + if (sql.startsWith('SELECT*FROM')) { + return fake.store == null ? [] : [{_id: '_', store: fake.store}]; + } + return []; + }, + sqlMatching: (pattern: RegExp) => + fake.commands.map(([sql]) => sql).filter((sql) => pattern.test(sql)), + }; + return fake; +}; + +const getPersister = ( + fake: FakeMsSql, + store = createStore(), + onIgnoredError?: (error: any) => void, + autoLoadIntervalSeconds = 0.01, +) => + createCustomMsSqlPersister( + store, + {mode: 'json', storeTableName: STORE_TABLE_NAME, autoLoadIntervalSeconds}, + fake.executeCommand, + undefined, + onIgnoredError, + () => {}, + Persists.StoreOrMergeableStore, + fake, + 'getMsSql', + ); + +// eslint-disable-next-line max-len +test('generates T-SQL rather than the shared PostgreSQL and SQLite spellings', async () => { + const fake = getFakeMsSql(); + const store = createStore().setTables({pets: {fido: {species: 'dog'}}}); + const persister = getPersister(fake, store); + + await persister.save(); + + const sql = fake.commands.map(([oneSql]) => oneSql); + // T-SQL has no boolean literal, and BEGIN and END are block delimiters. + expect(sql).toContain('BEGIN TRANSACTION'); + expect(sql).toContain('COMMIT'); + expect(sql.join('\n')).not.toMatch(/\btrue\b/); + expect(fake.sqlMatching(/^DELETE FROM/)[0]).toContain('WHERE(1=1)'); + // nvarchar(max) cannot be a primary key, so the row Id column is narrower. + expect(fake.sqlMatching(/^CREATE TABLE/)[0]).toBe( + 'CREATE TABLE"tinybase"("_id"nvarchar(450) PRIMARY KEY,' + + '"store"nvarchar(max));', + ); + // There is no ON CONFLICT in SQL Server. + expect(fake.sqlMatching(/^MERGE INTO/)[0]).toBe( + 'MERGE INTO"tinybase" WITH(HOLDLOCK)AS t USING(VALUES(@p1,@p2))' + + 'AS s("_id","store")ON t."_id"=s."_id" ' + + 'WHEN MATCHED THEN UPDATE SET t."store"=s."store" ' + + 'WHEN NOT MATCHED THEN INSERT("_id","store")' + + 'VALUES(s."_id",s."store");', + ); + + await persister.destroy(); +}); + +test('round-trips a Store through the database', async () => { + const fake = getFakeMsSql(); + const store = createStore() + .setTables({pets: {fido: {species: 'dog'}}}) + .setValues({employees: 3}); + const persister = getPersister(fake, store); + await persister.save(); + await persister.destroy(); + + expect(fake.store).toBe( + '[{"pets":{"fido":{"species":"dog"}}},{"employees":3}]', + ); + + const store2 = createStore(); + const persister2 = getPersister(fake, store2); + await persister2.load(); + expect(store2.getTables()).toEqual({pets: {fido: {species: 'dog'}}}); + expect(store2.getValues()).toEqual({employees: 3}); + await persister2.destroy(); +}); + +test('round-trips a MergeableStore through the database', async () => { + const fake = getFakeMsSql(); + const store = createMergeableStore('s1').setTables({ + pets: {fido: {species: 'dog'}}, + }); + const persister = getPersister(fake, store); + await persister.save(); + await persister.destroy(); + + const store2 = createMergeableStore('s2'); + const persister2 = getPersister(fake, store2); + await persister2.load(); + expect(store2.getTables()).toEqual({pets: {fido: {species: 'dog'}}}); + await persister2.destroy(); +}); + +// eslint-disable-next-line max-len +test('hides the rowversion column from the shared schema handling', async () => { + const fake = getFakeMsSql(); + const persister = getPersister( + fake, + createStore().setTables({pets: {fido: {species: 'dog'}}}), + ); + + // The column can only be added once a save has created the table. + await persister.save(); + await persister.startAutoLoad(); + // A subsequent save is the risky one: the shared save path drops any column + // it does not know about. + expect(fake.hasVersionColumn).toBe(true); + + fake.commands.length = 0; + await persister.save(); + expect(fake.sqlMatching(/^SELECT c\.TABLE_NAME/)[0]).toContain( + `c.DATA_TYPE<>'timestamp'`, + ); + expect(fake.sqlMatching(/DROP COLUMN/)).toEqual([]); + + await persister.destroy(); +}); + +test('adds the rowversion column once the table exists', async () => { + const fake = getFakeMsSql(); + const persister = getPersister(fake, createStore()); + + // Nothing to add to yet, and the probe cannot work. + await persister.startAutoLoad(); + expect(fake.hasVersionColumn).toBe(false); + expect(fake.sqlMatching(/^IF OBJECT_ID/).length).toBeGreaterThan(0); + + // Once a save has created the table, the probe recovers by itself. + fake.hasTable = true; + await pause(50); + expect(fake.hasVersionColumn).toBe(true); + + await persister.destroy(); +}); + +test('auto-loads when another writer changes the rowversion', async () => { + const fake = getFakeMsSql(); + fake.hasTable = true; + fake.hasVersionColumn = true; + fake.store = '[{"pets":{"fido":{"species":"dog"}}},{}]'; + const store = createStore(); + const persister = getPersister(fake, store); + + await persister.startAutoLoad(); + expect(store.getTables()).toEqual({pets: {fido: {species: 'dog'}}}); + + // A change from outside of TinyBase bumps the rowversion. + fake.store = '[{"pets":{"felix":{"species":"cat"}}},{}]'; + fake.version++; + await pause(100); + expect(store.getTables()).toEqual({pets: {felix: {species: 'cat'}}}); + + await persister.destroy(); +}); + +test('does not load again while the rowversion is unchanged', async () => { + const fake = getFakeMsSql(); + fake.hasTable = true; + fake.hasVersionColumn = true; + fake.store = '[{"pets":{"fido":{"species":"dog"}}},{}]'; + const persister = getPersister(fake, createStore()); + + await persister.startAutoLoad(); + fake.commands.length = 0; + await pause(100); + + // The version is polled, but nothing is read back. + expect(fake.sqlMatching(/^SELECT CONVERT\(bigint/).length).toBeGreaterThan(0); + expect(fake.sqlMatching(/^SELECT\*FROM/)).toEqual([]); + + await persister.destroy(); +}); + +test('stops polling once destroyed', async () => { + const fake = getFakeMsSql(); + fake.hasTable = true; + fake.hasVersionColumn = true; + fake.store = '[{},{}]'; + const persister = getPersister(fake, createStore()); + + await persister.startAutoLoad(); + await persister.destroy(); + + fake.commands.length = 0; + await pause(100); + expect(fake.commands).toEqual([]); +}); + +test('contains errors raised while polling', async () => { + const fake = getFakeMsSql(); + fake.hasTable = true; + fake.hasVersionColumn = true; + fake.store = '[{},{}]'; + const ignoredError = vi.fn(); + const persister = getPersister(fake, createStore(), ignoredError); + + await persister.startAutoLoad(); + ignoredError.mockClear(); + + fake.failWith = new Error('database is gone'); + await pause(100); + expect(ignoredError).toHaveBeenCalled(); + + fake.failWith = null; + await persister.destroy(); +}); + +test('rejects tabular mode, which is not yet supported', async () => { + const fake = getFakeMsSql(); + + expect(() => + createCustomMsSqlPersister( + createStore(), + {mode: 'tabular'}, + fake.executeCommand, + undefined, + undefined, + () => {}, + Persists.StoreOnly, + fake, + 'getMsSql', + ), + ).toThrow('tinybase:0'); + + expect(() => + createCustomMsSqlPersister( + createMergeableStore(), + {mode: 'tabular'}, + fake.executeCommand, + undefined, + undefined, + () => {}, + Persists.StoreOrMergeableStore, + fake, + 'getMsSql', + ), + ).toThrow('tinybase:0'); +}); + +test('returns the thing it was given', async () => { + const fake = getFakeMsSql(); + const persister = getPersister(fake, createStore()); + expect((persister as any).getMsSql()).toBe(fake); + await persister.destroy(); +}); diff --git a/test/unit/persisters/database/tabular.test.ts b/test/unit/persisters/database/tabular.test.ts index 946993eaab9..bee01aaeafd 100644 --- a/test/unit/persisters/database/tabular.test.ts +++ b/test/unit/persisters/database/tabular.test.ts @@ -10,6 +10,7 @@ import { ALL_VARIANTS, getDatabaseFunctions, getStoreContentWaiter, + usesJsonValues, } from '../common/databases.ts'; describe.each(Object.entries(ALL_VARIANTS))( @@ -24,13 +25,16 @@ describe.each(Object.entries(ALL_VARIANTS))( close, autoLoadPause = 3, autoLoadIntervalSeconds = 0.001, - isPostgres, + dialect, supportsMultipleConnections, skipSqlChecks, ], ) => { const [getDatabase, setDatabase, expectDatabaseContent] = - getDatabaseFunctions(cmd, isPostgres, isPostgres); + getDatabaseFunctions(cmd, dialect, usesJsonValues(dialect)); + // Tabular persistence is not yet supported for SQL Server, so this suite + // still only sees the SQLite and PostgreSQL variants. + const isPostgres = dialect == 'postgresql'; const expectStoreContent = getStoreContentWaiter(autoLoadPause); const columnType = isPostgres ? 'text' : '';